summaryrefslogtreecommitdiff
path: root/addons/account/wizard
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/account/wizard
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/account/wizard')
-rw-r--r--addons/account/wizard/__init__.py19
-rw-r--r--addons/account/wizard/account_automatic_entry_wizard.py431
-rw-r--r--addons/account/wizard/account_automatic_entry_wizard_views.xml56
-rw-r--r--addons/account/wizard/account_invoice_send.py134
-rw-r--r--addons/account/wizard/account_invoice_send_views.xml94
-rw-r--r--addons/account/wizard/account_move_reversal.py129
-rw-r--r--addons/account/wizard/account_move_reversal_view.xml60
-rw-r--r--addons/account/wizard/account_payment_register.py535
-rw-r--r--addons/account/wizard/account_payment_register_views.xml83
-rw-r--r--addons/account/wizard/account_report_common.py48
-rw-r--r--addons/account/wizard/account_report_common_journal.py15
-rw-r--r--addons/account/wizard/account_report_common_view.xml26
-rw-r--r--addons/account/wizard/account_report_print_journal.py17
-rw-r--r--addons/account/wizard/account_resequence.py145
-rw-r--r--addons/account/wizard/account_resequence_views.xml41
-rw-r--r--addons/account/wizard/account_tour_upload_bill.py98
-rw-r--r--addons/account/wizard/account_tour_upload_bill.xml49
-rw-r--r--addons/account/wizard/account_unreconcile.py12
-rw-r--r--addons/account/wizard/account_unreconcile_view.xml32
-rw-r--r--addons/account/wizard/account_validate_account_move.py23
-rw-r--r--addons/account/wizard/account_validate_move_view.xml38
-rw-r--r--addons/account/wizard/base_document_layout.py11
-rw-r--r--addons/account/wizard/pos_box.py54
-rw-r--r--addons/account/wizard/pos_box.xml30
-rw-r--r--addons/account/wizard/setup_wizards.py144
-rw-r--r--addons/account/wizard/setup_wizards_view.xml90
-rw-r--r--addons/account/wizard/wizard_tax_adjustments.py67
-rw-r--r--addons/account/wizard/wizard_tax_adjustments_view.xml39
28 files changed, 2520 insertions, 0 deletions
diff --git a/addons/account/wizard/__init__.py b/addons/account/wizard/__init__.py
new file mode 100644
index 00000000..313b31cc
--- /dev/null
+++ b/addons/account/wizard/__init__.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+
+from . import account_automatic_entry_wizard
+from . import account_unreconcile
+from . import account_validate_account_move
+from . import pos_box
+from . import account_move_reversal
+from . import account_report_common
+from . import account_report_common_journal
+from . import account_report_print_journal
+from . import account_resequence
+from . import setup_wizards
+from . import wizard_tax_adjustments
+from . import account_invoice_send
+from . import base_document_layout
+from . import account_payment_register
+from . import account_tour_upload_bill
diff --git a/addons/account/wizard/account_automatic_entry_wizard.py b/addons/account/wizard/account_automatic_entry_wizard.py
new file mode 100644
index 00000000..b0089c07
--- /dev/null
+++ b/addons/account/wizard/account_automatic_entry_wizard.py
@@ -0,0 +1,431 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+from odoo.tools.misc import format_date, formatLang
+
+from collections import defaultdict
+from itertools import groupby
+import json
+
+class AutomaticEntryWizard(models.TransientModel):
+ _name = 'account.automatic.entry.wizard'
+ _description = 'Create Automatic Entries'
+
+ # General
+ action = fields.Selection([('change_period', 'Change Period'), ('change_account', 'Change Account')], required=True)
+ move_data = fields.Text(compute="_compute_move_data", help="JSON value of the moves to be created")
+ preview_move_data = fields.Text(compute="_compute_preview_move_data", help="JSON value of the data to be displayed in the previewer")
+ move_line_ids = fields.Many2many('account.move.line')
+ date = fields.Date(required=True, default=lambda self: fields.Date.context_today(self))
+ company_id = fields.Many2one('res.company', required=True, readonly=True)
+ company_currency_id = fields.Many2one('res.currency', related='company_id.currency_id')
+ percentage = fields.Float("Percentage", compute='_compute_percentage', readonly=False, store=True, help="Percentage of each line to execute the action on.")
+ total_amount = fields.Monetary(compute='_compute_total_amount', store=True, readonly=False, currency_field='company_currency_id', help="Total amount impacted by the automatic entry.")
+ journal_id = fields.Many2one('account.journal', required=True, readonly=False, string="Journal",
+ domain="[('company_id', '=', company_id), ('type', '=', 'general')]",
+ compute="_compute_journal_id",
+ inverse="_inverse_journal_id",
+ help="Journal where to create the entry.")
+
+ # change period
+ account_type = fields.Selection([('income', 'Revenue'), ('expense', 'Expense')], compute='_compute_account_type', store=True)
+ expense_accrual_account = fields.Many2one('account.account', readonly=False,
+ domain="[('company_id', '=', company_id),"
+ "('internal_type', 'not in', ('receivable', 'payable')),"
+ "('is_off_balance', '=', False)]",
+ compute="_compute_expense_accrual_account",
+ inverse="_inverse_expense_accrual_account",
+ )
+ revenue_accrual_account = fields.Many2one('account.account', readonly=False,
+ domain="[('company_id', '=', company_id),"
+ "('internal_type', 'not in', ('receivable', 'payable')),"
+ "('is_off_balance', '=', False)]",
+ compute="_compute_revenue_accrual_account",
+ inverse="_inverse_revenue_accrual_account",
+ )
+
+ # change account
+ destination_account_id = fields.Many2one(string="To", comodel_name='account.account', help="Account to transfer to.")
+ display_currency_helper = fields.Boolean(string="Currency Conversion Helper", compute='_compute_display_currency_helper',
+ help="Technical field. Used to indicate whether or not to display the currency conversion tooltip. The tooltip informs a currency conversion will be performed with the transfer.")
+
+ @api.depends('company_id')
+ def _compute_expense_accrual_account(self):
+ for record in self:
+ record.expense_accrual_account = record.company_id.expense_accrual_account_id
+
+ def _inverse_expense_accrual_account(self):
+ for record in self:
+ record.company_id.sudo().expense_accrual_account_id = record.expense_accrual_account
+
+ @api.depends('company_id')
+ def _compute_revenue_accrual_account(self):
+ for record in self:
+ record.revenue_accrual_account = record.company_id.revenue_accrual_account_id
+
+ def _inverse_revenue_accrual_account(self):
+ for record in self:
+ record.company_id.sudo().revenue_accrual_account_id = record.revenue_accrual_account
+
+ @api.depends('company_id')
+ def _compute_journal_id(self):
+ for record in self:
+ record.journal_id = record.company_id.automatic_entry_default_journal_id
+
+ def _inverse_journal_id(self):
+ for record in self:
+ record.company_id.sudo().automatic_entry_default_journal_id = record.journal_id
+
+ @api.constrains('percentage', 'action')
+ def _constraint_percentage(self):
+ for record in self:
+ if not (0.0 < record.percentage <= 100.0) and record.action == 'change_period':
+ raise UserError(_("Percentage must be between 0 and 100"))
+
+ @api.depends('percentage', 'move_line_ids')
+ def _compute_total_amount(self):
+ for record in self:
+ record.total_amount = (record.percentage or 100) * sum(record.move_line_ids.mapped('balance')) / 100
+
+ @api.depends('total_amount', 'move_line_ids')
+ def _compute_percentage(self):
+ for record in self:
+ total = (sum(record.move_line_ids.mapped('balance')) or record.total_amount)
+ if total != 0:
+ record.percentage = (record.total_amount / total) * 100
+ else:
+ record.percentage = 100
+
+ @api.depends('move_line_ids')
+ def _compute_account_type(self):
+ for record in self:
+ record.account_type = 'income' if sum(record.move_line_ids.mapped('balance')) < 0 else 'expense'
+
+ @api.depends('destination_account_id')
+ def _compute_display_currency_helper(self):
+ for record in self:
+ record.display_currency_helper = bool(record.destination_account_id.currency_id)
+
+ @api.model
+ def default_get(self, fields):
+ res = super().default_get(fields)
+ if not set(fields) & set(['move_line_ids', 'company_id']):
+ return res
+
+ if self.env.context.get('active_model') != 'account.move.line' or not self.env.context.get('active_ids'):
+ raise UserError(_('This can only be used on journal items'))
+ move_line_ids = self.env['account.move.line'].browse(self.env.context['active_ids'])
+ res['move_line_ids'] = [(6, 0, move_line_ids.ids)]
+
+ if any(move.state != 'posted' for move in move_line_ids.mapped('move_id')):
+ raise UserError(_('You can only change the period/account for posted journal items.'))
+ if any(move_line.reconciled for move_line in move_line_ids):
+ raise UserError(_('You can only change the period/account for items that are not yet reconciled.'))
+ if any(line.company_id != move_line_ids[0].company_id for line in move_line_ids):
+ raise UserError(_('You cannot use this wizard on journal entries belonging to different companies.'))
+ res['company_id'] = move_line_ids[0].company_id.id
+
+ allowed_actions = set(dict(self._fields['action'].selection))
+ if self.env.context.get('default_action'):
+ allowed_actions = {self.env.context['default_action']}
+ if any(line.account_id.user_type_id != move_line_ids[0].account_id.user_type_id for line in move_line_ids):
+ allowed_actions.discard('change_period')
+ if not allowed_actions:
+ raise UserError(_('No possible action found with the selected lines.'))
+ res['action'] = allowed_actions.pop()
+ return res
+
+ def _get_move_dict_vals_change_account(self):
+ line_vals = []
+
+ # Group data from selected move lines
+ counterpart_balances = defaultdict(lambda: defaultdict(lambda: 0))
+ grouped_source_lines = defaultdict(lambda: self.env['account.move.line'])
+
+ for line in self.move_line_ids.filtered(lambda x: x.account_id != self.destination_account_id):
+ counterpart_currency = line.currency_id
+ counterpart_amount_currency = line.amount_currency
+
+ if self.destination_account_id.currency_id and self.destination_account_id.currency_id != self.company_id.currency_id:
+ counterpart_currency = self.destination_account_id.currency_id
+ counterpart_amount_currency = self.company_id.currency_id._convert(line.balance, self.destination_account_id.currency_id, self.company_id, line.date)
+
+ counterpart_balances[(line.partner_id, counterpart_currency)]['amount_currency'] += counterpart_amount_currency
+ counterpart_balances[(line.partner_id, counterpart_currency)]['balance'] += line.balance
+ grouped_source_lines[(line.partner_id, line.currency_id, line.account_id)] += line
+
+ # Generate counterpart lines' vals
+ for (counterpart_partner, counterpart_currency), counterpart_vals in counterpart_balances.items():
+ source_accounts = self.move_line_ids.mapped('account_id')
+ counterpart_label = len(source_accounts) == 1 and _("Transfer from %s", source_accounts.display_name) or _("Transfer counterpart")
+
+ if not counterpart_currency.is_zero(counterpart_vals['amount_currency']):
+ line_vals.append({
+ 'name': counterpart_label,
+ 'debit': counterpart_vals['balance'] > 0 and self.company_id.currency_id.round(counterpart_vals['balance']) or 0,
+ 'credit': counterpart_vals['balance'] < 0 and self.company_id.currency_id.round(-counterpart_vals['balance']) or 0,
+ 'account_id': self.destination_account_id.id,
+ 'partner_id': counterpart_partner.id or None,
+ 'amount_currency': counterpart_currency.round((counterpart_vals['balance'] < 0 and -1 or 1) * abs(counterpart_vals['amount_currency'])) or 0,
+ 'currency_id': counterpart_currency.id,
+ })
+
+ # Generate change_account lines' vals
+ for (partner, currency, account), lines in grouped_source_lines.items():
+ account_balance = sum(line.balance for line in lines)
+ if not self.company_id.currency_id.is_zero(account_balance):
+ account_amount_currency = currency.round(sum(line.amount_currency for line in lines))
+ line_vals.append({
+ 'name': _('Transfer to %s', self.destination_account_id.display_name or _('[Not set]')),
+ 'debit': account_balance < 0 and self.company_id.currency_id.round(-account_balance) or 0,
+ 'credit': account_balance > 0 and self.company_id.currency_id.round(account_balance) or 0,
+ 'account_id': account.id,
+ 'partner_id': partner.id or None,
+ 'currency_id': currency.id,
+ 'amount_currency': (account_balance > 0 and -1 or 1) * abs(account_amount_currency),
+ })
+
+ return [{
+ 'currency_id': self.journal_id.currency_id.id or self.journal_id.company_id.currency_id.id,
+ 'move_type': 'entry',
+ 'journal_id': self.journal_id.id,
+ 'date': fields.Date.to_string(self.date),
+ 'ref': self.destination_account_id.display_name and _("Transfer entry to %s", self.destination_account_id.display_name or ''),
+ 'line_ids': [(0, 0, line) for line in line_vals],
+ }]
+
+ def _get_move_dict_vals_change_period(self):
+ # set the change_period account on the selected journal items
+ accrual_account = self.revenue_accrual_account if self.account_type == 'income' else self.expense_accrual_account
+
+ move_data = {'new_date': {
+ 'currency_id': self.journal_id.currency_id.id or self.journal_id.company_id.currency_id.id,
+ 'move_type': 'entry',
+ 'line_ids': [],
+ 'ref': _('Adjusting Entry'),
+ 'date': fields.Date.to_string(self.date),
+ 'journal_id': self.journal_id.id,
+ }}
+ # complete the account.move data
+ for date, grouped_lines in groupby(self.move_line_ids, lambda m: m.move_id.date):
+ grouped_lines = list(grouped_lines)
+ amount = sum(l.balance for l in grouped_lines)
+ move_data[date] = {
+ 'currency_id': self.journal_id.currency_id.id or self.journal_id.company_id.currency_id.id,
+ 'move_type': 'entry',
+ 'line_ids': [],
+ 'ref': self._format_strings(_('Adjusting Entry of {date} ({percent:f}% recognized on {new_date})'), grouped_lines[0].move_id, amount),
+ 'date': fields.Date.to_string(date),
+ 'journal_id': self.journal_id.id,
+ }
+
+ # compute the account.move.lines and the total amount per move
+ for aml in self.move_line_ids:
+ # account.move.line data
+ reported_debit = aml.company_id.currency_id.round((self.percentage / 100) * aml.debit)
+ reported_credit = aml.company_id.currency_id.round((self.percentage / 100) * aml.credit)
+ reported_amount_currency = aml.currency_id.round((self.percentage / 100) * aml.amount_currency)
+
+ move_data['new_date']['line_ids'] += [
+ (0, 0, {
+ 'name': aml.name or '',
+ 'debit': reported_debit,
+ 'credit': reported_credit,
+ 'amount_currency': reported_amount_currency,
+ 'currency_id': aml.currency_id.id,
+ 'account_id': aml.account_id.id,
+ 'partner_id': aml.partner_id.id,
+ }),
+ (0, 0, {
+ 'name': _('Adjusting Entry'),
+ 'debit': reported_credit,
+ 'credit': reported_debit,
+ 'amount_currency': -reported_amount_currency,
+ 'currency_id': aml.currency_id.id,
+ 'account_id': accrual_account.id,
+ 'partner_id': aml.partner_id.id,
+ }),
+ ]
+ move_data[aml.move_id.date]['line_ids'] += [
+ (0, 0, {
+ 'name': aml.name or '',
+ 'debit': reported_credit,
+ 'credit': reported_debit,
+ 'amount_currency': -reported_amount_currency,
+ 'currency_id': aml.currency_id.id,
+ 'account_id': aml.account_id.id,
+ 'partner_id': aml.partner_id.id,
+ }),
+ (0, 0, {
+ 'name': _('Adjusting Entry'),
+ 'debit': reported_debit,
+ 'credit': reported_credit,
+ 'amount_currency': reported_amount_currency,
+ 'currency_id': aml.currency_id.id,
+ 'account_id': accrual_account.id,
+ 'partner_id': aml.partner_id.id,
+ }),
+ ]
+
+ move_vals = [m for m in move_data.values()]
+ return move_vals
+
+ @api.depends('move_line_ids', 'journal_id', 'revenue_accrual_account', 'expense_accrual_account', 'percentage', 'date', 'account_type', 'action', 'destination_account_id')
+ def _compute_move_data(self):
+ for record in self:
+ if record.action == 'change_period':
+ if any(line.account_id.user_type_id != record.move_line_ids[0].account_id.user_type_id for line in record.move_line_ids):
+ raise UserError(_('All accounts on the lines must be of the same type.'))
+ if record.action == 'change_period':
+ record.move_data = json.dumps(record._get_move_dict_vals_change_period())
+ elif record.action == 'change_account':
+ record.move_data = json.dumps(record._get_move_dict_vals_change_account())
+
+ @api.depends('move_data')
+ def _compute_preview_move_data(self):
+ for record in self:
+ preview_columns = [
+ {'field': 'account_id', 'label': _('Account')},
+ {'field': 'name', 'label': _('Label')},
+ {'field': 'debit', 'label': _('Debit'), 'class': 'text-right text-nowrap'},
+ {'field': 'credit', 'label': _('Credit'), 'class': 'text-right text-nowrap'},
+ ]
+ if record.action == 'change_account':
+ preview_columns[2:2] = [{'field': 'partner_id', 'label': _('Partner')}]
+
+ move_vals = json.loads(record.move_data)
+ preview_vals = []
+ for move in move_vals[:4]:
+ preview_vals += [self.env['account.move']._move_dict_to_preview_vals(move, record.company_id.currency_id)]
+ preview_discarded = max(0, len(move_vals) - len(preview_vals))
+
+ record.preview_move_data = json.dumps({
+ 'groups_vals': preview_vals,
+ 'options': {
+ 'discarded_number': _("%d moves", preview_discarded) if preview_discarded else False,
+ 'columns': preview_columns,
+ },
+ })
+
+ def do_action(self):
+ move_vals = json.loads(self.move_data)
+ if self.action == 'change_period':
+ return self._do_action_change_period(move_vals)
+ elif self.action == 'change_account':
+ return self._do_action_change_account(move_vals)
+
+ def _do_action_change_period(self, move_vals):
+ accrual_account = self.revenue_accrual_account if self.account_type == 'income' else self.expense_accrual_account
+
+ created_moves = self.env['account.move'].create(move_vals)
+ created_moves._post()
+
+ destination_move = created_moves[0]
+ destination_messages = []
+ for move in self.move_line_ids.move_id:
+ amount = sum((self.move_line_ids._origin & move.line_ids).mapped('balance'))
+ accrual_move = created_moves[1:].filtered(lambda m: m.date == move.date)
+
+ if accrual_account.reconcile:
+ to_reconcile = (accrual_move + destination_move).mapped('line_ids').filtered(lambda line: line.account_id == accrual_account)
+ to_reconcile.reconcile()
+ move.message_post(body=self._format_strings(_('Adjusting Entries have been created for this invoice:<ul><li>%(link1)s cancelling '
+ '{percent:f}%% of {amount}</li><li>%(link0)s postponing it to {new_date}</li></ul>',
+ link0=self._format_move_link(destination_move),
+ link1=self._format_move_link(accrual_move),
+ ), move, amount))
+ destination_messages += [self._format_strings(_('Adjusting Entry {link}: {percent:f}% of {amount} recognized from {date}'), move, amount)]
+ accrual_move.message_post(body=self._format_strings(_('Adjusting Entry for {link}: {percent:f}% of {amount} recognized on {new_date}'), move, amount))
+ destination_move.message_post(body='<br/>\n'.join(destination_messages))
+
+ # open the generated entries
+ action = {
+ 'name': _('Generated Entries'),
+ 'domain': [('id', 'in', created_moves.ids)],
+ 'res_model': 'account.move',
+ 'view_mode': 'tree,form',
+ 'type': 'ir.actions.act_window',
+ 'views': [(self.env.ref('account.view_move_tree').id, 'tree'), (False, 'form')],
+ }
+ if len(created_moves) == 1:
+ action.update({'view_mode': 'form', 'res_id': created_moves.id})
+ return action
+
+ def _do_action_change_account(self, move_vals):
+ new_move = self.env['account.move'].create(move_vals)
+ new_move._post()
+
+ # Group lines
+ grouped_lines = defaultdict(lambda: self.env['account.move.line'])
+ destination_lines = self.move_line_ids.filtered(lambda x: x.account_id == self.destination_account_id)
+ for line in self.move_line_ids - destination_lines:
+ grouped_lines[(line.partner_id, line.currency_id, line.account_id)] += line
+
+ # Reconcile
+ for (partner, currency, account), lines in grouped_lines.items():
+ if account.reconcile:
+ to_reconcile = lines + new_move.line_ids.filtered(lambda x: x.account_id == account and x.partner_id == partner and x.currency_id == currency)
+ to_reconcile.reconcile()
+
+ if destination_lines and self.destination_account_id.reconcile:
+ to_reconcile = destination_lines + new_move.line_ids.filtered(lambda x: x.account_id == self.destination_account_id and x.partner_id == partner and x.currency_id == currency)
+ to_reconcile.reconcile()
+
+ # Log the operation on source moves
+ acc_transfer_per_move = defaultdict(lambda: defaultdict(lambda: 0)) # dict(move, dict(account, balance))
+ for line in self.move_line_ids:
+ acc_transfer_per_move[line.move_id][line.account_id] += line.balance
+
+ for move, balances_per_account in acc_transfer_per_move.items():
+ message_to_log = self._format_transfer_source_log(balances_per_account, new_move)
+ if message_to_log:
+ move.message_post(body=message_to_log)
+
+ # Log on target move as well
+ new_move.message_post(body=self._format_new_transfer_move_log(acc_transfer_per_move))
+
+ return {
+ 'name': _("Transfer"),
+ 'type': 'ir.actions.act_window',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'account.move',
+ 'res_id': new_move.id,
+ }
+
+ # Transfer utils
+ def _format_new_transfer_move_log(self, acc_transfer_per_move):
+ format = _("<li>{amount} ({debit_credit}) from {link}, <strong>%(account_source_name)s</strong></li>")
+ rslt = _("This entry transfers the following amounts to <strong>%(destination)s</strong> <ul>", destination=self.destination_account_id.display_name)
+ for move, balances_per_account in acc_transfer_per_move.items():
+ for account, balance in balances_per_account.items():
+ if account != self.destination_account_id: # Otherwise, logging it here is confusing for the user
+ rslt += self._format_strings(format, move, balance) % {'account_source_name': account.display_name}
+
+ rslt += '</ul>'
+ return rslt
+
+ def _format_transfer_source_log(self, balances_per_account, transfer_move):
+ transfer_format = _("<li>{amount} ({debit_credit}) from <strong>%s</strong> were transferred to <strong>{account_target_name}</strong> by {link}</li>")
+ content = ''
+ for account, balance in balances_per_account.items():
+ if account != self.destination_account_id:
+ content += self._format_strings(transfer_format, transfer_move, balance) % account.display_name
+ return content and '<ul>' + content + '</ul>' or None
+
+ def _format_move_link(self, move):
+ move_link_format = "<a href=# data-oe-model=account.move data-oe-id={move_id}>{move_name}</a>"
+ return move_link_format.format(move_id=move.id, move_name=move.name)
+
+ def _format_strings(self, string, move, amount):
+ return string.format(
+ percent=self.percentage,
+ name=move.name,
+ id=move.id,
+ amount=formatLang(self.env, abs(amount), currency_obj=self.company_id.currency_id),
+ debit_credit=amount < 0 and _('C') or _('D'),
+ link=self._format_move_link(move),
+ date=format_date(self.env, move.date),
+ new_date=self.date and format_date(self.env, self.date) or _('[Not set]'),
+ account_target_name=self.destination_account_id.display_name,
+ )
diff --git a/addons/account/wizard/account_automatic_entry_wizard_views.xml b/addons/account/wizard/account_automatic_entry_wizard_views.xml
new file mode 100644
index 00000000..70426742
--- /dev/null
+++ b/addons/account/wizard/account_automatic_entry_wizard_views.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+ <record id="account_automatic_entry_wizard_form" model="ir.ui.view">
+ <field name="name">account.automatic.entry.wizard.form</field>
+ <field name="model">account.automatic.entry.wizard</field>
+ <field name="arch" type="xml">
+ <form>
+ <field name="account_type" invisible="1"/>
+ <field name="company_id" invisible="1"/>
+ <field name="move_line_ids" invisible="1"/>
+ <field name="display_currency_helper" invisible="1"/>
+ <div attrs="{'invisible': [('display_currency_helper', '=', False)]}" class="alert alert-info text-center" role="status">
+ The selected destination account is set to use a specific currency. Every entry transferred to it will be converted into this currency, causing
+ the loss of any pre-existing foreign currency amount.
+ </div>
+ <field name="action" invisible="context.get('hide_automatic_options')" widget="radio" options="{'horizontal': true}"/>
+ <group>
+ <group attrs="{'invisible': [('action', '!=', 'change_period')]}">
+ <field name="date" string="Recognition Date"/>
+ <field name="expense_accrual_account" string="Accrued Account"
+ attrs="{'invisible': [('account_type', '!=', 'expense')], 'required': [('account_type', '=', 'expense'), ('action', '=', 'change_period')]}"/>
+ <field name="revenue_accrual_account" string="Accrued Account"
+ attrs="{'invisible': [('account_type', '!=', 'income')], 'required': [('account_type', '=', 'income'), ('action', '=', 'change_period')]}"/>
+ </group>
+ <group attrs="{'invisible': [('action', '!=', 'change_account')]}">
+ <field name="date" string="Transfer Date"/>
+ <field name="destination_account_id" attrs="{'required': [('action', '=', 'change_account')]}" domain="[('company_id', '=', company_id)]"/>
+ </group>
+ <group>
+ <label for="total_amount" string="Adjusting Amount" attrs="{'invisible': [('action', '!=', 'change_period')]}"/>
+ <div attrs="{'invisible': [('action', '!=', 'change_period')]}">
+ <field name="percentage" style="width:40% !important" class="oe_inline" attrs="{'readonly': [('action', '!=', 'change_period')]}"/>%<span class="px-3"></span>(<field name="total_amount" class="oe_inline"/>)
+ </div>
+ <field name="total_amount" readonly="1" attrs="{'invisible': [('action', '=', 'change_period')]}"/>
+ <field name="journal_id"/>
+ </group>
+ </group>
+ <label for="preview_move_data" string="The following Journal Entries will be generated"/>
+ <field name="preview_move_data" widget="grouped_view_widget"/>
+ <footer>
+ <button string="Create Journal Entries" name="do_action" type="object" class="oe_highlight"/>
+ <button string="Cancel" class="btn btn-secondary" special="cancel"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="account_automatic_entry_wizard_action" model="ir.actions.act_window">
+ <field name="name">Create Automatic Entries for selected Journal Items</field>
+ <field name="res_model">account.automatic.entry.wizard</field>
+ <field name="view_mode">form</field>
+ <field name="target">new</field>
+ </record>
+ </data>
+</odoo>
diff --git a/addons/account/wizard/account_invoice_send.py b/addons/account/wizard/account_invoice_send.py
new file mode 100644
index 00000000..b9aae09e
--- /dev/null
+++ b/addons/account/wizard/account_invoice_send.py
@@ -0,0 +1,134 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+from odoo.addons.mail.wizard.mail_compose_message import _reopen
+from odoo.exceptions import UserError
+from odoo.tools.misc import get_lang
+
+
+class AccountInvoiceSend(models.TransientModel):
+ _name = 'account.invoice.send'
+ _inherits = {'mail.compose.message':'composer_id'}
+ _description = 'Account Invoice Send'
+
+ is_email = fields.Boolean('Email', default=lambda self: self.env.company.invoice_is_email)
+ invoice_without_email = fields.Text(compute='_compute_invoice_without_email', string='invoice(s) that will not be sent')
+ is_print = fields.Boolean('Print', default=lambda self: self.env.company.invoice_is_print)
+ printed = fields.Boolean('Is Printed', default=False)
+ invoice_ids = fields.Many2many('account.move', 'account_move_account_invoice_send_rel', string='Invoices')
+ composer_id = fields.Many2one('mail.compose.message', string='Composer', required=True, ondelete='cascade')
+ template_id = fields.Many2one(
+ 'mail.template', 'Use template', index=True,
+ domain="[('model', '=', 'account.move')]"
+ )
+
+ @api.model
+ def default_get(self, fields):
+ res = super(AccountInvoiceSend, self).default_get(fields)
+ res_ids = self._context.get('active_ids')
+
+ invoices = self.env['account.move'].browse(res_ids).filtered(lambda move: move.is_invoice(include_receipts=True))
+ if not invoices:
+ raise UserError(_("You can only send invoices."))
+
+ composer = self.env['mail.compose.message'].create({
+ 'composition_mode': 'comment' if len(res_ids) == 1 else 'mass_mail',
+ })
+ res.update({
+ 'invoice_ids': res_ids,
+ 'composer_id': composer.id,
+ })
+ return res
+
+ @api.onchange('invoice_ids')
+ def _compute_composition_mode(self):
+ for wizard in self:
+ wizard.composer_id.composition_mode = 'comment' if len(wizard.invoice_ids) == 1 else 'mass_mail'
+
+ @api.onchange('template_id')
+ def onchange_template_id(self):
+ for wizard in self:
+ if wizard.composer_id:
+ wizard.composer_id.template_id = wizard.template_id.id
+ wizard._compute_composition_mode()
+ wizard.composer_id.onchange_template_id_wrapper()
+
+ @api.onchange('is_email')
+ def onchange_is_email(self):
+ if self.is_email:
+ res_ids = self._context.get('active_ids')
+ if not self.composer_id:
+ self.composer_id = self.env['mail.compose.message'].create({
+ 'composition_mode': 'comment' if len(res_ids) == 1 else 'mass_mail',
+ 'template_id': self.template_id.id
+ })
+ else:
+ self.composer_id.composition_mode = 'comment' if len(res_ids) == 1 else 'mass_mail'
+ self.composer_id.template_id = self.template_id.id
+ self._compute_composition_mode()
+ self.composer_id.onchange_template_id_wrapper()
+
+ @api.onchange('is_email')
+ def _compute_invoice_without_email(self):
+ for wizard in self:
+ if wizard.is_email and len(wizard.invoice_ids) > 1:
+ invoices = self.env['account.move'].search([
+ ('id', 'in', self.env.context.get('active_ids')),
+ ('partner_id.email', '=', False)
+ ])
+ if invoices:
+ wizard.invoice_without_email = "%s\n%s" % (
+ _("The following invoice(s) will not be sent by email, because the customers don't have email address."),
+ "\n".join([i.name for i in invoices])
+ )
+ else:
+ wizard.invoice_without_email = False
+ else:
+ wizard.invoice_without_email = False
+
+ def _send_email(self):
+ if self.is_email:
+ # with_context : we don't want to reimport the file we just exported.
+ self.composer_id.with_context(no_new_invoice=True, mail_notify_author=self.env.user.partner_id in self.composer_id.partner_ids).send_mail()
+ if self.env.context.get('mark_invoice_as_sent'):
+ #Salesman send posted invoice, without the right to write
+ #but they should have the right to change this flag
+ self.mapped('invoice_ids').sudo().write({'is_move_sent': True})
+
+ def _print_document(self):
+ """ to override for each type of models that will use this composer."""
+ self.ensure_one()
+ action = self.invoice_ids.action_invoice_print()
+ action.update({'close_on_report_download': True})
+ return action
+
+ def send_and_print_action(self):
+ self.ensure_one()
+ # Send the mails in the correct language by splitting the ids per lang.
+ # This should ideally be fixed in mail_compose_message, so when a fix is made there this whole commit should be reverted.
+ # basically self.body (which could be manually edited) extracts self.template_id,
+ # which is then not translated for each customer.
+ if self.composition_mode == 'mass_mail' and self.template_id:
+ active_ids = self.env.context.get('active_ids', self.res_id)
+ active_records = self.env[self.model].browse(active_ids)
+ langs = active_records.mapped('partner_id.lang')
+ default_lang = get_lang(self.env)
+ for lang in (set(langs) or [default_lang]):
+ active_ids_lang = active_records.filtered(lambda r: r.partner_id.lang == lang).ids
+ self_lang = self.with_context(active_ids=active_ids_lang, lang=lang)
+ self_lang.onchange_template_id()
+ self_lang._send_email()
+ else:
+ self._send_email()
+ if self.is_print:
+ return self._print_document()
+ return {'type': 'ir.actions.act_window_close'}
+
+ def save_as_template(self):
+ self.ensure_one()
+ self.composer_id.save_as_template()
+ self.template_id = self.composer_id.template_id.id
+ action = _reopen(self, self.id, self.model, context=self._context)
+ action.update({'name': _('Send Invoice')})
+ return action
diff --git a/addons/account/wizard/account_invoice_send_views.xml b/addons/account/wizard/account_invoice_send_views.xml
new file mode 100644
index 00000000..518aed6d
--- /dev/null
+++ b/addons/account/wizard/account_invoice_send_views.xml
@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+
+ <record id="account_invoice_send_wizard_form" model="ir.ui.view">
+ <field name="name">account.invoice.send.form</field>
+ <field name="model">account.invoice.send</field>
+ <field name="groups_id" eval="[(4,ref('base.group_user'))]"/>
+ <field name="arch" type="xml">
+ <form string="Invoice send &amp; Print">
+ <!-- truly invisible fields for control and options -->
+ <field name="composition_mode" invisible="1"/>
+ <field name="invoice_ids" invisible="1"/>
+ <field name="email_from" invisible="1" />
+ <field name="mail_server_id" invisible="1"/>
+ <div name="option_print">
+ <field name="is_print" />
+ <b><label for="is_print"/></b>
+ <div name="info_form" attrs="{'invisible': ['|', ('is_print', '=', False), ('composition_mode', '=', 'mass_mail')]}" class="text-center text-muted d-inline-block">
+ Preview as a PDF
+ </div>
+ </div>
+ <div name="option_email">
+ <field name="is_email" />
+ <b><label for="is_email"/></b>
+ </div>
+ <div class="text-left d-inline-block mr8" attrs="{'invisible': ['|', ('is_email','=', False), ('invoice_without_email', '=', False)]}">
+ <field name="invoice_without_email" class="mr4"/>
+ </div>
+ <div name="mail_form" attrs="{'invisible': [('is_email', '=', False)]}">
+ <!-- visible wizard -->
+ <div attrs="{'invisible': [('composition_mode', '=', 'mass_mail')]}">
+ <group>
+ <label for="partner_ids" string="Recipients" groups="base.group_user"/>
+ <div groups="base.group_user">
+ <span attrs="{'invisible': [('composition_mode', '!=', 'mass_mail')]}">
+ <strong>Email mass mailing</strong> on
+ <span>the selected records</span>
+ </span>
+ <span>Followers of the document and</span>
+ <field name="partner_ids" widget="many2many_tags_email" placeholder="Add contacts to notify..."
+ context="{'force_email':True, 'show_email':True}" attrs="{'invisible': [('composition_mode', '=', 'mass_mail')]}"/>
+ </div>
+ <field name="subject" placeholder="Subject..." attrs="{'required': [('is_email', '=', True), ('composition_mode', '=', 'comment')]}"/>
+ </group>
+ <field name="body" style="border:none;" options="{'style-inline': true}"/>
+ </div>
+ <group>
+ <group attrs="{'invisible': [('composition_mode', '=', 'mass_mail')]}">
+ <field name="attachment_ids" widget="many2many_binary" string="Attach a file" nolabel="1" colspan="2" attrs="{'invisible': [('composition_mode', '=', 'mass_mail')]}"/>
+ </group>
+ <group>
+ <field name="template_id" options="{'no_create': True, 'no_edit': True}"
+ context="{'default_model': 'account.move'}"/>
+ </group>
+ </group>
+ </div>
+
+ <footer>
+ <button string="Send &amp; Print"
+ attrs="{'invisible': ['|', ('is_email', '=', False), ('is_print', '=', False)]}"
+ name="send_and_print_action" type="object" class="send_and_print btn-primary o_mail_send"/>
+ <button string="Send"
+ attrs="{'invisible': ['|', ('is_print', '=', True), ('is_email', '=', False)]}"
+ name="send_and_print_action" type="object" class="send btn-primary o_mail_send"/>
+ <button string="Print"
+ attrs="{'invisible': ['|', ('is_print', '=', False), ('is_email', '=', True)]}"
+ name="send_and_print_action" type="object" class="print btn-primary o_mail_send"/>
+ <button string="Cancel" class="btn-secondary" special="cancel" />
+ <button icon="fa-lg fa-save" type="object" name="save_as_template" string="Save as new template"
+ attrs="{'invisible': ['|', ('composition_mode', '=', 'mass_mail'), ('is_email', '=', False)]}"
+ class="pull-right btn-secondary" help="Save as a new template" />
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="invoice_send" model="ir.actions.act_window">
+ <field name="name">Send &amp; print</field>
+ <field name="res_model">account.invoice.send</field>
+ <field name="view_mode">form</field>
+ <field name="target">new</field>
+ <field name="context" eval="{
+ 'default_template_id': ref('account.email_template_edi_invoice'),
+ 'mark_invoice_as_sent': True,
+ 'custom_layout': 'mail.mail_notification_paynow',
+ }"/>
+ <field name="binding_model_id" ref="model_account_move"/>
+ <field name="binding_view_types">list</field>
+ </record>
+
+ </data>
+
+</odoo>
diff --git a/addons/account/wizard/account_move_reversal.py b/addons/account/wizard/account_move_reversal.py
new file mode 100644
index 00000000..7724ca44
--- /dev/null
+++ b/addons/account/wizard/account_move_reversal.py
@@ -0,0 +1,129 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, api
+from odoo.tools.translate import _
+from odoo.exceptions import UserError
+
+
+class AccountMoveReversal(models.TransientModel):
+ """
+ Account move reversal wizard, it cancel an account move by reversing it.
+ """
+ _name = 'account.move.reversal'
+ _description = 'Account Move Reversal'
+ _check_company_auto = True
+
+ move_ids = fields.Many2many('account.move', 'account_move_reversal_move', 'reversal_id', 'move_id', domain=[('state', '=', 'posted')])
+ new_move_ids = fields.Many2many('account.move', 'account_move_reversal_new_move', 'reversal_id', 'new_move_id')
+ date_mode = fields.Selection(selection=[
+ ('custom', 'Specific'),
+ ('entry', 'Journal Entry Date')
+ ], required=True, default='custom')
+ date = fields.Date(string='Reversal date', default=fields.Date.context_today)
+ reason = fields.Char(string='Reason')
+ refund_method = fields.Selection(selection=[
+ ('refund', 'Partial Refund'),
+ ('cancel', 'Full Refund'),
+ ('modify', 'Full refund and new draft invoice')
+ ], string='Credit Method', required=True,
+ help='Choose how you want to credit this invoice. You cannot "modify" nor "cancel" if the invoice is already reconciled.')
+ journal_id = fields.Many2one('account.journal', string='Use Specific Journal', help='If empty, uses the journal of the journal entry to be reversed.', check_company=True)
+ company_id = fields.Many2one('res.company', required=True, readonly=True)
+
+ # computed fields
+ residual = fields.Monetary(compute="_compute_from_moves")
+ currency_id = fields.Many2one('res.currency', compute="_compute_from_moves")
+ move_type = fields.Char(compute="_compute_from_moves")
+
+ @api.model
+ def default_get(self, fields):
+ res = super(AccountMoveReversal, self).default_get(fields)
+ move_ids = self.env['account.move'].browse(self.env.context['active_ids']) if self.env.context.get('active_model') == 'account.move' else self.env['account.move']
+
+ if any(move.state != "posted" for move in move_ids):
+ raise UserError(_('You can only reverse posted moves.'))
+ if 'company_id' in fields:
+ res['company_id'] = move_ids.company_id.id or self.env.company.id
+ if 'move_ids' in fields:
+ res['move_ids'] = [(6, 0, move_ids.ids)]
+ if 'refund_method' in fields:
+ res['refund_method'] = (len(move_ids) > 1 or move_ids.move_type == 'entry') and 'cancel' or 'refund'
+ return res
+
+ @api.depends('move_ids')
+ def _compute_from_moves(self):
+ for record in self:
+ move_ids = record.move_ids._origin
+ record.residual = len(move_ids) == 1 and move_ids.amount_residual or 0
+ record.currency_id = len(move_ids.currency_id) == 1 and move_ids.currency_id or False
+ record.move_type = move_ids.move_type if len(move_ids) == 1 else (any(move.move_type in ('in_invoice', 'out_invoice') for move in move_ids) and 'some_invoice' or False)
+
+ def _prepare_default_reversal(self, move):
+ reverse_date = self.date if self.date_mode == 'custom' else move.date
+ return {
+ 'ref': _('Reversal of: %(move_name)s, %(reason)s', move_name=move.name, reason=self.reason)
+ if self.reason
+ else _('Reversal of: %s', move.name),
+ 'date': reverse_date,
+ 'invoice_date': move.is_invoice(include_receipts=True) and (self.date or move.date) or False,
+ 'journal_id': self.journal_id and self.journal_id.id or move.journal_id.id,
+ 'invoice_payment_term_id': None,
+ 'invoice_user_id': move.invoice_user_id.id,
+ 'auto_post': True if reverse_date > fields.Date.context_today(self) else False,
+ }
+
+ def _reverse_moves_post_hook(self, moves):
+ # DEPRECATED: TO REMOVE IN MASTER
+ return
+
+ def reverse_moves(self):
+ self.ensure_one()
+ moves = self.move_ids
+
+ # Create default values.
+ default_values_list = []
+ for move in moves:
+ default_values_list.append(self._prepare_default_reversal(move))
+
+ batches = [
+ [self.env['account.move'], [], True], # Moves to be cancelled by the reverses.
+ [self.env['account.move'], [], False], # Others.
+ ]
+ for move, default_vals in zip(moves, default_values_list):
+ is_auto_post = bool(default_vals.get('auto_post'))
+ is_cancel_needed = not is_auto_post and self.refund_method in ('cancel', 'modify')
+ batch_index = 0 if is_cancel_needed else 1
+ batches[batch_index][0] |= move
+ batches[batch_index][1].append(default_vals)
+
+ # Handle reverse method.
+ moves_to_redirect = self.env['account.move']
+ for moves, default_values_list, is_cancel_needed in batches:
+ new_moves = moves._reverse_moves(default_values_list, cancel=is_cancel_needed)
+
+ if self.refund_method == 'modify':
+ moves_vals_list = []
+ for move in moves.with_context(include_business_fields=True):
+ moves_vals_list.append(move.copy_data({'date': self.date if self.date_mode == 'custom' else move.date})[0])
+ new_moves = self.env['account.move'].create(moves_vals_list)
+
+ moves_to_redirect |= new_moves
+
+ self.new_move_ids = moves_to_redirect
+
+ # Create action.
+ action = {
+ 'name': _('Reverse Moves'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move',
+ }
+ if len(moves_to_redirect) == 1:
+ action.update({
+ 'view_mode': 'form',
+ 'res_id': moves_to_redirect.id,
+ })
+ else:
+ action.update({
+ 'view_mode': 'tree,form',
+ 'domain': [('id', 'in', moves_to_redirect.ids)],
+ })
+ return action
diff --git a/addons/account/wizard/account_move_reversal_view.xml b/addons/account/wizard/account_move_reversal_view.xml
new file mode 100644
index 00000000..2939a3f7
--- /dev/null
+++ b/addons/account/wizard/account_move_reversal_view.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+ <record id="view_account_move_reversal" model="ir.ui.view">
+ <field name="name">account.move.reversal.form</field>
+ <field name="model">account.move.reversal</field>
+ <field name="arch" type="xml">
+ <form string="Reverse Journal Entry">
+ <field name="residual" invisible="1"/>
+ <field name="company_id" invisible="1"/>
+ <field name="move_ids" invisible="1"/>
+ <field name="move_type" invisible="1"/>
+ <group>
+ <group attrs="{'invisible': [('move_type', 'not in', ('out_invoice', 'in_invoice'))]}">
+ <field name="refund_method" widget="radio" attrs="{'readonly': [('residual', '=', 0)]}"/>
+ </group>
+ <group attrs="{'invisible': [('move_type', 'not in', ('out_invoice', 'in_invoice', 'some_invoice'))]}">
+ <div attrs="{'invisible':[('refund_method', '!=', 'refund')]}" class="oe_grey" colspan="4">
+ The credit note is created in draft and can be edited before being issued.
+ </div>
+ <div attrs="{'invisible':[('refund_method', '!=', 'cancel')]}" class="oe_grey" colspan="4">
+ The credit note is auto-validated and reconciled with the invoice.
+ </div>
+ <div attrs="{'invisible':[('refund_method', '!=', 'modify')]}" class="oe_grey" colspan="4">
+ The credit note is auto-validated and reconciled with the invoice.
+ The original invoice is duplicated as a new draft.
+ </div>
+ </group>
+ </group>
+ <group>
+ <group>
+ <field name="reason" attrs="{'invisible': [('move_type', '==', 'entry')], 'reason': [('move_type', '==', 'entry')]}"/>
+ <field name="date_mode" string="Reversal Date" widget="radio"/>
+ </group>
+ <group>
+ <field name="journal_id"/>
+ <field name="date" string="Refund Date" attrs="{'invisible': ['|', ('move_type', 'not in', ('out_invoice', 'in_invoice')), ('date_mode', '!=', 'custom')], 'required':[('date_mode', '=', 'custom')]}"/>
+ <field name="date" attrs="{'invisible': ['|', ('move_type', 'in', ('out_invoice', 'in_invoice')), ('date_mode', '!=', 'custom')], 'required':[('date_mode', '=', 'custom')]}"/>
+ </group>
+ </group>
+ <footer>
+ <button string='Reverse' name="reverse_moves" type="object" class="btn-primary"/>
+ <button string="Cancel" class="btn-secondary" special="cancel"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="action_view_account_move_reversal" model="ir.actions.act_window">
+ <field name="name">Reverse</field>
+ <field name="res_model">account.move.reversal</field>
+ <field name="view_mode">tree,form</field>
+ <field name="view_id" ref="view_account_move_reversal"/>
+ <field name="target">new</field>
+ <field name="groups_id" eval="[(4, ref('account.group_account_invoice'))]"/>
+ <field name="binding_model_id" ref="account.model_account_move" />
+ <field name="binding_view_types">list</field>
+ </record>
+ </data>
+</odoo>
diff --git a/addons/account/wizard/account_payment_register.py b/addons/account/wizard/account_payment_register.py
new file mode 100644
index 00000000..98bed601
--- /dev/null
+++ b/addons/account/wizard/account_payment_register.py
@@ -0,0 +1,535 @@
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError
+
+
+class AccountPaymentRegister(models.TransientModel):
+ _name = 'account.payment.register'
+ _description = 'Register Payment'
+
+ # == Business fields ==
+ payment_date = fields.Date(string="Payment Date", required=True,
+ default=fields.Date.context_today)
+ amount = fields.Monetary(currency_field='currency_id', store=True, readonly=False,
+ compute='_compute_amount')
+ communication = fields.Char(string="Memo", store=True, readonly=False,
+ compute='_compute_communication')
+ group_payment = fields.Boolean(string="Group Payments", store=True, readonly=False,
+ compute='_compute_group_payment',
+ help="Only one payment will be created by partner (bank)/ currency.")
+ currency_id = fields.Many2one('res.currency', string='Currency', store=True, readonly=False,
+ compute='_compute_currency_id',
+ help="The payment's currency.")
+ journal_id = fields.Many2one('account.journal', store=True, readonly=False,
+ compute='_compute_journal_id',
+ domain="[('company_id', '=', company_id), ('type', 'in', ('bank', 'cash'))]")
+ partner_bank_id = fields.Many2one('res.partner.bank', string="Recipient Bank Account",
+ readonly=False, store=True,
+ compute='_compute_partner_bank_id',
+ domain="['|', ('company_id', '=', False), ('company_id', '=', company_id), ('partner_id', '=', partner_id)]")
+ company_currency_id = fields.Many2one('res.currency', string="Company Currency",
+ related='company_id.currency_id')
+
+ # == Fields given through the context ==
+ line_ids = fields.Many2many('account.move.line', 'account_payment_register_move_line_rel', 'wizard_id', 'line_id',
+ string="Journal items", readonly=True, copy=False,)
+ payment_type = fields.Selection([
+ ('outbound', 'Send Money'),
+ ('inbound', 'Receive Money'),
+ ], string='Payment Type', store=True, copy=False,
+ compute='_compute_from_lines')
+ partner_type = fields.Selection([
+ ('customer', 'Customer'),
+ ('supplier', 'Vendor'),
+ ], store=True, copy=False,
+ compute='_compute_from_lines')
+ source_amount = fields.Monetary(
+ string="Amount to Pay (company currency)", store=True, copy=False,
+ currency_field='company_currency_id',
+ compute='_compute_from_lines')
+ source_amount_currency = fields.Monetary(
+ string="Amount to Pay (foreign currency)", store=True, copy=False,
+ currency_field='source_currency_id',
+ compute='_compute_from_lines')
+ source_currency_id = fields.Many2one('res.currency',
+ string='Source Currency', store=True, copy=False,
+ compute='_compute_from_lines',
+ help="The payment's currency.")
+ can_edit_wizard = fields.Boolean(store=True, copy=False,
+ compute='_compute_from_lines',
+ help="Technical field used to indicate the user can edit the wizard content such as the amount.")
+ can_group_payments = fields.Boolean(store=True, copy=False,
+ compute='_compute_from_lines',
+ help="Technical field used to indicate the user can see the 'group_payments' box.")
+ company_id = fields.Many2one('res.company', store=True, copy=False,
+ compute='_compute_from_lines')
+ partner_id = fields.Many2one('res.partner',
+ string="Customer/Vendor", store=True, copy=False, ondelete='restrict',
+ compute='_compute_from_lines')
+
+ # == Payment methods fields ==
+ payment_method_id = fields.Many2one('account.payment.method', string='Payment Method',
+ readonly=False, store=True,
+ compute='_compute_payment_method_id',
+ domain="[('id', 'in', available_payment_method_ids)]",
+ help="Manual: Get paid by cash, check or any other method outside of Odoo.\n"\
+ "Electronic: Get paid automatically through a payment acquirer by requesting a transaction on a card saved by the customer when buying or subscribing online (payment token).\n"\
+ "Check: Pay bill by check and print it from Odoo.\n"\
+ "Batch Deposit: Encase several customer checks at once by generating a batch deposit to submit to your bank. When encoding the bank statement in Odoo, you are suggested to reconcile the transaction with the batch deposit.To enable batch deposit, module account_batch_payment must be installed.\n"\
+ "SEPA Credit Transfer: Pay bill from a SEPA Credit Transfer file you submit to your bank. To enable sepa credit transfer, module account_sepa must be installed ")
+ available_payment_method_ids = fields.Many2many('account.payment.method',
+ compute='_compute_payment_method_fields')
+ hide_payment_method = fields.Boolean(
+ compute='_compute_payment_method_fields',
+ help="Technical field used to hide the payment method if the selected journal has only one available which is 'manual'")
+
+ # == Payment difference fields ==
+ payment_difference = fields.Monetary(
+ compute='_compute_payment_difference')
+ payment_difference_handling = fields.Selection([
+ ('open', 'Keep open'),
+ ('reconcile', 'Mark as fully paid'),
+ ], default='open', string="Payment Difference Handling")
+ writeoff_account_id = fields.Many2one('account.account', string="Difference Account", copy=False,
+ domain="[('deprecated', '=', False), ('company_id', '=', company_id)]")
+ writeoff_label = fields.Char(string='Journal Item Label', default='Write-Off',
+ help='Change label of the counterpart that will hold the payment difference')
+
+ # == Display purpose fields ==
+ show_partner_bank_account = fields.Boolean(
+ compute='_compute_show_require_partner_bank',
+ help="Technical field used to know whether the field `partner_bank_id` needs to be displayed or not in the payments form views")
+ require_partner_bank_account = fields.Boolean(
+ compute='_compute_show_require_partner_bank',
+ help="Technical field used to know whether the field `partner_bank_id` needs to be required or not in the payments form views")
+ country_code = fields.Char(related='company_id.country_id.code', readonly=True)
+
+ # -------------------------------------------------------------------------
+ # HELPERS
+ # -------------------------------------------------------------------------
+
+ @api.model
+ def _get_batch_communication(self, batch_result):
+ ''' Helper to compute the communication based on the batch.
+ :param batch_result: A batch returned by '_get_batches'.
+ :return: A string representing a communication to be set on payment.
+ '''
+ labels = set(line.name or line.move_id.ref or line.move_id.name for line in batch_result['lines'])
+ return ' '.join(sorted(labels))
+
+ @api.model
+ def _get_line_batch_key(self, line):
+ ''' Turn the line passed as parameter to a dictionary defining on which way the lines
+ will be grouped together.
+ :return: A python dictionary.
+ '''
+ return {
+ 'partner_id': line.partner_id.id,
+ 'account_id': line.account_id.id,
+ 'currency_id': (line.currency_id or line.company_currency_id).id,
+ 'partner_bank_id': (line.move_id.partner_bank_id or line.partner_id.commercial_partner_id.bank_ids[:1]).id,
+ 'partner_type': 'customer' if line.account_internal_type == 'receivable' else 'supplier',
+ 'payment_type': 'inbound' if line.balance > 0.0 else 'outbound',
+ }
+
+ def _get_batches(self):
+ ''' Group the account.move.line linked to the wizard together.
+ :return: A list of batches, each one containing:
+ * key_values: The key as a dictionary used to group the journal items together.
+ * moves: An account.move recordset.
+ '''
+ self.ensure_one()
+
+ lines = self.line_ids._origin
+
+ if len(lines.company_id) > 1:
+ raise UserError(_("You can't create payments for entries belonging to different companies."))
+ if not lines:
+ raise UserError(_("You can't open the register payment wizard without at least one receivable/payable line."))
+
+ batches = {}
+ for line in lines:
+ batch_key = self._get_line_batch_key(line)
+
+ serialized_key = '-'.join(str(v) for v in batch_key.values())
+ batches.setdefault(serialized_key, {
+ 'key_values': batch_key,
+ 'lines': self.env['account.move.line'],
+ })
+ batches[serialized_key]['lines'] += line
+ return list(batches.values())
+
+ @api.model
+ def _get_wizard_values_from_batch(self, batch_result):
+ ''' Extract values from the batch passed as parameter (see '_get_batches')
+ to be mounted in the wizard view.
+ :param batch_result: A batch returned by '_get_batches'.
+ :return: A dictionary containing valid fields
+ '''
+ key_values = batch_result['key_values']
+ lines = batch_result['lines']
+ company = lines[0].company_id
+
+ source_amount = abs(sum(lines.mapped('amount_residual')))
+ if key_values['currency_id'] == company.currency_id.id:
+ source_amount_currency = source_amount
+ else:
+ source_amount_currency = abs(sum(lines.mapped('amount_residual_currency')))
+
+ return {
+ 'company_id': company.id,
+ 'partner_id': key_values['partner_id'],
+ 'partner_type': key_values['partner_type'],
+ 'payment_type': key_values['payment_type'],
+ 'source_currency_id': key_values['currency_id'],
+ 'source_amount': source_amount,
+ 'source_amount_currency': source_amount_currency,
+ }
+
+ # -------------------------------------------------------------------------
+ # COMPUTE METHODS
+ # -------------------------------------------------------------------------
+
+ @api.depends('line_ids')
+ def _compute_from_lines(self):
+ ''' Load initial values from the account.moves passed through the context. '''
+ for wizard in self:
+ batches = wizard._get_batches()
+ batch_result = batches[0]
+ wizard_values_from_batch = wizard._get_wizard_values_from_batch(batch_result)
+
+ if len(batches) == 1:
+ # == Single batch to be mounted on the view ==
+ wizard.update(wizard_values_from_batch)
+
+ wizard.can_edit_wizard = True
+ wizard.can_group_payments = len(batch_result['lines']) != 1
+ else:
+ # == Multiple batches: The wizard is not editable ==
+ wizard.update({
+ 'company_id': batches[0]['lines'][0].company_id.id,
+ 'partner_id': False,
+ 'partner_type': False,
+ 'payment_type': wizard_values_from_batch['payment_type'],
+ 'source_currency_id': False,
+ 'source_amount': False,
+ 'source_amount_currency': False,
+ })
+
+ wizard.can_edit_wizard = False
+ wizard.can_group_payments = any(len(batch_result['lines']) != 1 for batch_result in batches)
+
+ @api.depends('can_edit_wizard')
+ def _compute_communication(self):
+ # The communication can't be computed in '_compute_from_lines' because
+ # it's a compute editable field and then, should be computed in a separated method.
+ for wizard in self:
+ if wizard.can_edit_wizard:
+ batches = self._get_batches()
+ wizard.communication = wizard._get_batch_communication(batches[0])
+ else:
+ wizard.communication = False
+
+ @api.depends('can_edit_wizard')
+ def _compute_group_payment(self):
+ for wizard in self:
+ if wizard.can_edit_wizard:
+ batches = wizard._get_batches()
+ wizard.group_payment = len(batches[0]['lines'].move_id) == 1
+ else:
+ wizard.group_payment = False
+
+ @api.depends('company_id', 'source_currency_id')
+ def _compute_journal_id(self):
+ for wizard in self:
+ domain = [
+ ('type', 'in', ('bank', 'cash')),
+ ('company_id', '=', wizard.company_id.id),
+ ]
+ journal = None
+ if wizard.source_currency_id:
+ journal = self.env['account.journal'].search(domain + [('currency_id', '=', wizard.source_currency_id.id)], limit=1)
+ if not journal:
+ journal = self.env['account.journal'].search(domain, limit=1)
+ wizard.journal_id = journal
+
+ @api.depends('journal_id')
+ def _compute_currency_id(self):
+ for wizard in self:
+ wizard.currency_id = wizard.journal_id.currency_id or wizard.source_currency_id or wizard.company_id.currency_id
+
+ @api.depends('partner_id')
+ def _compute_partner_bank_id(self):
+ ''' The default partner_bank_id will be the first available on the partner. '''
+ for wizard in self:
+ available_partner_bank_accounts = wizard.partner_id.bank_ids.filtered(lambda x: x.company_id in (False, wizard.company_id))
+ if available_partner_bank_accounts:
+ wizard.partner_bank_id = available_partner_bank_accounts[0]._origin
+ else:
+ wizard.partner_bank_id = False
+
+ @api.depends('payment_type',
+ 'journal_id.inbound_payment_method_ids',
+ 'journal_id.outbound_payment_method_ids')
+ def _compute_payment_method_fields(self):
+ for wizard in self:
+ if wizard.payment_type == 'inbound':
+ wizard.available_payment_method_ids = wizard.journal_id.inbound_payment_method_ids
+ else:
+ wizard.available_payment_method_ids = wizard.journal_id.outbound_payment_method_ids
+
+ wizard.hide_payment_method = len(wizard.available_payment_method_ids) == 1 and wizard.available_payment_method_ids.code == 'manual'
+
+ @api.depends('payment_type',
+ 'journal_id.inbound_payment_method_ids',
+ 'journal_id.outbound_payment_method_ids')
+ def _compute_payment_method_id(self):
+ for wizard in self:
+ if wizard.payment_type == 'inbound':
+ available_payment_methods = wizard.journal_id.inbound_payment_method_ids
+ else:
+ available_payment_methods = wizard.journal_id.outbound_payment_method_ids
+
+ # Select the first available one by default.
+ if available_payment_methods:
+ wizard.payment_method_id = available_payment_methods[0]._origin
+ else:
+ wizard.payment_method_id = False
+
+ @api.depends('payment_method_id')
+ def _compute_show_require_partner_bank(self):
+ """ Computes if the destination bank account must be displayed in the payment form view. By default, it
+ won't be displayed but some modules might change that, depending on the payment type."""
+ for wizard in self:
+ wizard.show_partner_bank_account = wizard.payment_method_id.code in self.env['account.payment']._get_method_codes_using_bank_account()
+ wizard.require_partner_bank_account = wizard.payment_method_id.code in self.env['account.payment']._get_method_codes_needing_bank_account()
+
+ @api.depends('source_amount', 'source_amount_currency', 'source_currency_id', 'company_id', 'currency_id', 'payment_date')
+ def _compute_amount(self):
+ for wizard in self:
+ if wizard.source_currency_id == wizard.currency_id:
+ # Same currency.
+ wizard.amount = wizard.source_amount_currency
+ elif wizard.currency_id == wizard.company_id.currency_id:
+ # Payment expressed on the company's currency.
+ wizard.amount = wizard.source_amount
+ else:
+ # Foreign currency on payment different than the one set on the journal entries.
+ amount_payment_currency = wizard.company_id.currency_id._convert(wizard.source_amount, wizard.currency_id, wizard.company_id, wizard.payment_date)
+ wizard.amount = amount_payment_currency
+
+ @api.depends('amount')
+ def _compute_payment_difference(self):
+ for wizard in self:
+ if wizard.source_currency_id == wizard.currency_id:
+ # Same currency.
+ wizard.payment_difference = wizard.source_amount_currency - wizard.amount
+ elif wizard.currency_id == wizard.company_id.currency_id:
+ # Payment expressed on the company's currency.
+ wizard.payment_difference = wizard.source_amount - wizard.amount
+ else:
+ # Foreign currency on payment different than the one set on the journal entries.
+ amount_payment_currency = wizard.company_id.currency_id._convert(wizard.source_amount, wizard.currency_id, wizard.company_id, wizard.payment_date)
+ wizard.payment_difference = amount_payment_currency - wizard.amount
+
+ # -------------------------------------------------------------------------
+ # LOW-LEVEL METHODS
+ # -------------------------------------------------------------------------
+
+ @api.model
+ def default_get(self, fields_list):
+ # OVERRIDE
+ res = super().default_get(fields_list)
+
+ if 'line_ids' in fields_list and 'line_ids' not in res:
+
+ # Retrieve moves to pay from the context.
+
+ if self._context.get('active_model') == 'account.move':
+ lines = self.env['account.move'].browse(self._context.get('active_ids', [])).line_ids
+ elif self._context.get('active_model') == 'account.move.line':
+ lines = self.env['account.move.line'].browse(self._context.get('active_ids', []))
+ else:
+ raise UserError(_(
+ "The register payment wizard should only be called on account.move or account.move.line records."
+ ))
+
+ # Keep lines having a residual amount to pay.
+ available_lines = self.env['account.move.line']
+ for line in lines:
+ if line.move_id.state != 'posted':
+ raise UserError(_("You can only register payment for posted journal entries."))
+
+ if line.account_internal_type not in ('receivable', 'payable'):
+ continue
+ if line.currency_id:
+ if line.currency_id.is_zero(line.amount_residual_currency):
+ continue
+ else:
+ if line.company_currency_id.is_zero(line.amount_residual):
+ continue
+ available_lines |= line
+
+ # Check.
+ if not available_lines:
+ raise UserError(_("You can't register a payment because there is nothing left to pay on the selected journal items."))
+ if len(lines.company_id) > 1:
+ raise UserError(_("You can't create payments for entries belonging to different companies."))
+ if len(set(available_lines.mapped('account_internal_type'))) > 1:
+ raise UserError(_("You can't register payments for journal items being either all inbound, either all outbound."))
+
+ res['line_ids'] = [(6, 0, available_lines.ids)]
+
+ return res
+
+ # -------------------------------------------------------------------------
+ # BUSINESS METHODS
+ # -------------------------------------------------------------------------
+
+ def _create_payment_vals_from_wizard(self):
+ payment_vals = {
+ 'date': self.payment_date,
+ 'amount': self.amount,
+ 'payment_type': self.payment_type,
+ 'partner_type': self.partner_type,
+ 'ref': self.communication,
+ 'journal_id': self.journal_id.id,
+ 'currency_id': self.currency_id.id,
+ 'partner_id': self.partner_id.id,
+ 'partner_bank_id': self.partner_bank_id.id,
+ 'payment_method_id': self.payment_method_id.id,
+ 'destination_account_id': self.line_ids[0].account_id.id
+ }
+
+ if not self.currency_id.is_zero(self.payment_difference) and self.payment_difference_handling == 'reconcile':
+ payment_vals['write_off_line_vals'] = {
+ 'name': self.writeoff_label,
+ 'amount': self.payment_difference,
+ 'account_id': self.writeoff_account_id.id,
+ }
+ return payment_vals
+
+ def _create_payment_vals_from_batch(self, batch_result):
+ batch_values = self._get_wizard_values_from_batch(batch_result)
+ return {
+ 'date': self.payment_date,
+ 'amount': batch_values['source_amount_currency'],
+ 'payment_type': batch_values['payment_type'],
+ 'partner_type': batch_values['partner_type'],
+ 'ref': self._get_batch_communication(batch_result),
+ 'journal_id': self.journal_id.id,
+ 'currency_id': batch_values['source_currency_id'],
+ 'partner_id': batch_values['partner_id'],
+ 'partner_bank_id': batch_result['key_values']['partner_bank_id'],
+ 'payment_method_id': self.payment_method_id.id,
+ 'destination_account_id': batch_result['lines'][0].account_id.id
+ }
+
+ def _create_payments(self):
+ self.ensure_one()
+ batches = self._get_batches()
+ edit_mode = self.can_edit_wizard and (len(batches[0]['lines']) == 1 or self.group_payment)
+
+ to_reconcile = []
+ if edit_mode:
+ payment_vals = self._create_payment_vals_from_wizard()
+ payment_vals_list = [payment_vals]
+ to_reconcile.append(batches[0]['lines'])
+ else:
+ # Don't group payments: Create one batch per move.
+ if not self.group_payment:
+ new_batches = []
+ for batch_result in batches:
+ for line in batch_result['lines']:
+ new_batches.append({
+ **batch_result,
+ 'lines': line,
+ })
+ batches = new_batches
+
+ payment_vals_list = []
+ for batch_result in batches:
+ payment_vals_list.append(self._create_payment_vals_from_batch(batch_result))
+ to_reconcile.append(batch_result['lines'])
+
+ payments = self.env['account.payment'].create(payment_vals_list)
+
+ # If payments are made using a currency different than the source one, ensure the balance match exactly in
+ # order to fully paid the source journal items.
+ # For example, suppose a new currency B having a rate 100:1 regarding the company currency A.
+ # If you try to pay 12.15A using 0.12B, the computed balance will be 12.00A for the payment instead of 12.15A.
+ if edit_mode:
+ for payment, lines in zip(payments, to_reconcile):
+ # Batches are made using the same currency so making 'lines.currency_id' is ok.
+ if payment.currency_id != lines.currency_id:
+ liquidity_lines, counterpart_lines, writeoff_lines = payment._seek_for_lines()
+ source_balance = abs(sum(lines.mapped('amount_residual')))
+ payment_rate = liquidity_lines[0].amount_currency / liquidity_lines[0].balance
+ source_balance_converted = abs(source_balance) * payment_rate
+
+ # Translate the balance into the payment currency is order to be able to compare them.
+ # In case in both have the same value (12.15 * 0.01 ~= 0.12 in our example), it means the user
+ # attempt to fully paid the source lines and then, we need to manually fix them to get a perfect
+ # match.
+ payment_balance = abs(sum(counterpart_lines.mapped('balance')))
+ payment_amount_currency = abs(sum(counterpart_lines.mapped('amount_currency')))
+ if not payment.currency_id.is_zero(source_balance_converted - payment_amount_currency):
+ continue
+
+ delta_balance = source_balance - payment_balance
+
+ # Balance are already the same.
+ if self.company_currency_id.is_zero(delta_balance):
+ continue
+
+ # Fix the balance but make sure to peek the liquidity and counterpart lines first.
+ debit_lines = (liquidity_lines + counterpart_lines).filtered('debit')
+ credit_lines = (liquidity_lines + counterpart_lines).filtered('credit')
+
+ payment.move_id.write({'line_ids': [
+ (1, debit_lines[0].id, {'debit': debit_lines[0].debit + delta_balance}),
+ (1, credit_lines[0].id, {'credit': credit_lines[0].credit + delta_balance}),
+ ]})
+
+ payments.action_post()
+
+ domain = [('account_internal_type', 'in', ('receivable', 'payable')), ('reconciled', '=', False)]
+ for payment, lines in zip(payments, to_reconcile):
+
+ # When using the payment tokens, the payment could not be posted at this point (e.g. the transaction failed)
+ # and then, we can't perform the reconciliation.
+ if payment.state != 'posted':
+ continue
+
+ payment_lines = payment.line_ids.filtered_domain(domain)
+ for account in payment_lines.account_id:
+ (payment_lines + lines)\
+ .filtered_domain([('account_id', '=', account.id), ('reconciled', '=', False)])\
+ .reconcile()
+
+ return payments
+
+ def action_create_payments(self):
+ payments = self._create_payments()
+
+ if self._context.get('dont_redirect_to_payments'):
+ return True
+
+ action = {
+ 'name': _('Payments'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.payment',
+ 'context': {'create': False},
+ }
+ if len(payments) == 1:
+ action.update({
+ 'view_mode': 'form',
+ 'res_id': payments.id,
+ })
+ else:
+ action.update({
+ 'view_mode': 'tree,form',
+ 'domain': [('id', 'in', payments.ids)],
+ })
+ return action
diff --git a/addons/account/wizard/account_payment_register_views.xml b/addons/account/wizard/account_payment_register_views.xml
new file mode 100644
index 00000000..16eec30e
--- /dev/null
+++ b/addons/account/wizard/account_payment_register_views.xml
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+
+ <record id="view_account_payment_register_form" model="ir.ui.view">
+ <field name="name">account.payment.register.form</field>
+ <field name="model">account.payment.register</field>
+ <field name="arch" type="xml">
+ <form string="Register Payment">
+ <!-- Invisible fields -->
+ <field name="line_ids" invisible="1"/>
+ <field name="can_edit_wizard" invisible="1" force_save="1"/>
+ <field name="can_group_payments" invisible="1" force_save="1"/>
+ <field name="payment_type" invisible="1" force_save="1"/>
+ <field name="partner_type" invisible="1" force_save="1"/>
+ <field name="source_amount" invisible="1" force_save="1"/>
+ <field name="source_amount_currency" invisible="1" force_save="1"/>
+ <field name="source_currency_id" invisible="1" force_save="1"/>
+ <field name="company_id" invisible="1" force_save="1"/>
+ <field name="partner_id" invisible="1" force_save="1"/>
+ <field name="country_code" invisible="1" force_save="1"/>
+
+ <field name="show_partner_bank_account" invisible="1"/>
+ <field name="require_partner_bank_account" invisible="1"/>
+ <field name="hide_payment_method" invisible="1"/>
+ <field name="available_payment_method_ids" invisible="1"/>
+ <field name="company_currency_id" invisible="1"/>
+
+ <group>
+ <group name="group1">
+ <field name="journal_id" widget="selection" required="1"/>
+ <field name="payment_method_id" widget="radio"
+ required="1"
+ attrs="{'invisible': [('hide_payment_method', '=', True)]}"/>
+ <field name="partner_bank_id"
+ attrs="{'invisible': ['|', ('show_partner_bank_account', '=', False), '|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)],
+ 'required': [('require_partner_bank_account', '=', True), ('can_edit_wizard', '=', True), '|', ('can_group_payments', '=', False), ('group_payment', '=', False)]}"/>
+ <field name="group_payment"
+ attrs="{'invisible': [('can_group_payments', '=', False)]}"/>
+ </group>
+ <group name="group2">
+ <label for="amount"
+ attrs="{'invisible': ['|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)]}"/>
+ <div name="amount_div" class="o_row"
+ attrs="{'invisible': ['|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)]}">
+ <field name="amount"/>
+ <field name="currency_id"
+ options="{'no_create': True, 'no_open': True}"
+ groups="base.group_multi_currency"/>
+ </div>
+ <field name="payment_date"/>
+ <field name="communication"
+ attrs="{'invisible': ['|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)]}"/>
+ </group>
+ <group name="group3"
+ attrs="{'invisible': ['|', ('payment_difference', '=', 0.0), '|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)]}"
+ groups="account.group_account_readonly">
+ <label for="payment_difference"/>
+ <div>
+ <field name="payment_difference"/>
+ <field name="payment_difference_handling" widget="radio" nolabel="1"/>
+ <div attrs="{'invisible': [('payment_difference_handling','=','open')]}">
+ <label for="writeoff_account_id" string="Post Difference In" class="oe_edit_only"/>
+ <field name="writeoff_account_id"
+ string="Post Difference In"
+ options="{'no_create': True}"
+ attrs="{'required': [('payment_difference_handling', '=', 'reconcile')]}"/>
+ <label for="writeoff_label" class="oe_edit_only" string="Label"/>
+ <field name="writeoff_label" attrs="{'required': [('payment_difference_handling', '=', 'reconcile')]}"/>
+ </div>
+ </div>
+ </group>
+ </group>
+ <footer>
+ <button string="Create Payment" name="action_create_payments" type="object" class="oe_highlight"/>
+ <button string="Cancel" class="btn btn-secondary" special="cancel"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ </data>
+</odoo>
diff --git a/addons/account/wizard/account_report_common.py b/addons/account/wizard/account_report_common.py
new file mode 100644
index 00000000..e6317dae
--- /dev/null
+++ b/addons/account/wizard/account_report_common.py
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+from odoo.tools.misc import get_lang
+
+
+class AccountCommonReport(models.TransientModel):
+ _name = "account.common.report"
+ _description = "Account Common Report"
+
+ company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
+ journal_ids = fields.Many2many('account.journal', string='Journals', required=True, default=lambda self: self.env['account.journal'].search([('company_id', '=', self.company_id.id)]))
+ date_from = fields.Date(string='Start Date')
+ date_to = fields.Date(string='End Date')
+ target_move = fields.Selection([('posted', 'All Posted Entries'),
+ ('all', 'All Entries'),
+ ], string='Target Moves', required=True, default='posted')
+
+ @api.onchange('company_id')
+ def _onchange_company_id(self):
+ if self.company_id:
+ self.journal_ids = self.env['account.journal'].search(
+ [('company_id', '=', self.company_id.id)])
+ else:
+ self.journal_ids = self.env['account.journal'].search([])
+
+ def _build_contexts(self, data):
+ result = {}
+ result['journal_ids'] = 'journal_ids' in data['form'] and data['form']['journal_ids'] or False
+ result['state'] = 'target_move' in data['form'] and data['form']['target_move'] or ''
+ result['date_from'] = data['form']['date_from'] or False
+ result['date_to'] = data['form']['date_to'] or False
+ result['strict_range'] = True if result['date_from'] else False
+ result['company_id'] = data['form']['company_id'][0] or False
+ return result
+
+ def _print_report(self, data):
+ raise NotImplementedError()
+
+ def check_report(self):
+ self.ensure_one()
+ data = {}
+ data['ids'] = self.env.context.get('active_ids', [])
+ data['model'] = self.env.context.get('active_model', 'ir.ui.menu')
+ data['form'] = self.read(['date_from', 'date_to', 'journal_ids', 'target_move', 'company_id'])[0]
+ used_context = self._build_contexts(data)
+ data['form']['used_context'] = dict(used_context, lang=get_lang(self.env).code)
+ return self.with_context(discard_logo_check=True)._print_report(data)
diff --git a/addons/account/wizard/account_report_common_journal.py b/addons/account/wizard/account_report_common_journal.py
new file mode 100644
index 00000000..c00de454
--- /dev/null
+++ b/addons/account/wizard/account_report_common_journal.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+
+class AccountCommonJournalReport(models.TransientModel):
+ _name = 'account.common.journal.report'
+ _description = 'Common Journal Report'
+ _inherit = "account.common.report"
+
+ amount_currency = fields.Boolean('With Currency', help="Print Report with the currency column if the currency differs from the company currency.")
+
+ def pre_print_report(self, data):
+ data['form'].update({'amount_currency': self.amount_currency})
+ return data
diff --git a/addons/account/wizard/account_report_common_view.xml b/addons/account/wizard/account_report_common_view.xml
new file mode 100644
index 00000000..6333f125
--- /dev/null
+++ b/addons/account/wizard/account_report_common_view.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+ <record id="account_common_report_view" model="ir.ui.view">
+ <field name="name">Common Report</field>
+ <field name="model">account.common.report</field>
+ <field name="arch" type="xml">
+ <form string="Report Options">
+ <group col="4">
+ <field name="target_move" widget="radio"/>
+ <field name="date_from"/>
+ <field name="date_to"/>
+ </group>
+ <group>
+ <field name="journal_ids" widget="many2many_tags" options="{'no_create': True}"/>
+ <field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/>
+ </group>
+ <footer>
+ <button name="check_report" string="Print" type="object" default_focus="1" class="oe_highlight"/>
+ <button string="Cancel" class="btn btn-secondary" special="cancel" />
+ </footer>
+ </form>
+ </field>
+ </record>
+
+</odoo>
diff --git a/addons/account/wizard/account_report_print_journal.py b/addons/account/wizard/account_report_print_journal.py
new file mode 100644
index 00000000..fee61441
--- /dev/null
+++ b/addons/account/wizard/account_report_print_journal.py
@@ -0,0 +1,17 @@
+# -*- coding: utf-8 -*-
+
+from odoo import fields, models
+
+
+class AccountPrintJournal(models.TransientModel):
+ _inherit = "account.common.journal.report"
+ _name = "account.print.journal"
+ _description = "Account Print Journal"
+
+ sort_selection = fields.Selection([('date', 'Date'), ('move_name', 'Journal Entry Number'),], 'Entries Sorted by', required=True, default='move_name')
+ journal_ids = fields.Many2many('account.journal', string='Journals', required=True, default=lambda self: self.env['account.journal'].search([('type', 'in', ['sale', 'purchase'])]))
+
+ def _print_report(self, data):
+ data = self.pre_print_report(data)
+ data['form'].update({'sort_selection': self.sort_selection})
+ return self.env.ref('account.action_report_journal').with_context(landscape=True).report_action(self, data=data)
diff --git a/addons/account/wizard/account_resequence.py b/addons/account/wizard/account_resequence.py
new file mode 100644
index 00000000..1e0f2006
--- /dev/null
+++ b/addons/account/wizard/account_resequence.py
@@ -0,0 +1,145 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+from odoo.tools.date_utils import get_month, get_fiscal_year
+from odoo.tools.misc import format_date
+
+import re
+from collections import defaultdict
+import json
+
+
+class ReSequenceWizard(models.TransientModel):
+ _name = 'account.resequence.wizard'
+ _description = 'Remake the sequence of Journal Entries.'
+
+ sequence_number_reset = fields.Char(compute='_compute_sequence_number_reset')
+ first_date = fields.Date(help="Date (inclusive) from which the numbers are resequenced.")
+ end_date = fields.Date(help="Date (inclusive) to which the numbers are resequenced. If not set, all Journal Entries up to the end of the period are resequenced.")
+ first_name = fields.Char(compute="_compute_first_name", readonly=False, store=True, required=True, string="First New Sequence")
+ ordering = fields.Selection([('keep', 'Keep current order'), ('date', 'Reorder by accounting date')], required=True, default='keep')
+ move_ids = fields.Many2many('account.move')
+ new_values = fields.Text(compute='_compute_new_values')
+ preview_moves = fields.Text(compute='_compute_preview_moves')
+
+ @api.model
+ def default_get(self, fields_list):
+ values = super(ReSequenceWizard, self).default_get(fields_list)
+ active_move_ids = self.env['account.move']
+ if self.env.context['active_model'] == 'account.move' and 'active_ids' in self.env.context:
+ active_move_ids = self.env['account.move'].browse(self.env.context['active_ids'])
+ if len(active_move_ids.journal_id) > 1:
+ raise UserError(_('You can only resequence items from the same journal'))
+ move_types = set(active_move_ids.mapped('move_type'))
+ if (
+ active_move_ids.journal_id.refund_sequence
+ and ('in_refund' in move_types or 'out_refund' in move_types)
+ and len(move_types) > 1
+ ):
+ raise UserError(_('The sequences of this journal are different for Invoices and Refunds but you selected some of both types.'))
+ values['move_ids'] = [(6, 0, active_move_ids.ids)]
+ return values
+
+ @api.depends('first_name')
+ def _compute_sequence_number_reset(self):
+ for record in self:
+ record.sequence_number_reset = record.move_ids[0]._deduce_sequence_number_reset(record.first_name)
+
+ @api.depends('move_ids')
+ def _compute_first_name(self):
+ self.first_name = ""
+ for record in self:
+ if record.move_ids:
+ record.first_name = min(record.move_ids._origin.mapped('name'))
+
+ @api.depends('new_values', 'ordering')
+ def _compute_preview_moves(self):
+ """Reduce the computed new_values to a smaller set to display in the preview."""
+ for record in self:
+ new_values = sorted(json.loads(record.new_values).values(), key=lambda x: x['server-date'], reverse=True)
+ changeLines = []
+ in_elipsis = 0
+ previous_line = None
+ for i, line in enumerate(new_values):
+ if i < 3 or i == len(new_values) - 1 or line['new_by_name'] != line['new_by_date'] \
+ or (self.sequence_number_reset == 'year' and line['server-date'][0:4] != previous_line['server-date'][0:4])\
+ or (self.sequence_number_reset == 'month' and line['server-date'][0:7] != previous_line['server-date'][0:7]):
+ if in_elipsis:
+ changeLines.append({'id': 'other_' + str(line['id']), 'current_name': _('... (%s other)', in_elipsis), 'new_by_name': '...', 'new_by_date': '...', 'date': '...'})
+ in_elipsis = 0
+ changeLines.append(line)
+ else:
+ in_elipsis += 1
+ previous_line = line
+
+ record.preview_moves = json.dumps({
+ 'ordering': record.ordering,
+ 'changeLines': changeLines,
+ })
+
+ @api.depends('first_name', 'move_ids', 'sequence_number_reset')
+ def _compute_new_values(self):
+ """Compute the proposed new values.
+
+ Sets a json string on new_values representing a dictionary thats maps account.move
+ ids to a disctionay containing the name if we execute the action, and information
+ relative to the preview widget.
+ """
+ def _get_move_key(move_id):
+ if self.sequence_number_reset == 'year':
+ return move_id.date.year
+ elif self.sequence_number_reset == 'month':
+ return (move_id.date.year, move_id.date.month)
+ return 'default'
+
+ self.new_values = "{}"
+ for record in self.filtered('first_name'):
+ moves_by_period = defaultdict(lambda: record.env['account.move'])
+ for move in record.move_ids._origin: # Sort the moves by period depending on the sequence number reset
+ moves_by_period[_get_move_key(move)] += move
+
+ format, format_values = self.env['account.move']._get_sequence_format_param(record.first_name)
+
+ new_values = {}
+ for j, period_recs in enumerate(moves_by_period.values()):
+ # compute the new values period by period
+ for move in period_recs:
+ new_values[move.id] = {
+ 'id': move.id,
+ 'current_name': move.name,
+ 'state': move.state,
+ 'date': format_date(self.env, move.date),
+ 'server-date': str(move.date),
+ }
+
+ new_name_list = [format.format(**{
+ **format_values,
+ 'year': period_recs[0].date.year % (10 ** format_values['year_length']),
+ 'month': period_recs[0].date.month,
+ 'seq': i + (format_values['seq'] if j == (len(moves_by_period)-1) else 1),
+ }) for i in range(len(period_recs))]
+
+ # For all the moves of this period, assign the name by increasing initial name
+ for move, new_name in zip(period_recs.sorted(lambda m: (m.sequence_prefix, m.sequence_number)), new_name_list):
+ new_values[move.id]['new_by_name'] = new_name
+ # For all the moves of this period, assign the name by increasing date
+ for move, new_name in zip(period_recs.sorted(lambda m: (m.date, m.name, m.id)), new_name_list):
+ new_values[move.id]['new_by_date'] = new_name
+
+ record.new_values = json.dumps(new_values)
+
+ def resequence(self):
+ new_values = json.loads(self.new_values)
+ # Can't change the name of a posted invoice, but we do not want to have the chatter
+ # logging 3 separate changes with [state to draft], [change of name], [state to posted]
+ self.with_context(tracking_disable=True).move_ids.state = 'draft'
+ if self.move_ids.journal_id and self.move_ids.journal_id.restrict_mode_hash_table:
+ if self.ordering == 'date':
+ raise UserError(_('You can not reorder sequence by date when the journal is locked with a hash.'))
+ for move_id in self.move_ids:
+ if str(move_id.id) in new_values:
+ if self.ordering == 'keep':
+ move_id.name = new_values[str(move_id.id)]['new_by_name']
+ else:
+ move_id.name = new_values[str(move_id.id)]['new_by_date']
+ move_id.with_context(tracking_disable=True).state = new_values[str(move_id.id)]['state']
diff --git a/addons/account/wizard/account_resequence_views.xml b/addons/account/wizard/account_resequence_views.xml
new file mode 100644
index 00000000..c1d5cb15
--- /dev/null
+++ b/addons/account/wizard/account_resequence_views.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+ <record id="account_resequence_view" model="ir.ui.view">
+ <field name="name">Re-sequence Journal Entries</field>
+ <field name="model">account.resequence.wizard</field>
+ <field name="arch" type="xml">
+ <form string="Re-Sequence">
+ <field name="move_ids" invisible="1"/>
+ <field name="new_values" invisible="1"/>
+ <field name="sequence_number_reset" invisible="1"/>
+ <group>
+ <group>
+ <field name="ordering" widget="radio"/>
+ </group>
+ <group>
+ <field name="first_name"/>
+ </group>
+ </group>
+ <label for="preview_moves" string="Preview Modifications"/>
+ <field name="preview_moves" widget="account_resequence_widget"/>
+ <footer>
+ <button string="Confirm" name="resequence" type="object" default_focus="1" class="btn-primary"/>
+ <button string="Cancel" class="btn-secondary" special="cancel"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="action_account_resequence" model="ir.actions.act_window">
+ <field name="name">Resequence</field>
+ <field name="res_model">account.resequence.wizard</field>
+ <field name="view_mode">form</field>
+ <field name="view_id" ref="account_resequence_view"/>
+ <field name="target">new</field>
+ <field name="groups_id" eval="[(6, 0, [ref('account.group_account_manager'), ref('base.group_system')])]"/>
+ <field name="binding_model_id" ref="account.model_account_move" />
+ <field name="binding_view_types">list</field>
+ </record>
+ </data>
+</odoo>
diff --git a/addons/account/wizard/account_tour_upload_bill.py b/addons/account/wizard/account_tour_upload_bill.py
new file mode 100644
index 00000000..26135e1e
--- /dev/null
+++ b/addons/account/wizard/account_tour_upload_bill.py
@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models, api, _
+from odoo.modules.module import get_resource_path
+import base64
+
+class AccountTourUploadBill(models.TransientModel):
+ _name = 'account.tour.upload.bill'
+ _description = 'Account tour upload bill'
+ _inherits = {'mail.compose.message': 'composer_id'}
+
+ composer_id = fields.Many2one('mail.compose.message', string='Composer', required=True, ondelete='cascade')
+
+ selection = fields.Selection(
+ selection=lambda self: self._selection_values(),
+ default="sample"
+ )
+
+ sample_bill_preview = fields.Binary(
+ readonly=True,
+ compute='_compute_sample_bill_image'
+ )
+
+ def _selection_values(self):
+ journal_alias = self.env['account.journal'] \
+ .search([('type', '=', 'purchase'), ('company_id', '=', self.env.company.id)], limit=1)
+
+ return [('sample', _('Try a sample vendor bill')),
+ ('upload', _('Upload your own bill')),
+ ('email', _('Or send a bill to %s@%s', journal_alias.alias_name, journal_alias.alias_domain))]
+
+ def _compute_sample_bill_image(self):
+ """ Retrieve sample bill with facturx to speed up onboarding """
+ try:
+ path = get_resource_path('account_edi_facturx', 'data/files', 'Invoice.pdf')
+ self.sample_bill_preview = base64.b64encode(open(path, 'rb').read()) if path else False
+ except (IOError, OSError):
+ self.sample_bill_preview = False
+ return
+
+ def _action_list_view_bill(self, bill_ids=[]):
+ context = dict(self._context)
+ context['default_move_type'] = 'in_invoice'
+ return {
+ 'name': _('Generated Documents'),
+ 'domain': [('id', 'in', bill_ids)],
+ 'view_mode': 'tree,form',
+ 'res_model': 'account.move',
+ 'views': [[False, "tree"], [False, "form"]],
+ 'type': 'ir.actions.act_window',
+ 'context': context
+ }
+
+ def apply(self):
+ purchase_journal = self.env['account.journal'].search([('type', '=', 'purchase')], limit=1)
+ if self.selection == 'upload':
+ return purchase_journal.with_context(default_journal_id=purchase_journal.id, default_move_type='in_invoice').create_invoice_from_attachment(attachment_ids=self.attachment_ids.ids)
+ elif self.selection == 'sample':
+ attachment = self.env['ir.attachment'].create({
+ 'type': 'binary',
+ 'name': 'INV/2020/0011.pdf',
+ 'res_model': 'mail.compose.message',
+ 'datas': self.sample_bill_preview,
+ })
+ bill_action = purchase_journal.with_context(default_journal_id=purchase_journal.id, default_move_type='in_invoice').create_invoice_from_attachment(attachment.ids)
+ bill = self.env['account.move'].browse(bill_action['res_id'])
+ bill.write({
+ 'partner_id': self.env.ref('base.main_partner').id,
+ 'ref': 'INV/2020/0011'
+ })
+ return self._action_list_view_bill(bill.ids)
+ else:
+ email_alias = '%s@%s' % (purchase_journal.alias_name, purchase_journal.alias_domain)
+ new_wizard = self.env['account.tour.upload.bill.email.confirm'].create({'email_alias': email_alias})
+ view_id = self.env.ref('account.account_tour_upload_bill_email_confirm').id
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Confirm'),
+ 'view_mode': 'form',
+ 'res_model': 'account.tour.upload.bill.email.confirm',
+ 'target': 'new',
+ 'res_id': new_wizard.id,
+ 'views': [[view_id, 'form']],
+ }
+
+
+class AccountTourUploadBillEmailConfirm(models.TransientModel):
+ _name = 'account.tour.upload.bill.email.confirm'
+ _description = 'Account tour upload bill email confirm'
+
+ email_alias = fields.Char(readonly=True)
+
+ def apply(self):
+ purchase_journal = self.env['account.journal'].search([('type', '=', 'purchase')], limit=1)
+ bill_ids = self.env['account.move'].search([('journal_id', '=', purchase_journal.id)]).ids
+ return self.env['account.tour.upload.bill']._action_list_view_bill(bill_ids)
diff --git a/addons/account/wizard/account_tour_upload_bill.xml b/addons/account/wizard/account_tour_upload_bill.xml
new file mode 100644
index 00000000..a3ceb8dd
--- /dev/null
+++ b/addons/account/wizard/account_tour_upload_bill.xml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+ <record id="account_tour_upload_bill" model="ir.ui.view">
+ <field name="name">account.tour.upload.bill</field>
+ <field name="model">account.tour.upload.bill</field>
+ <field name="arch" type="xml">
+ <form>
+ <sheet>
+ <h2>With Odoo, you won't have to records bills manually</h2>
+ <p>We process bills automatically so that you only have to validate them. Choose how you want to test our artificial intelligence engine:</p>
+ <group>
+ <group>
+ <field name="selection" widget="radio" nolabel="1"/>
+ </group>
+ <group attrs="{'invisible': [('selection', '!=', 'sample')]}">
+ <field name="sample_bill_preview" widget="pdf_viewer" nolabel="1"/>
+ </group>
+ <group attrs="{'invisible': [('selection', '!=', 'upload')]}">
+ <field name="attachment_ids" widget="many2many_binary" string="Attach a file" nolabel="1" colspan="2"/>
+ </group>
+ </group>
+ </sheet>
+ <footer>
+ <button string="Continue" type="object" name="apply" class="btn-primary"/>
+ <button string="Discard" class="btn-secondary" special="cancel" />
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="account_tour_upload_bill_email_confirm" model="ir.ui.view">
+ <field name="name">account.tour.upload.bill.email.confirm</field>
+ <field name="model">account.tour.upload.bill.email.confirm</field>
+ <field name="arch" type="xml">
+ <form>
+ <sheet>
+ <p>Send your email to <field name="email_alias" class="oe_inline"/> with a pdf of an invoice as attachment.</p>
+ <p>Once done, press continue.</p>
+ </sheet>
+ <footer>
+ <button string="Continue" type="object" name="apply" class="btn-primary"/>
+ <button string="Discard" class="btn-secondary" special="cancel" />
+ </footer>
+ </form>
+ </field>
+ </record>
+ </data>
+</odoo>
diff --git a/addons/account/wizard/account_unreconcile.py b/addons/account/wizard/account_unreconcile.py
new file mode 100644
index 00000000..790e9b28
--- /dev/null
+++ b/addons/account/wizard/account_unreconcile.py
@@ -0,0 +1,12 @@
+from odoo import models, api
+
+
+class AccountUnreconcile(models.TransientModel):
+ _name = "account.unreconcile"
+ _description = "Account Unreconcile"
+
+ def trans_unrec(self):
+ context = dict(self._context or {})
+ if context.get('active_ids', False):
+ self.env['account.move.line'].browse(context.get('active_ids')).remove_move_reconcile()
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/addons/account/wizard/account_unreconcile_view.xml b/addons/account/wizard/account_unreconcile_view.xml
new file mode 100644
index 00000000..c1175301
--- /dev/null
+++ b/addons/account/wizard/account_unreconcile_view.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+
+ <record id="account_unreconcile_view" model="ir.ui.view">
+ <field name="name">Unreconcile Entries</field>
+ <field name="model">account.unreconcile</field>
+ <field name="arch" type="xml">
+ <form string="Unreconcile">
+ <separator string="Unreconcile Transactions"/>
+ <form class="o_form_label">If you unreconcile transactions, you must also verify all the actions that are linked to those transactions because they will not be disabled</form>
+ <footer>
+ <button string="Unreconcile" name="trans_unrec" type="object" default_focus="1" class="btn-primary"/>
+ <button string="Cancel" class="btn-secondary" special="cancel"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="action_account_unreconcile" model="ir.actions.act_window">
+ <field name="name">Unreconcile</field>
+ <field name="groups_id" eval="[(4, ref('account.group_account_user'))]"/>
+ <field name="res_model">account.unreconcile</field>
+ <field name="view_mode">form</field>
+ <field name="view_id" ref="account_unreconcile_view"/>
+ <field name="target">new</field>
+ <field name="binding_model_id" ref="account.model_account_move_line" />
+ <field name="binding_view_types">list</field>
+ </record>
+
+ </data>
+</odoo>
diff --git a/addons/account/wizard/account_validate_account_move.py b/addons/account/wizard/account_validate_account_move.py
new file mode 100644
index 00000000..3be48a2b
--- /dev/null
+++ b/addons/account/wizard/account_validate_account_move.py
@@ -0,0 +1,23 @@
+from odoo import models, fields, _
+from odoo.exceptions import UserError
+
+
+class ValidateAccountMove(models.TransientModel):
+ _name = "validate.account.move"
+ _description = "Validate Account Move"
+
+ force_post = fields.Boolean(string="Force", help="Entries in the future are set to be auto-posted by default. Check this checkbox to post them now.")
+
+ def validate_move(self):
+ if self._context.get('active_model') == 'account.move':
+ domain = [('id', 'in', self._context.get('active_ids', [])), ('state', '=', 'draft')]
+ elif self._context.get('active_model') == 'account.journal':
+ domain = [('journal_id', '=', self._context.get('active_id')), ('state', '=', 'draft')]
+ else:
+ raise UserError(_("Missing 'active_model' in context."))
+
+ moves = self.env['account.move'].search(domain).filtered('line_ids')
+ if not moves:
+ raise UserError(_('There are no journal items in the draft state to post.'))
+ moves._post(not self.force_post)
+ return {'type': 'ir.actions.act_window_close'}
diff --git a/addons/account/wizard/account_validate_move_view.xml b/addons/account/wizard/account_validate_move_view.xml
new file mode 100644
index 00000000..e4494cfc
--- /dev/null
+++ b/addons/account/wizard/account_validate_move_view.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data>
+
+ <!--Account Move lines-->
+ <record id="validate_account_move_view" model="ir.ui.view">
+ <field name="name">Post Journal Entries</field>
+ <field name="model">validate.account.move</field>
+ <field name="arch" type="xml">
+ <form string="Post Journal Entries">
+ <group>
+ <field name="force_post"/>
+ </group>
+ <span class="o_form_label">All selected journal entries will be validated and posted. You won't be able to modify them afterwards.</span>
+ <footer>
+ <button string="Post Journal Entries" name="validate_move" type="object" default_focus="1" class="btn-primary"/>
+ <button string="Cancel" class="btn-secondary" special="cancel"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="action_validate_account_move" model="ir.actions.act_window">
+ <field name="name">Post entries</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="res_model">validate.account.move</field>
+ <field name="view_mode">form</field>
+ <field name="view_id" ref="validate_account_move_view"/>
+ <field name="context">{}</field>
+ <field name="target">new</field>
+ <field name="help">This wizard will validate all journal entries selected. Once journal entries are validated, you can not update them anymore.</field>
+ <field name="groups_id" eval="[(4, ref('account.group_account_invoice'))]"/>
+ <field name="binding_model_id" ref="account.model_account_move" />
+ <field name="binding_view_types">list</field>
+ </record>
+
+ </data>
+</odoo>
diff --git a/addons/account/wizard/base_document_layout.py b/addons/account/wizard/base_document_layout.py
new file mode 100644
index 00000000..ab76e69a
--- /dev/null
+++ b/addons/account/wizard/base_document_layout.py
@@ -0,0 +1,11 @@
+from odoo import models
+
+
+class BaseDocumentLayout(models.TransientModel):
+ _inherit = 'base.document.layout'
+
+ def document_layout_save(self):
+ res = super(BaseDocumentLayout, self).document_layout_save()
+ for wizard in self:
+ wizard.company_id.action_save_onboarding_invoice_layout()
+ return res
diff --git a/addons/account/wizard/pos_box.py b/addons/account/wizard/pos_box.py
new file mode 100644
index 00000000..7c2931e0
--- /dev/null
+++ b/addons/account/wizard/pos_box.py
@@ -0,0 +1,54 @@
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError
+
+class CashBox(models.TransientModel):
+ _register = False
+
+ name = fields.Char(string='Reason', required=True)
+ # Attention, we don't set a domain, because there is a journal_type key
+ # in the context of the action
+ amount = fields.Float(string='Amount', digits=0, required=True)
+
+ def run(self):
+ context = dict(self._context or {})
+ active_model = context.get('active_model', False)
+ active_ids = context.get('active_ids', [])
+
+ records = self.env[active_model].browse(active_ids)
+
+ return self._run(records)
+
+ def _run(self, records):
+ for box in self:
+ for record in records:
+ if not record.journal_id:
+ raise UserError(_("Please check that the field 'Journal' is set on the Bank Statement"))
+ if not record.journal_id.company_id.transfer_account_id:
+ raise UserError(_("Please check that the field 'Transfer Account' is set on the company."))
+ box._create_bank_statement_line(record)
+ return {}
+
+ def _create_bank_statement_line(self, record):
+ for box in self:
+ if record.state == 'confirm':
+ raise UserError(_("You cannot put/take money in/out for a bank statement which is closed."))
+ values = box._calculate_values_for_statement_line(record)
+ account = record.journal_id.company_id.transfer_account_id
+ self.env['account.bank.statement.line'].with_context(counterpart_account_id=account.id).sudo().create(values)
+
+
+class CashBoxOut(CashBox):
+ _name = 'cash.box.out'
+ _description = 'Cash Box Out'
+
+ def _calculate_values_for_statement_line(self, record):
+ if not record.journal_id.company_id.transfer_account_id:
+ raise UserError(_("You have to define an 'Internal Transfer Account' in your cash register's journal."))
+ amount = self.amount or 0.0
+ return {
+ 'date': record.date,
+ 'statement_id': record.id,
+ 'journal_id': record.journal_id.id,
+ 'amount': amount,
+ 'payment_ref': self.name,
+ }
diff --git a/addons/account/wizard/pos_box.xml b/addons/account/wizard/pos_box.xml
new file mode 100644
index 00000000..975b7fd5
--- /dev/null
+++ b/addons/account/wizard/pos_box.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<odoo>
+ <data>
+ <record model="ir.ui.view" id="cash_box_out_form">
+ <field name="name">cash_box_out</field>
+ <field name="model">cash.box.out</field>
+ <field name="arch" type="xml">
+ <form string="Take Money In/Out">
+ <separator string="Describe why you put/take money from the cash register:"/>
+ <group>
+ <field name="name" class="oe_inline"/>
+ <field name="amount" class="oe_inline"/>
+ </group>
+
+ <footer>
+ <button name="run" string="Take Money In/Out" type="object" class="btn-primary"/>
+ <button class="btn-secondary" special="cancel" string="Cancel" />
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="action_cash_box_out" model="ir.actions.act_window">
+ <field name="name">Take Money In/Out</field>
+ <field name="res_model">cash.box.out</field>
+ <field name="view_mode">form</field>
+ <field name="target">new</field>
+ </record>
+ </data>
+</odoo>
diff --git a/addons/account/wizard/setup_wizards.py b/addons/account/wizard/setup_wizards.py
new file mode 100644
index 00000000..02d772bd
--- /dev/null
+++ b/addons/account/wizard/setup_wizards.py
@@ -0,0 +1,144 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import date, timedelta
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class FinancialYearOpeningWizard(models.TransientModel):
+ _name = 'account.financial.year.op'
+ _description = 'Opening Balance of Financial Year'
+
+ company_id = fields.Many2one(comodel_name='res.company', required=True)
+ opening_move_posted = fields.Boolean(string='Opening Move Posted', compute='_compute_opening_move_posted')
+ opening_date = fields.Date(string='Opening Date', required=True, related='company_id.account_opening_date', help="Date from which the accounting is managed in Odoo. It is the date of the opening entry.", readonly=False)
+ fiscalyear_last_day = fields.Integer(related="company_id.fiscalyear_last_day", required=True, readonly=False,
+ help="The last day of the month will be used if the chosen day doesn't exist.")
+ fiscalyear_last_month = fields.Selection(related="company_id.fiscalyear_last_month", readonly=False,
+ required=True,
+ help="The last day of the month will be used if the chosen day doesn't exist.")
+
+ @api.depends('company_id.account_opening_move_id')
+ def _compute_opening_move_posted(self):
+ for record in self:
+ record.opening_move_posted = record.company_id.opening_move_posted()
+
+ @api.constrains('fiscalyear_last_day', 'fiscalyear_last_month')
+ def _check_fiscalyear(self):
+ # We try if the date exists in 2020, which is a leap year.
+ # We do not define the constrain on res.company, since the recomputation of the related
+ # fields is done one field at a time.
+ for wiz in self:
+ try:
+ date(2020, int(wiz.fiscalyear_last_month), wiz.fiscalyear_last_day)
+ except ValueError:
+ raise ValidationError(
+ _('Incorrect fiscal year date: day is out of range for month. Month: %s; Day: %s') %
+ (wiz.fiscalyear_last_month, wiz.fiscalyear_last_day)
+ )
+
+ def write(self, vals):
+ # Amazing workaround: non-stored related fields on company are a BAD idea since the 3 fields
+ # must follow the constraint '_check_fiscalyear_last_day'. The thing is, in case of related
+ # fields, the inverse write is done one value at a time, and thus the constraint is verified
+ # one value at a time... so it is likely to fail.
+ for wiz in self:
+ wiz.company_id.write({
+ 'fiscalyear_last_day': vals.get('fiscalyear_last_day') or wiz.company_id.fiscalyear_last_day,
+ 'fiscalyear_last_month': vals.get('fiscalyear_last_month') or wiz.company_id.fiscalyear_last_month,
+ 'account_opening_date': vals.get('opening_date') or wiz.company_id.account_opening_date,
+ })
+ wiz.company_id.account_opening_move_id.write({
+ 'date': fields.Date.from_string(vals.get('opening_date') or wiz.company_id.account_opening_date) - timedelta(days=1),
+ })
+
+ vals.pop('opening_date', None)
+ vals.pop('fiscalyear_last_day', None)
+ vals.pop('fiscalyear_last_month', None)
+ return super().write(vals)
+
+ def action_save_onboarding_fiscal_year(self):
+ self.env.company.sudo().set_onboarding_step_done('account_setup_fy_data_state')
+
+
+class SetupBarBankConfigWizard(models.TransientModel):
+ _inherits = {'res.partner.bank': 'res_partner_bank_id'}
+ _name = 'account.setup.bank.manual.config'
+ _description = 'Bank setup manual config'
+ _check_company_auto = True
+
+ res_partner_bank_id = fields.Many2one(comodel_name='res.partner.bank', ondelete='cascade', required=True)
+ new_journal_name = fields.Char(default=lambda self: self.linked_journal_id.name, inverse='set_linked_journal_id', required=True, help='Will be used to name the Journal related to this bank account')
+ linked_journal_id = fields.Many2one(string="Journal",
+ comodel_name='account.journal', inverse='set_linked_journal_id',
+ compute="_compute_linked_journal_id", check_company=True,
+ domain="[('type','=','bank'), ('bank_account_id', '=', False), ('company_id', '=', company_id)]")
+ bank_bic = fields.Char(related='bank_id.bic', readonly=False, string="Bic")
+ num_journals_without_account = fields.Integer(default=lambda self: self._number_unlinked_journal())
+
+ def _number_unlinked_journal(self):
+ return self.env['account.journal'].search([('type', '=', 'bank'), ('bank_account_id', '=', False),
+ ('id', '!=', self.default_linked_journal_id())], count=True)
+
+ @api.onchange('acc_number')
+ def _onchange_acc_number(self):
+ for record in self:
+ record.new_journal_name = record.acc_number
+
+ @api.model
+ def create(self, vals):
+ """ This wizard is only used to setup an account for the current active
+ company, so we always inject the corresponding partner when creating
+ the model.
+ """
+ vals['partner_id'] = self.env.company.partner_id.id
+ vals['new_journal_name'] = vals['acc_number']
+
+ # If no bank has been selected, but we have a bic, we are using it to find or create the bank
+ if not vals['bank_id'] and vals['bank_bic']:
+ vals['bank_id'] = self.env['res.bank'].search([('bic', '=', vals['bank_bic'])], limit=1).id \
+ or self.env['res.bank'].create({'name': vals['bank_bic'], 'bic': vals['bank_bic']}).id
+
+ return super(SetupBarBankConfigWizard, self).create(vals)
+
+ @api.onchange('linked_journal_id')
+ def _onchange_new_journal_related_data(self):
+ for record in self:
+ if record.linked_journal_id:
+ record.new_journal_name = record.linked_journal_id.name
+
+ @api.depends('journal_id') # Despite its name, journal_id is actually a One2many field
+ def _compute_linked_journal_id(self):
+ for record in self:
+ record.linked_journal_id = record.journal_id and record.journal_id[0] or record.default_linked_journal_id()
+
+ def default_linked_journal_id(self):
+ default = self.env['account.journal'].search([('type', '=', 'bank'), ('bank_account_id', '=', False)], limit=1)
+ return default[:1].id
+
+ def set_linked_journal_id(self):
+ """ Called when saving the wizard.
+ """
+ for record in self:
+ selected_journal = record.linked_journal_id
+ if not selected_journal:
+ new_journal_code = self.env['account.journal'].get_next_bank_cash_default_code('bank', self.env.company)
+ company = self.env.company
+ selected_journal = self.env['account.journal'].create({
+ 'name': record.new_journal_name,
+ 'code': new_journal_code,
+ 'type': 'bank',
+ 'company_id': company.id,
+ 'bank_account_id': record.res_partner_bank_id.id,
+ })
+ else:
+ selected_journal.bank_account_id = record.res_partner_bank_id.id
+ selected_journal.name = record.new_journal_name
+
+ def validate(self):
+ """ Called by the validation button of this wizard. Serves as an
+ extension hook in account_bank_statement_import.
+ """
+ self.linked_journal_id.mark_bank_setup_as_done_action()
diff --git a/addons/account/wizard/setup_wizards_view.xml b/addons/account/wizard/setup_wizards_view.xml
new file mode 100644
index 00000000..3ae4ae67
--- /dev/null
+++ b/addons/account/wizard/setup_wizards_view.xml
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<odoo>
+ <data>
+ <record id="setup_financial_year_opening_form" model="ir.ui.view">
+ <field name="name">account.financial.year.op.setup.wizard.form</field>
+ <field name="model">account.financial.year.op</field>
+ <field name="arch" type="xml">
+ <form>
+ <sheet>
+ <group>
+ <group string="Fiscal Years">
+ <field name="opening_move_posted" invisible="1"/>
+ <field name="opening_date" attrs="{'readonly': [('opening_move_posted', '=', True)]}"/>
+
+ <label for="fiscalyear_last_month" string="Fiscal Year End"/>
+ <div>
+ <field name="fiscalyear_last_day" class="oe_inline text-center" style="width: 20% !important;"/>
+ <span style="width: 5%; display: inline-block"/>
+ <field name="fiscalyear_last_month" class="oe_inline" style="width: 75% !important;"/>
+ </div>
+ </group>
+ </group>
+ </sheet>
+ <footer>
+ <button name="action_save_onboarding_fiscal_year" string="Apply"
+ class="oe_highlight" type="object" />
+ <button special="cancel" string="Cancel" />
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="setup_bank_account_wizard" model="ir.ui.view">
+ <field name="name">account.online.sync.res.partner.bank.setup.form</field>
+ <field name="model">account.setup.bank.manual.config</field>
+ <field name="arch" type="xml">
+ <form>
+ <field name="num_journals_without_account" invisible="1"/>
+ <field name="journal_id" invisible="1"/>
+ <field name="company_id" invisible="1"/>
+ <field name="linked_journal_id" invisible="1"/>
+ <sheet>
+ <group>
+ <group>
+ <field name="acc_number" placeholder="e.g BE15001559627230"/>
+ <field name="bank_id" placeholder="e.g Bank of America"/>
+ <field name="bank_bic" placeholder="e.g GEBABEBB" string="Bank Identifier Code"/>
+ </group>
+ </group>
+ <group attrs="{'invisible': [('num_journals_without_account', '=', 0)]}">
+ <group>
+ <field name="linked_journal_id" options="{'no_create': True}"/>
+ </group>
+ <group>
+ <span class="text-muted">Leave empty to create a new journal for this bank account, or select a journal to link it with the bank account.</span>
+ </group>
+ </group>
+ </sheet>
+ <footer>
+ <button string="Create" class="oe_highlight" type="object" name="validate"/>
+ <button string="Cancel" special="cancel"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="init_accounts_tree" model="ir.ui.view">
+ <field name="name">account.setup.opening.move.line.tree</field>
+ <field name="model">account.account</field>
+ <field name="arch" type="xml">
+ <tree editable="top" create="1" delete="1" multi_edit="1" decoration-muted="opening_debit == 0 and opening_credit == 0">
+ <field name="code"/>
+ <field name="name"/>
+ <field name="company_id" invisible="1"/>
+ <field name="user_type_id" widget="account_hierarchy_selection"/>
+ <field name="reconcile" widget="boolean_toggle"/>
+ <field name="opening_debit"/>
+ <field name="opening_credit"/>
+ <field name="opening_balance" optional="hide"/>
+ <field name="tax_ids" optional="hide" widget="many2many_tags"/>
+ <field name="tag_ids" optional="hide" widget="many2many_tags"/>
+ <field name="allowed_journal_ids" optional="hide" widget="many2many_tags"/>
+ <button name="action_read_account" type="object" string="Setup" class="float-right btn-secondary"/>
+ </tree>
+ </field>
+ </record>
+
+ </data>
+</odoo>
diff --git a/addons/account/wizard/wizard_tax_adjustments.py b/addons/account/wizard/wizard_tax_adjustments.py
new file mode 100644
index 00000000..6214f572
--- /dev/null
+++ b/addons/account/wizard/wizard_tax_adjustments.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, fields, api
+
+
+class TaxAdjustments(models.TransientModel):
+ _name = 'tax.adjustments.wizard'
+ _description = 'Tax Adjustments Wizard'
+
+ def _get_default_journal(self):
+ return self.env['account.journal'].search([('type', '=', 'general')], limit=1).id
+
+ reason = fields.Char(string='Justification', required=True)
+ journal_id = fields.Many2one('account.journal', string='Journal', required=True, default=_get_default_journal, domain=[('type', '=', 'general')])
+ date = fields.Date(required=True, default=fields.Date.context_today)
+ debit_account_id = fields.Many2one('account.account', string='Debit account', required=True,
+ domain="[('deprecated', '=', False), ('is_off_balance', '=', False)]")
+ credit_account_id = fields.Many2one('account.account', string='Credit account', required=True,
+ domain="[('deprecated', '=', False), ('is_off_balance', '=', False)]")
+ amount = fields.Monetary(currency_field='company_currency_id', required=True)
+ adjustment_type = fields.Selection([('debit', 'Applied on debit journal item'), ('credit', 'Applied on credit journal item')], string="Adjustment Type", required=True)
+ tax_report_line_id = fields.Many2one(string="Report Line", comodel_name='account.tax.report.line', required=True, help="The report line to make an adjustment for.")
+ company_currency_id = fields.Many2one('res.currency', readonly=True, default=lambda x: x.env.company.currency_id)
+ report_id = fields.Many2one(string="Report", related='tax_report_line_id.report_id')
+
+
+ def create_move(self):
+ move_line_vals = []
+
+ is_debit = self.adjustment_type == 'debit'
+ sign_multiplier = (self.amount<0 and -1 or 1) * (self.adjustment_type == 'credit' and -1 or 1)
+ filter_lambda = (sign_multiplier < 0) and (lambda x: x.tax_negate) or (lambda x: not x.tax_negate)
+ adjustment_tag = self.tax_report_line_id.tag_ids.filtered(filter_lambda)
+
+ # Vals for the amls corresponding to the ajustment tag
+ move_line_vals.append((0, 0, {
+ 'name': self.reason,
+ 'debit': is_debit and abs(self.amount) or 0,
+ 'credit': not is_debit and abs(self.amount) or 0,
+ 'account_id': is_debit and self.debit_account_id.id or self.credit_account_id.id,
+ 'tax_tag_ids': [(6, False, [adjustment_tag.id])],
+ }))
+
+ # Vals for the counterpart line
+ move_line_vals.append((0, 0, {
+ 'name': self.reason,
+ 'debit': not is_debit and abs(self.amount) or 0,
+ 'credit': is_debit and abs(self.amount) or 0,
+ 'account_id': is_debit and self.credit_account_id.id or self.debit_account_id.id,
+ }))
+
+ # Create the move
+ vals = {
+ 'journal_id': self.journal_id.id,
+ 'date': self.date,
+ 'state': 'draft',
+ 'line_ids': move_line_vals,
+ }
+ move = self.env['account.move'].create(vals)
+ move._post()
+
+ # Return an action opening the created move
+ result = self.env['ir.actions.act_window']._for_xml_id('account.action_move_line_form')
+ result['views'] = [(False, 'form')]
+ result['res_id'] = move.id
+ return result
diff --git a/addons/account/wizard/wizard_tax_adjustments_view.xml b/addons/account/wizard/wizard_tax_adjustments_view.xml
new file mode 100644
index 00000000..39e98e27
--- /dev/null
+++ b/addons/account/wizard/wizard_tax_adjustments_view.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+ <record id="tax_adjustments_wizard" model="ir.ui.view">
+ <field name="name">tax.adjustments.wizard.form</field>
+ <field name="model">tax.adjustments.wizard</field>
+ <field name="arch" type="xml">
+ <form>
+ <h1>
+ <field name="reason" class="oe_inline" placeholder="Reason..."/>
+ </h1>
+ <group>
+ <field name="report_id" invisible="1"/>
+ <field name="tax_report_line_id" widget="selection" domain="[('tag_name', '!=', None)]"/>
+ </group>
+ <group>
+ <group>
+ <field name="amount"/>
+ </group>
+ <group>
+ <field name="adjustment_type"/>
+ </group>
+ <group string="Accounts">
+ <field name="debit_account_id"/>
+ <field name="credit_account_id"/>
+ </group>
+ <group string="Options">
+ <field name="journal_id"/>
+ <field name="date"/>
+ </group>
+ </group>
+ <footer>
+ <button name="create_move" string="Create and post move" type="object" default_focus="1" class="oe_highlight"/>
+ <button string="Cancel" class="btn btn-secondary" special="cancel" />
+ </footer>
+ </form>
+ </field>
+ </record>
+</odoo>