Initial commit: Accounting packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:47 +02:00
commit 4ef34c2317
2661 changed files with 1709616 additions and 0 deletions

View file

@ -0,0 +1,16 @@
# -*- 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 account_move_reversal
from . import account_resequence
from . import setup_wizards
from . import account_invoice_send
from . import base_document_layout
from . import account_payment_register
from . import account_tour_upload_bill
from . import accrued_orders
from . import base_partner_merge

View file

@ -0,0 +1,504 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools.misc import format_date, formatLang
from collections import defaultdict
from odoo.tools import groupby, frozendict
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") # JSON value of the moves to be created
preview_move_data = fields.Text(compute="_compute_preview_move_data") # 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),"
"('account_type', 'not in', ('asset_receivable', 'liability_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),"
"('account_type', 'not in', ('asset_receivable', 'liability_payable')),"
"('is_off_balance', '=', False)]",
compute="_compute_revenue_accrual_account",
inverse="_inverse_revenue_accrual_account",
)
lock_date_message = fields.Char(string="Lock Date Message", compute="_compute_lock_date_message")
# 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')
# 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 = min((record.total_amount / total) * 100, 100) # min() to avoid value being slightly over 100 due to rounding error
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('action', 'move_line_ids')
def _compute_lock_date_message(self):
for record in self:
record.lock_date_message = False
if record.action == 'change_period':
for aml in record.move_line_ids:
lock_date_message = aml.move_id._get_lock_date_message(aml.date, False)
if lock_date_message:
record.lock_date_message = lock_date_message
break
@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.constrains('date', 'move_line_ids')
def _check_date(self):
for wizard in self:
if wizard.move_line_ids.move_id._get_violated_lock_dates(wizard.date, False):
raise ValidationError(_("The date selected is protected by a lock date"))
@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.account_type != move_line_ids[0].account_id.account_type 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))
counterpart_distribution_amount = defaultdict(lambda: defaultdict(lambda: {}))
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)
grouping_key = (line.partner_id, counterpart_currency)
counterpart_balances[grouping_key]['amount_currency'] += counterpart_amount_currency
counterpart_balances[grouping_key]['balance'] += line.balance
if line.analytic_distribution:
for account_id, distribution in line.analytic_distribution.items():
# For the counterpart, we will need to make a prorata of the different distribution of the lines
# This computes the total balance for each analytic account, for each counterpart line to generate
distribution_values = counterpart_distribution_amount[grouping_key]
distribution_values[account_id] = (line.balance * distribution + distribution_values.get(account_id, 0) * 100) / 100
counterpart_balances[grouping_key]['analytic_distribution'] = counterpart_distribution_amount[grouping_key] or {}
grouped_source_lines[(
line.partner_id,
line.currency_id,
line.account_id,
line.analytic_distribution and frozendict(line.analytic_distribution),
)] += 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")
# We divide the amount for each account by the total balance to reflect the lines counter-parted
analytic_distribution = {
account_id: (
100
if counterpart_currency.is_zero(counterpart_vals['balance'])
else 100 * distribution_amount / counterpart_vals['balance']
)
for account_id, distribution_amount in counterpart_vals['analytic_distribution'].items()
}
if not counterpart_currency.is_zero(counterpart_vals['amount_currency']) or not self.company_id.currency_id.is_zero(counterpart_vals['balance']):
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,
'analytic_distribution': analytic_distribution,
})
# Generate change_account lines' vals
for (partner, currency, account, analytic_distribution), 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),
'analytic_distribution': analytic_distribution,
})
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_line_dict_vals_change_period(self, aml, date):
# account.move.line data
accrual_account = self.revenue_accrual_account if self.account_type == 'income' else self.expense_accrual_account
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)
if date == 'new_date':
return [
(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,
'analytic_distribution': aml.analytic_distribution,
}),
(0, 0, {
'name': self._format_strings(_('{percent:0.2f}% to recognize on {new_date}'), aml.move_id),
'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,
'analytic_distribution': aml.analytic_distribution,
}),
]
return [
(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,
'analytic_distribution': aml.analytic_distribution,
}),
(0, 0, {
'name': self._format_strings(_('{percent:0.2f}% to recognize on {new_date}'), aml.move_id),
'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,
'analytic_distribution': aml.analytic_distribution,
}),
]
def _get_lock_safe_date(self, date):
# Use a reference move in the correct journal because _get_accounting_date depends on the journal sequence.
reference_move = self.env['account.move'].new({'journal_id': self.journal_id.id, 'move_type': 'entry', 'invoice_date': date})
return reference_move._get_accounting_date(date, False)
def _get_move_dict_vals_change_period(self):
def get_lock_safe_date(aml):
return self._get_lock_safe_date(aml.date)
# set the change_period account on the selected journal items
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': self._format_strings(_('{label}: Adjusting Entry of {new_date}'), self.move_line_ids[0].move_id),
'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, get_lock_safe_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(_('{label}: Adjusting Entry of {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:
for date in (get_lock_safe_date(aml), 'new_date'):
move_data[date]['line_ids'] += self._get_move_line_dict_vals_change_period(aml, date)
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.account_type != record.move_line_ids[0].account_id.account_type 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-end text-nowrap'},
{'field': 'credit', 'label': _('Credit'), 'class': 'text-end 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)
self = self.with_context(skip_computed_taxes=True)
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_move_offset = 0
destination_messages = []
accrual_move_messages = defaultdict(lambda: [])
accrual_move_offsets = defaultdict(int)
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 == self._get_lock_safe_date(move.date))
if accrual_account.reconcile and accrual_move.state == 'posted' and destination_move.state == 'posted':
destination_move_lines = destination_move.mapped('line_ids').filtered(lambda line: line.account_id == accrual_account)[destination_move_offset:destination_move_offset+2]
destination_move_offset += 2
accrual_move_lines = accrual_move.mapped('line_ids').filtered(lambda line: line.account_id == accrual_account)[accrual_move_offsets[accrual_move]:accrual_move_offsets[accrual_move]+2]
accrual_move_offsets[accrual_move] += 2
(accrual_move_lines + destination_move_lines).filtered(lambda line: not line.currency_id.is_zero(line.balance)).reconcile()
move.message_post(body=self._format_strings(_('Adjusting Entries have been created for this invoice:<ul><li>%(link1)s cancelling '
'{percent:.2f}%% 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:.2f}% of {amount} recognized from {date}'), move, amount)]
accrual_move_messages[accrual_move] += [self._format_strings(_('Adjusting Entry for {link}: {percent:.2f}% of {amount} recognized on {new_date}'), move, amount)]
destination_move.message_post(body='<br/>\n'.join(destination_messages))
for accrual_move, messages in accrual_move_messages.items():
accrual_move.message_post(body='<br/>\n'.join(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 % {'account_source_name': account.display_name}, move, balance)
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 % account.display_name, transfer_move, balance)
return content and '<ul>' + content + '</ul>' or None
def _format_move_link(self, move):
return move._get_html_link()
def _format_strings(self, string, move, amount=None):
return string.format(
label=move.name or 'Adjusting Entry',
percent=self.percentage,
name=move.name,
id=move.id,
amount=formatLang(self.env, abs(amount), currency_obj=self.company_id.currency_id) if amount else '',
debit_credit=amount < 0 and _('C') or _('D') if amount else None,
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,
)

View file

@ -0,0 +1,59 @@
<?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>
<div attrs="{'invisible': [('lock_date_message', '=', False)]}" class="alert alert-warning text-center" role="status">
<field name="lock_date_message" nolabel="1"/>
</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" 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" class="d-block"/>
<footer>
<button string="Create Journal Entries" name="do_action" type="object" class="oe_highlight" data-hotkey="q"/>
<button string="Cancel" class="btn btn-secondary" special="cancel" data-hotkey="z"/>
</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>

View file

@ -0,0 +1,175 @@
# -*- 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',
domain="[('model', '=', 'account.move')]"
)
# Technical field containing a textual representation of the selected move types,
# if multiple. It is used to inform the user in the window in such case.
move_types = fields.Char(
string='Move types',
compute='_compute_move_types',
readonly=True)
@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('invoice_ids')
def _compute_move_types(self):
for wizard in self:
move_types = False
if len(wizard.invoice_ids) > 1:
moves = self.env['account.move'].browse(self.env.context.get('active_ids'))
# Get the move types of all selected moves and see if there is more than one of them.
# If so, we'll display a warning on the next window about it.
move_types_set = set(m.type_name for m in moves)
if len(move_types_set) > 1:
move_types = ', '.join(move_types_set)
wizard.move_types = move_types
@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,
mailing_document_based=True,
)._action_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})
for invoice in self.invoice_ids:
prioritary_attachments = False
if self.composition_mode == 'comment':
# With a single invoice we take the attachment directly from the composer
prioritary_attachments = self.attachment_ids.filtered(lambda x: x.mimetype.endswith('pdf')).sorted('id')
elif self.composition_mode == 'mass_mail':
# In mass mail mode we need to look for attachment in the invoice record
prioritary_attachments = invoice.attachment_ids.filtered(lambda x: x.mimetype.endswith('pdf'))
if prioritary_attachments:
main_attachment = prioritary_attachments[0]
invoice.with_context(tracking_disable=True).sudo().write({'message_main_attachment_id': main_attachment.id})
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 = set(active_records.mapped('partner_id.lang'))
for lang in langs:
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=get_lang(self.env, lang).code)
self_lang.onchange_template_id()
self_lang._send_email()
else:
active_record = self.env[self.model].browse(self.res_id)
lang = get_lang(self.env, active_record.partner_id.lang).code
self.with_context(lang=lang)._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.action_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

View file

@ -0,0 +1,100 @@
<?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">
<div class="alert alert-warning" role="alert"
attrs="{'invisible': [('move_types', '=', False)]}">
You have selected the following document types at the same time:
<field name="move_types"/>
</div>
<!-- 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 ms-2">
Preview as a PDF
</div>
</div>
<div name="option_email">
<field name="is_email" />
<b><label for="is_email"/></b>
</div>
<div class="text-start 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" class="oe-bordered-editor" 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)]}" data-hotkey="q"
name="send_and_print_action" type="object" class="send_and_print btn-primary o_mail_send"/>
<button string="Send" data-hotkey="q"
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" data-hotkey="q"
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" data-hotkey="z" />
<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="float-end btn-secondary" help="Save as a new template" data-hotkey="w" />
</footer>
</form>
</field>
</record>
<record id="invoice_send" model="ir.actions.server">
<field name="name">Send &amp; print</field>
<field name="state">code</field>
<field name="type">ir.actions.server</field>
<field name="model_id" ref="model_account_move"/>
<field name="binding_model_id" ref="model_account_move"/>
<field name="binding_view_types">list</field>
<field name="code">
if records:
action = records.action_send_and_print()
</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,181 @@
# -*- 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(
comodel_name='account.journal',
string='Use Specific Journal',
required=True,
compute='_compute_journal_id',
readonly=False,
store=True,
check_company=True,
help='If empty, uses the journal of the journal entry to be reversed.',
)
company_id = fields.Many2one('res.company', required=True, readonly=True)
available_journal_ids = fields.Many2many('account.journal', compute='_compute_available_journal_ids')
country_code = fields.Char(related='company_id.country_id.code')
# 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.depends('move_ids')
def _compute_journal_id(self):
for record in self:
if record.journal_id:
record.journal_id = record.journal_id
else:
journals = record.move_ids.journal_id.filtered(lambda x: x.active)
record.journal_id = journals[0] if journals else None
@api.depends('move_ids')
def _compute_available_journal_ids(self):
for record in self:
if record.move_ids:
record.available_journal_ids = self.env['account.journal'].search([
('company_id', '=', record.company_id.id),
('type', 'in', record.move_ids.journal_id.mapped('type')),
])
else:
record.available_journal_ids = self.env['account.journal'].search([('company_id', '=', record.company_id.id)])
@api.constrains('journal_id', 'move_ids')
def _check_journal_type(self):
for record in self:
if record.journal_id.type not in record.move_ids.journal_id.mapped('type'):
raise UserError(_('Journal should be the same type as the reversed entry.'))
@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
mixed_payment_term = move.invoice_payment_term_id.id if move.invoice_payment_term_id and move.company_id.early_pay_discount_computation == 'mixed' else None
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_due': reverse_date,
'invoice_date': move.is_invoice(include_receipts=True) and reverse_date or False,
'journal_id': self.journal_id.id,
'invoice_payment_term_id': mixed_payment_term,
'invoice_user_id': move.invoice_user_id.id,
'auto_post': 'at_date' if reverse_date > fields.Date.context_today(self) else 'no',
}
def reverse_moves(self):
self.ensure_one()
moves = self.move_ids
# Create default values.
partners = moves.company_id.partner_id + moves.commercial_partner_id
bank_ids = self.env['res.partner.bank'].search([
('partner_id', 'in', partners.ids),
('company_id', 'in', moves.company_id.ids + [False]),
], order='sequence DESC')
partner_to_bank = {bank.partner_id: bank for bank in bank_ids}
default_values_list = []
for move in moves:
if move.is_outbound():
partner = move.company_id.partner_id
else:
partner = move.commercial_partner_id
default_values_list.append({
'partner_bank_id': partner_to_bank.get(partner, self.env['res.partner.bank']).id,
**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 = default_vals.get('auto_post') != 'no'
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,
'context': {'default_move_type': moves_to_redirect.move_type},
})
else:
action.update({
'view_mode': 'tree,form',
'domain': [('id', 'in', moves_to_redirect.ids)],
})
if len(set(moves_to_redirect.mapped('move_type'))) == 1:
action['context'] = {'default_move_type': moves_to_redirect.mapped('move_type').pop()}
return action

View file

@ -0,0 +1,61 @@
<?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"/>
<field name="available_journal_ids" invisible="1"/>
<group>
<group attrs="{'invisible': ['|',('move_type', 'not in', ('out_invoice', 'in_invoice')),('residual', '=', 0)]}">
<field name="refund_method" widget="radio" attrs="{'readonly': [('residual', '=', 0)]}"/>
</group>
<group attrs="{'invisible': ['|', ('move_type', 'not in', ('out_invoice', 'in_invoice', 'some_invoice')), ('residual', '=', 0)]}">
<div attrs="{'invisible':[('refund_method', '!=', 'refund')]}" class="oe_grey" colspan="2">
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="2">
The credit note is auto-validated and reconciled with the invoice.
</div>
<div attrs="{'invisible':[('refund_method', '!=', 'modify')]}" class="oe_grey" colspan="2">
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')]}"/>
<field name="date_mode" string="Reversal Date" widget="radio"/>
</group>
<group>
<field name="journal_id" domain="[('id', 'in', available_journal_ids)]"/>
<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" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
</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>

View file

@ -0,0 +1,897 @@
# -*- coding: utf-8 -*-
from collections import defaultdict
from odoo import Command, models, fields, api, _
from odoo.exceptions import UserError
from odoo.tools import frozendict
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')
hide_writeoff_section = fields.Boolean(compute="_compute_hide_writeoff_section")
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), instead of one per bill.")
early_payment_discount_mode = fields.Boolean(compute='_compute_early_payment_discount_mode')
currency_id = fields.Many2one(
comodel_name='res.currency',
string='Currency',
compute='_compute_currency_id', store=True, readonly=False, precompute=True,
help="The payment's currency.")
journal_id = fields.Many2one(
comodel_name='account.journal',
compute='_compute_journal_id', store=True, readonly=False, precompute=True,
domain="[('id', 'in', available_journal_ids)]")
available_journal_ids = fields.Many2many(
comodel_name='account.journal',
compute='_compute_available_journal_ids'
)
available_partner_bank_ids = fields.Many2many(
comodel_name='res.partner.bank',
compute='_compute_available_partner_bank_ids',
)
partner_bank_id = fields.Many2one(
comodel_name='res.partner.bank',
string="Recipient Bank Account",
readonly=False,
store=True,
compute='_compute_partner_bank_id',
domain="[('id', 'in', available_partner_bank_ids)]",
)
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')
can_edit_wizard = fields.Boolean(store=True, copy=False,
compute='_compute_from_lines') # used to check if user can edit info such as the amount
can_group_payments = fields.Boolean(store=True, copy=False,
compute='_compute_from_lines') # can the user 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_line_id = fields.Many2one('account.payment.method.line', string='Payment Method',
readonly=False, store=True,
compute='_compute_payment_method_line_id',
domain="[('id', 'in', available_payment_method_line_ids)]",
help="Manual: Pay or Get paid by any method outside of Odoo.\n"
"Payment Providers: Each payment provider has its own Payment Method. Request a transaction on/to a card thanks to a payment token saved by the partner when buying or subscribing online.\n"
"Check: Pay bills by check and print it from Odoo.\n"
"Batch Deposit: Collect several customer checks at once generating and submitting a batch deposit to your bank. Module account_batch_payment is necessary.\n"
"SEPA Credit Transfer: Pay in the SEPA zone by submitting a SEPA Credit Transfer file to your bank. Module account_sepa is necessary.\n"
"SEPA Direct Debit: Get paid in the SEPA zone thanks to a mandate your partner will have granted to you. Module account_sepa is necessary.\n")
available_payment_method_line_ids = fields.Many2many('account.payment.method.line', compute='_compute_payment_method_line_fields')
# == Payment difference fields ==
payment_difference = fields.Monetary(
compute='_compute_payment_difference')
payment_difference_handling = fields.Selection(
string="Payment Difference Handling",
selection=[('open', 'Keep open'), ('reconcile', 'Mark as fully paid')],
compute='_compute_payment_difference_handling',
store=True,
readonly=False,
)
writeoff_account_id = fields.Many2one(
comodel_name='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') # Used to know whether the field `partner_bank_id` should be displayed
require_partner_bank_account = fields.Boolean(
compute='_compute_show_require_partner_bank') # used to know whether the field `partner_bank_id` should be required
country_code = fields.Char(related='company_id.account_fiscal_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.move_id.payment_reference or 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_batch_available_journals(self, batch_result):
""" Helper to compute the available journals based on the batch.
:param batch_result: A batch returned by '_get_batches'.
:return: A recordset of account.journal.
"""
payment_type = batch_result['payment_values']['payment_type']
company = batch_result['lines'].company_id
journals = self.env['account.journal'].search([('company_id', '=', company.id), ('type', 'in', ('bank', 'cash'))])
if payment_type == 'inbound':
return journals.filtered('inbound_payment_method_line_ids')
else:
return journals.filtered('outbound_payment_method_line_ids')
@api.model
def _get_batch_journal(self, batch_result):
""" Helper to compute the journal based on the batch.
:param batch_result: A batch returned by '_get_batches'.
:return: An account.journal record.
"""
payment_values = batch_result['payment_values']
foreign_currency_id = payment_values['currency_id']
partner_bank_id = payment_values['partner_bank_id']
currency_domain = [('currency_id', '=', foreign_currency_id)]
partner_bank_domain = [('bank_account_id', '=', partner_bank_id)]
default_domain = [
('type', 'in', ('bank', 'cash')),
('company_id', '=', batch_result['lines'].company_id.id),
('id', 'in', self.available_journal_ids.ids)
]
if partner_bank_id:
extra_domains = (
currency_domain + partner_bank_domain,
partner_bank_domain,
currency_domain,
[],
)
else:
extra_domains = (
currency_domain,
[],
)
for extra_domain in extra_domains:
journal = self.env['account.journal'].search(default_domain + extra_domain, limit=1)
if journal:
return journal
return self.env['account.journal']
@api.model
def _get_batch_available_partner_banks(self, batch_result, journal):
payment_values = batch_result['payment_values']
company = batch_result['lines'].company_id
# A specific bank account is set on the journal. The user must use this one.
if payment_values['payment_type'] == 'inbound':
# Receiving money on a bank account linked to the journal.
return journal.bank_account_id
else:
# Sending money to a bank account owned by a partner.
return batch_result['lines'].partner_id.bank_ids.filtered(lambda x: x.company_id.id in (False, company.id))._origin
@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.
'''
move = line.move_id
partner_bank_account = self.env['res.partner.bank']
if move.is_invoice(include_receipts=True):
partner_bank_account = move.partner_bank_id._origin
return {
'partner_id': line.partner_id.id,
'account_id': line.account_id.id,
'currency_id': line.currency_id.id,
'partner_bank_id': partner_bank_account.id,
'partner_type': 'customer' if line.account_type == 'asset_receivable' else 'supplier',
}
def _get_batches(self):
''' Group the account.move.line linked to the wizard together.
Lines are grouped if they share 'partner_id','account_id','currency_id' & 'partner_type' and if
0 or 1 partner_bank_id can be determined for the group.
:return: A list of batches, each one containing:
* payment_values: A dictionary of payment values.
* 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 = defaultdict(lambda: {'lines': self.env['account.move.line']})
banks_per_partner = defaultdict(lambda: {'inbound': set(), 'outbound': set()})
for line in lines:
batch_key = self._get_line_batch_key(line)
vals = batches[frozendict(batch_key)]
vals['payment_values'] = batch_key
vals['lines'] += line
banks_per_partner[batch_key['partner_id']]['inbound' if line.balance > 0.0 else 'outbound'].add(
batch_key['partner_bank_id']
)
partner_unique_inbound = {p for p, b in banks_per_partner.items() if len(b['inbound']) == 1}
partner_unique_outbound = {p for p, b in banks_per_partner.items() if len(b['outbound']) == 1}
# Compute 'payment_type'.
batch_vals = []
seen_keys = set()
for i, key in enumerate(list(batches)):
if key in seen_keys:
continue
vals = batches[key]
lines = vals['lines']
merge = (
batch_key['partner_id'] in partner_unique_inbound
and batch_key['partner_id'] in partner_unique_outbound
)
if merge:
for other_key in list(batches)[i+1:]:
if other_key in seen_keys:
continue
other_vals = batches[other_key]
if all(
other_vals['payment_values'][k] == v
for k, v in vals['payment_values'].items()
if k not in ('partner_bank_id', 'payment_type')
):
# add the lines in this batch and mark as seen
lines += other_vals['lines']
seen_keys.add(other_key)
balance = sum(lines.mapped('balance'))
vals['payment_values']['payment_type'] = 'inbound' if balance > 0.0 else 'outbound'
if merge:
partner_banks = banks_per_partner[batch_key['partner_id']]
vals['partner_bank_id'] = partner_banks[vals['payment_values']['payment_type']]
vals['lines'] = lines
batch_vals.append(vals)
return batch_vals
@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
'''
payment_values = batch_result['payment_values']
lines = batch_result['lines']
company = lines[0].company_id
source_amount = abs(sum(lines.mapped('amount_residual')))
if payment_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': payment_values['partner_id'],
'partner_type': payment_values['partner_type'],
'payment_type': payment_values['payment_type'],
'source_currency_id': payment_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 = wizard._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('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('payment_type', 'company_id', 'can_edit_wizard')
def _compute_available_journal_ids(self):
for wizard in self:
available_journals = self.env['account.journal']
for batch in wizard._get_batches():
available_journals |= wizard._get_batch_available_journals(batch)
wizard.available_journal_ids = [Command.set(available_journals.ids)]
@api.depends('available_journal_ids')
def _compute_journal_id(self):
for wizard in self:
if wizard.can_edit_wizard:
batch = wizard._get_batches()[0]
wizard.journal_id = wizard._get_batch_journal(batch)
else:
wizard.journal_id = self.env['account.journal'].search([
('type', 'in', ('bank', 'cash')),
('company_id', '=', wizard.company_id.id),
('id', 'in', self.available_journal_ids.ids)
], limit=1)
@api.depends('can_edit_wizard', 'journal_id')
def _compute_available_partner_bank_ids(self):
for wizard in self:
if wizard.can_edit_wizard:
batch = wizard._get_batches()[0]
wizard.available_partner_bank_ids = wizard._get_batch_available_partner_banks(batch, wizard.journal_id)
else:
wizard.available_partner_bank_ids = None
@api.depends('journal_id', 'available_partner_bank_ids')
def _compute_partner_bank_id(self):
for wizard in self:
if wizard.can_edit_wizard:
batch = wizard._get_batches()[0]
partner_bank_id = batch['payment_values']['partner_bank_id']
available_partner_banks = wizard.available_partner_bank_ids._origin
if partner_bank_id and partner_bank_id in available_partner_banks.ids:
wizard.partner_bank_id = self.env['res.partner.bank'].browse(partner_bank_id)
else:
wizard.partner_bank_id = available_partner_banks[:1]
else:
wizard.partner_bank_id = None
@api.depends('payment_type', 'journal_id', 'currency_id')
def _compute_payment_method_line_fields(self):
for wizard in self:
if wizard.journal_id:
wizard.available_payment_method_line_ids = wizard.journal_id._get_available_payment_method_lines(wizard.payment_type)
else:
wizard.available_payment_method_line_ids = False
@api.depends('payment_type', 'journal_id')
def _compute_payment_method_line_id(self):
for wizard in self:
if wizard.journal_id:
available_payment_method_lines = wizard.journal_id._get_available_payment_method_lines(wizard.payment_type)
else:
available_payment_method_lines = False
# Select the first available one by default.
if available_payment_method_lines:
wizard.payment_method_line_id = available_payment_method_lines[0]._origin
else:
wizard.payment_method_line_id = False
@api.depends('payment_method_line_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:
if wizard.journal_id.type == 'cash':
wizard.show_partner_bank_account = False
else:
wizard.show_partner_bank_account = wizard.payment_method_line_id.code in self.env['account.payment']._get_method_codes_using_bank_account()
wizard.require_partner_bank_account = wizard.payment_method_line_id.code in self.env['account.payment']._get_method_codes_needing_bank_account()
def _get_total_amount_using_same_currency(self, batch_result, early_payment_discount=True):
self.ensure_one()
amount = 0.0
mode = False
for aml in batch_result['lines']:
if early_payment_discount and aml._is_eligible_for_early_payment_discount(aml.currency_id, self.payment_date):
amount += aml.discount_amount_currency
mode = 'early_payment'
else:
amount += aml.amount_residual_currency
return abs(amount), mode
def _get_total_amount_in_wizard_currency_to_full_reconcile(self, batch_result, early_payment_discount=True):
""" Compute the total amount needed in the currency of the wizard to fully reconcile the batch of journal
items passed as parameter.
:param batch_result: A batch returned by '_get_batches'.
:return: An amount in the currency of the wizard.
"""
self.ensure_one()
comp_curr = self.company_id.currency_id
if self.source_currency_id == self.currency_id:
# Same currency (manage the early payment discount).
return self._get_total_amount_using_same_currency(batch_result, early_payment_discount=early_payment_discount)
elif self.source_currency_id != comp_curr and self.currency_id == comp_curr:
# Foreign currency on source line but the company currency one on the opposite line.
return self.source_currency_id._convert(
self.source_amount_currency,
comp_curr,
self.company_id,
self.payment_date,
), False
elif self.source_currency_id == comp_curr and self.currency_id != comp_curr:
# Company currency on source line but a foreign currency one on the opposite line.
residual_amount = 0.0
for aml in batch_result['lines']:
if not aml.move_id.payment_id and not aml.move_id.statement_line_id:
conversion_date = self.payment_date
else:
conversion_date = aml.date
residual_amount += comp_curr._convert(
aml.amount_residual,
self.currency_id,
self.company_id,
conversion_date,
)
return abs(residual_amount), False
else:
# Foreign currency on payment different than the one set on the journal entries.
return comp_curr._convert(
self.source_amount,
self.currency_id,
self.company_id,
self.payment_date,
), False
@api.depends('can_edit_wizard', '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 and wizard.can_edit_wizard:
batch_result = wizard._get_batches()[0]
wizard.amount = wizard._get_total_amount_in_wizard_currency_to_full_reconcile(batch_result)[0]
else:
# The wizard is not editable so no partial payment allowed and then, 'amount' is not used.
wizard.amount = None
@api.depends('can_edit_wizard', 'payment_date', 'currency_id', 'amount')
def _compute_early_payment_discount_mode(self):
for wizard in self:
if wizard.can_edit_wizard and wizard.currency_id:
batch_result = wizard._get_batches()[0]
total_amount_residual_in_wizard_currency, mode = wizard._get_total_amount_in_wizard_currency_to_full_reconcile(batch_result)
wizard.early_payment_discount_mode = \
wizard.currency_id.compare_amounts(wizard.amount, total_amount_residual_in_wizard_currency) == 0 \
and mode == 'early_payment'
else:
wizard.early_payment_discount_mode = False
@api.depends('can_edit_wizard', 'amount')
def _compute_payment_difference(self):
for wizard in self:
if wizard.can_edit_wizard:
batch_result = wizard._get_batches()[0]
total_amount_residual_in_wizard_currency = wizard\
._get_total_amount_in_wizard_currency_to_full_reconcile(batch_result, early_payment_discount=False)[0]
wizard.payment_difference = total_amount_residual_in_wizard_currency - wizard.amount
else:
wizard.payment_difference = 0.0
@api.depends('early_payment_discount_mode')
def _compute_payment_difference_handling(self):
for wizard in self:
if wizard.can_edit_wizard:
wizard.payment_difference_handling = 'reconcile' if wizard.early_payment_discount_mode else 'open'
else:
wizard.payment_difference_handling = False
@api.depends('early_payment_discount_mode')
def _compute_hide_writeoff_section(self):
for wizard in self:
wizard.hide_writeoff_section = wizard.early_payment_discount_mode
# -------------------------------------------------------------------------
# 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."
))
if 'journal_id' in res and not self.env['account.journal'].browse(res['journal_id'])\
.filtered_domain([('company_id', '=', lines.company_id.id), ('type', 'in', ('bank', 'cash'))]):
# default can be inherited from the list view, should be computed instead
del res['journal_id']
# 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_type not in ('asset_receivable', 'liability_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_type'))) > 1:
raise UserError(_("You can't register payments for both inbound and outbound moves at the same time."))
res['line_ids'] = [(6, 0, available_lines.ids)]
return res
# -------------------------------------------------------------------------
# BUSINESS METHODS
# -------------------------------------------------------------------------
def _create_payment_vals_from_wizard(self, batch_result):
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_line_id': self.payment_method_line_id.id,
'destination_account_id': self.line_ids[0].account_id.id,
'write_off_line_vals': [],
}
conversion_rate = self.env['res.currency']._get_conversion_rate(
self.currency_id,
self.company_id.currency_id,
self.company_id,
self.payment_date,
)
if self.payment_difference_handling == 'reconcile':
if self.early_payment_discount_mode:
epd_aml_values_list = []
for aml in batch_result['lines']:
if aml._is_eligible_for_early_payment_discount(self.currency_id, self.payment_date):
epd_aml_values_list.append({
'aml': aml,
'amount_currency': -aml.amount_residual_currency,
'balance': aml.company_currency_id.round(-aml.amount_residual_currency * conversion_rate),
})
open_amount_currency = self.payment_difference * (-1 if self.payment_type == 'outbound' else 1)
open_balance = self.company_id.currency_id.round(open_amount_currency * conversion_rate)
early_payment_values = self.env['account.move']._get_invoice_counterpart_amls_for_early_payment_discount(epd_aml_values_list, open_balance)
for aml_values_list in early_payment_values.values():
payment_vals['write_off_line_vals'] += aml_values_list
elif not self.currency_id.is_zero(self.payment_difference):
if self.payment_type == 'inbound':
# Receive money.
write_off_amount_currency = self.payment_difference
else: # if self.payment_type == 'outbound':
# Send money.
write_off_amount_currency = -self.payment_difference
write_off_balance = self.company_id.currency_id.round(write_off_amount_currency * conversion_rate)
payment_vals['write_off_line_vals'].append({
'name': self.writeoff_label,
'account_id': self.writeoff_account_id.id,
'partner_id': self.partner_id.id,
'currency_id': self.currency_id.id,
'amount_currency': write_off_amount_currency,
'balance': write_off_balance,
})
return payment_vals
def _create_payment_vals_from_batch(self, batch_result):
batch_values = self._get_wizard_values_from_batch(batch_result)
if batch_values['payment_type'] == 'inbound':
partner_bank_id = self.journal_id.bank_account_id.id
else:
partner_bank_id = batch_result['payment_values']['partner_bank_id']
payment_method_line = self.payment_method_line_id
if batch_values['payment_type'] != payment_method_line.payment_type:
payment_method_line = self.journal_id._get_available_payment_method_lines(batch_values['payment_type'])[:1]
payment_vals = {
'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': partner_bank_id,
'payment_method_line_id': payment_method_line.id,
'destination_account_id': batch_result['lines'][0].account_id.id,
'write_off_line_vals': [],
}
total_amount, mode = self._get_total_amount_using_same_currency(batch_result)
currency = self.env['res.currency'].browse(batch_values['source_currency_id'])
if mode == 'early_payment':
payment_vals['amount'] = total_amount
conversion_rate = self.env['res.currency']._get_conversion_rate(
currency,
self.company_id.currency_id,
self.company_id,
self.payment_date,
)
epd_aml_values_list = []
for aml in batch_result['lines']:
if aml._is_eligible_for_early_payment_discount(currency, self.payment_date):
epd_aml_values_list.append({
'aml': aml,
'amount_currency': -aml.amount_residual_currency,
'balance': aml.company_currency_id.round(-aml.amount_residual_currency * conversion_rate),
})
open_amount_currency = (batch_values['source_amount_currency'] - total_amount) * (-1 if batch_values['payment_type'] == 'outbound' else 1)
open_balance = self.company_id.currency_id.round(open_amount_currency * conversion_rate)
early_payment_values = self.env['account.move']\
._get_invoice_counterpart_amls_for_early_payment_discount(epd_aml_values_list, open_balance)
for aml_values_list in early_payment_values.values():
payment_vals['write_off_line_vals'] += aml_values_list
return payment_vals
def _init_payments(self, to_process, edit_mode=False):
""" Create the payments.
:param to_process: A list of python dictionary, one for each payment to create, containing:
* create_vals: The values used for the 'create' method.
* to_reconcile: The journal items to perform the reconciliation.
* batch: A python dict containing everything you want about the source journal items
to which a payment will be created (see '_get_batches').
:param edit_mode: Is the wizard in edition mode.
"""
payments = self.env['account.payment']\
.with_context(skip_invoice_sync=True)\
.create([x['create_vals'] for x in to_process])
for payment, vals in zip(payments, to_process):
vals['payment'] = payment
# 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:
lines = vals['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')))
if liquidity_lines[0].balance:
payment_rate = liquidity_lines[0].amount_currency / liquidity_lines[0].balance
else:
payment_rate = 0.0
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')
if debit_lines and credit_lines:
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}),
]})
return payments
def _post_payments(self, to_process, edit_mode=False):
""" Post the newly created payments.
:param to_process: A list of python dictionary, one for each payment to create, containing:
* create_vals: The values used for the 'create' method.
* to_reconcile: The journal items to perform the reconciliation.
* batch: A python dict containing everything you want about the source journal items
to which a payment will be created (see '_get_batches').
:param edit_mode: Is the wizard in edition mode.
"""
payments = self.env['account.payment']
for vals in to_process:
payments |= vals['payment']
payments.action_post()
def _reconcile_payments(self, to_process, edit_mode=False):
""" Reconcile the payments.
:param to_process: A list of python dictionary, one for each payment to create, containing:
* create_vals: The values used for the 'create' method.
* to_reconcile: The journal items to perform the reconciliation.
* batch: A python dict containing everything you want about the source journal items
to which a payment will be created (see '_get_batches').
:param edit_mode: Is the wizard in edition mode.
"""
domain = [
('parent_state', '=', 'posted'),
('account_type', 'in', ('asset_receivable', 'liability_payable')),
('reconciled', '=', False),
]
for vals in to_process:
payment_lines = vals['payment'].line_ids.filtered_domain(domain)
lines = vals['to_reconcile']
for account in payment_lines.account_id:
(payment_lines + lines)\
.filtered_domain([('account_id', '=', account.id), ('reconciled', '=', False)])\
.reconcile()
def _create_payments(self):
self.ensure_one()
batches = self._get_batches()
first_batch_result = batches[0]
edit_mode = self.can_edit_wizard and (len(first_batch_result['lines']) == 1 or self.group_payment)
to_process = []
if edit_mode:
payment_vals = self._create_payment_vals_from_wizard(first_batch_result)
to_process.append({
'create_vals': payment_vals,
'to_reconcile': first_batch_result['lines'],
'batch': first_batch_result,
})
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,
'payment_values': {
**batch_result['payment_values'],
'payment_type': 'inbound' if line.balance > 0 else 'outbound'
},
'lines': line,
})
batches = new_batches
for batch_result in batches:
to_process.append({
'create_vals': self._create_payment_vals_from_batch(batch_result),
'to_reconcile': batch_result['lines'],
'batch': batch_result,
})
payments = self._init_payments(to_process, edit_mode=edit_mode)
self._post_payments(to_process, edit_mode=edit_mode)
self._reconcile_payments(to_process, edit_mode=edit_mode)
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

View file

@ -0,0 +1,89 @@
<?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="early_payment_discount_mode" 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="available_journal_ids" invisible="1"/>
<field name="available_payment_method_line_ids" invisible="1"/>
<field name="available_partner_bank_ids" invisible="1"/>
<field name="company_currency_id" invisible="1"/>
<field name="hide_writeoff_section" invisible="1"/>
<div role="alert" class="alert alert-info" attrs="{'invisible': [('hide_writeoff_section', '=', False)]}">
<p><b>Early Payment Discount of <field name="payment_difference"/> has been applied.</b></p>
</div>
<group>
<group name="group1">
<field name="journal_id" options="{'no_open': True, 'no_create': True}" required="1"/>
<field name="payment_method_line_id"
required="1" options="{'no_create': True, 'no_open': 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)], 'readonly': [('payment_type', '=', 'inbound')]}"
context="{'default_allow_out_payment': True}"/>
<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"
required="1"
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), '|', ('early_payment_discount_mode', '=', True), '|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)]}">
<label for="payment_difference"/>
<div>
<field name="payment_difference"/>
<field name="payment_difference_handling" widget="radio" nolabel="1"/>
<div attrs="{'invisible': ['|', ('hide_writeoff_section', '=', True), ('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'), ('early_payment_discount_mode', '=', False)]}"/>
<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" data-hotkey="q"/>
<button string="Cancel" class="btn btn-secondary" special="cancel" data-hotkey="z"/>
</footer>
</form>
</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,163 @@
# -*- 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)
if 'move_ids' not in fields_list:
return values
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.'))
is_payment = set(active_move_ids.mapped(lambda x: bool(x.payment_id)))
if (
active_move_ids.journal_id.payment_sequence
and len(is_payment) > 1
):
raise UserError(_('The sequences of this journal are different for Payments and non-Payments 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(lambda move: move.name or ""))
@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 == 'year_range' and line['server-year-start-date'][0:4] != previous_line['server-year-start-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):
company = move_id.company_id
year_start, year_end = get_fiscal_year(move_id.date, day=company.fiscalyear_last_day, month=int(company.fiscalyear_last_month))
if self.sequence_number_reset == 'year':
return move_id.date.year
elif self.sequence_number_reset == 'year_range':
return "%s-%s"%(year_start.year, year_end.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
seq_format, format_values = record.move_ids[0]._get_sequence_format_param(record.first_name)
sequence_number_reset = record.move_ids[0]._deduce_sequence_number_reset(record.first_name)
new_values = {}
for j, period_recs in enumerate(moves_by_period.values()):
# compute the new values period by period
year_start, year_end = period_recs[0]._get_sequence_date_range(sequence_number_reset)
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),
'server-year-start-date': str(year_start),
}
new_name_list = [seq_format.format(**{
**format_values,
'month': year_start.month,
'year_end': year_end.year % (10 ** format_values['year_end_length']),
'year': year_start.year % (10 ** format_values['year_length']),
'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 or "", 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)
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.'))
moves_to_rename = self.env['account.move'].browse(int(k) for k in new_values.keys())
moves_to_rename.name = '/'
moves_to_rename.flush_recordset(["name"])
# If the db is not forcibly updated, the temporary renaming could only happen in cache and still trigger the constraint
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']

View file

@ -0,0 +1,44 @@
<?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>
<group>
<label for="preview_moves" string="Preview Modifications" colspan="2"/>
<field name="preview_moves" widget="account_resequence_widget" nolabel="1" colspan="2"/>
</group>
<footer>
<button string="Confirm" name="resequence" type="object" default_focus="1" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
</footer>
</form>
</field>
</record>
</data>
<data noupdate="1">
<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('base.group_no_one')])]"/>
<field name="binding_model_id" ref="account.model_account_move" />
<field name="binding_view_types">list</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _, Command, tools
import base64
from datetime import timedelta
class AccountTourUploadBill(models.TransientModel):
_name = 'account.tour.upload.bill'
_description = 'Account tour upload bill'
attachment_ids = fields.Many2many(
comodel_name='ir.attachment',
relation='account_tour_upload_bill_ir_attachments_rel',
string='Attachments')
selection = fields.Selection(
selection=lambda self: self._selection_values(),
default="sample"
)
preview_invoice = fields.Html(
compute="_compute_preview_invoice",
string="Invoice Preview",
translate=True,
)
def _compute_preview_invoice(self):
invoice_date = fields.Date.today() - timedelta(days=12)
addr = [x for x in [
self.env.company.street,
self.env.company.street2,
' '.join([x for x in [self.env.company.state_id.name, self.env.company.zip] if x]),
self.env.company.country_id.name,
] if x]
ref = 'INV/%s/0001' % invoice_date.strftime('%Y/%m')
html = self.env['ir.qweb']._render('account.bill_preview', {
'company_name': self.env.company.name,
'company_street_address': addr,
'invoice_name': 'Invoice ' + ref,
'invoice_ref': ref,
'invoice_date': invoice_date,
'invoice_due_date': invoice_date + timedelta(days=30),
})
for record in self:
record.preview_invoice = html
def _selection_values(self):
journal_alias = self.env['account.journal'] \
.search([('type', '=', 'purchase'), ('company_id', '=', self.env.company.id)], limit=1)
values = [('sample', _('Try a sample vendor bill')), ('upload', _('Upload your own bill'))]
if journal_alias.alias_name and journal_alias.alias_domain:
values.append(('email', _('Or send a bill to %s@%s', journal_alias.alias_name, journal_alias.alias_domain)))
return values
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):
if self._context.get('active_model') == 'account.journal' and self._context.get('active_ids'):
purchase_journal = self.env['account.journal'].browse(self._context['active_ids'])
else:
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_document_from_attachment(attachment_ids=self.attachment_ids.ids)
elif self.selection == 'sample':
invoice_date = fields.Date.today() - timedelta(days=12)
partner = self.env['res.partner'].search([('name', '=', 'Deco Addict')], limit=1)
if not partner:
partner = self.env['res.partner'].create({
'name': 'Deco Addict',
'is_company': True,
})
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': partner.id,
'ref': 'INV/%s/0001' % invoice_date.strftime('%Y/%m'),
'invoice_date': invoice_date,
'invoice_date_due': invoice_date + timedelta(days=30),
'journal_id': purchase_journal.id,
'invoice_line_ids': [
Command.create({
'name': "[FURN_8999] Three-Seat Sofa",
'quantity': 5,
'price_unit': 1500,
}),
Command.create({
'name': "[FURN_8220] Four Person Desk",
'quantity': 5,
'price_unit': 2350,
})
],
})
# In case of test environment, don't create the pdf
if tools.config['test_enable'] or tools.config['test_file']:
bill.with_context(no_new_invoice=True).message_post()
else:
bodies = self.env['ir.actions.report']._prepare_html(self.preview_invoice)[0]
content = self.env['ir.actions.report']._run_wkhtmltopdf(bodies)
attachment = self.env['ir.attachment'].create({
'type': 'binary',
'name': 'INV-%s-0001.pdf' % invoice_date.strftime('%Y-%m'),
'res_model': 'mail.compose.message',
'datas': base64.encodebytes(content),
})
bill.with_context(no_new_invoice=True).message_post(attachment_ids=[attachment.id])
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)

View file

@ -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 record 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 col="6">
<group colspan="2">
<field name="selection" widget="radio" nolabel="1" colspan="2"/>
</group>
<group colspan="4" attrs="{'invisible': [('selection', '!=', 'sample')]}">
<field name="preview_invoice" widget="html" nolabel="1" colspan="2"/>
</group>
<group colspan="4" 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" data-hotkey="q"/>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="z" />
</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" data-hotkey="q"/>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="z" />
</footer>
</form>
</field>
</record>
</data>
</odoo>

View file

@ -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'}

View file

@ -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" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
</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>

View file

@ -0,0 +1,25 @@
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.'))
if self.force_post:
moves.auto_post = 'no'
moves._post(not self.force_post)
return {'type': 'ir.actions.act_window_close'}

View file

@ -0,0 +1,44 @@
<?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"
data-hotkey="q"
context="{'validate_analytic': True}"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
</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>

View file

@ -0,0 +1,279 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil.relativedelta import relativedelta
import json
from odoo import models, fields, api, _, Command
from odoo.tools import format_date
from odoo.exceptions import UserError
from odoo.tools import date_utils
from odoo.tools.misc import formatLang
class AccruedExpenseRevenue(models.TransientModel):
_name = 'account.accrued.orders.wizard'
_description = 'Accrued Orders Wizard'
def _get_account_domain(self):
if self.env.context.get('active_model') == 'purchase.order':
return [('account_type', '=', 'liability_current'), ('company_id', '=', self._get_default_company())]
else:
return [('account_type', '=', 'asset_current'), ('company_id', '=', self._get_default_company())]
def _get_default_company(self):
if not self._context.get('active_model'):
return
orders = self.env[self._context['active_model']].browse(self._context['active_ids'])
return orders and orders[0].company_id.id
def _get_default_journal(self):
return self.env['account.journal'].search([('company_id', '=', self.env.company.id), ('type', '=', 'general')], limit=1)
def _get_default_date(self):
return date_utils.get_month(fields.Date.context_today(self))[0] - relativedelta(days=1)
company_id = fields.Many2one('res.company', default=_get_default_company)
journal_id = fields.Many2one(
comodel_name='account.journal',
compute='_compute_journal_id',
domain="[('type', '=', 'general'), ('company_id', '=', company_id)]",
readonly=False,
required=True,
default=_get_default_journal,
check_company=True,
company_dependent=True,
string='Journal',
)
date = fields.Date(default=_get_default_date, required=True)
reversal_date = fields.Date(
compute="_compute_reversal_date",
required=True,
readonly=False,
store=True,
precompute=True,
)
amount = fields.Monetary(string='Amount', help="Specify an arbitrary value that will be accrued on a \
default account for the entire order, regardless of the products on the different lines.")
currency_id = fields.Many2one(related='company_id.currency_id', string='Company Currency',
readonly=True, store=True,
help='Utility field to express amount currency')
account_id = fields.Many2one(
comodel_name='account.account',
required=True,
string='Accrual Account',
check_company=True,
domain=_get_account_domain,
)
preview_data = fields.Text(compute='_compute_preview_data')
display_amount = fields.Boolean(compute='_compute_display_amount')
@api.depends('date', 'amount')
def _compute_display_amount(self):
single_order = len(self._context['active_ids']) == 1
for record in self:
preview_data = json.loads(self.preview_data)
lines = preview_data.get('groups_vals', [])[0].get('items_vals', [])
record.display_amount = record.amount or (single_order and not lines)
@api.depends('date')
def _compute_reversal_date(self):
for record in self:
if not record.reversal_date or record.reversal_date <= record.date:
record.reversal_date = record.date + relativedelta(days=1)
else:
record.reversal_date = record.reversal_date
@api.depends('company_id')
def _compute_journal_id(self):
journal = self.env['account.journal'].search(
[('type', '=', 'general'), ('company_id', '=', self.company_id.id)], limit=1
)
for record in self:
record.journal_id = journal
@api.depends('date', 'journal_id', 'account_id', 'amount')
def _compute_preview_data(self):
for record in self:
preview_vals = [self.env['account.move']._move_dict_to_preview_vals(
record._compute_move_vals()[0],
record.company_id.currency_id,
)]
preview_columns = [
{'field': 'account_id', 'label': _('Account')},
{'field': 'name', 'label': _('Label')},
{'field': 'debit', 'label': _('Debit'), 'class': 'text-end text-nowrap'},
{'field': 'credit', 'label': _('Credit'), 'class': 'text-end text-nowrap'},
]
record.preview_data = json.dumps({
'groups_vals': preview_vals,
'options': {
'columns': preview_columns,
},
})
def _get_computed_account(self, order, product, is_purchase):
accounts = product.with_company(order.company_id).product_tmpl_id.get_product_accounts(fiscal_pos=order.fiscal_position_id)
if is_purchase:
return accounts['expense']
else:
return accounts['income']
def _compute_move_vals(self):
def _get_aml_vals(order, balance, amount_currency, account_id, label="", analytic_distribution=None):
if not is_purchase:
balance *= -1
amount_currency *= -1
values = {
'name': label,
'debit': balance if balance > 0 else 0.0,
'credit': balance * -1 if balance < 0 else 0.0,
'account_id': account_id,
}
if analytic_distribution:
values.update({
'analytic_distribution': analytic_distribution,
})
if len(order) == 1 and self.company_id.currency_id != order.currency_id:
values.update({
'amount_currency': amount_currency,
'currency_id': order.currency_id.id,
})
return values
def _ellipsis(string, size):
if len(string) > size:
return string[0:size - 3] + '...'
return string
self.ensure_one()
move_lines = []
is_purchase = self.env.context.get('active_model') == 'purchase.order'
orders = self.env[self._context['active_model']].with_company(self.company_id).browse(self._context['active_ids'])
if orders.filtered(lambda o: o.company_id != self.company_id):
raise UserError(_('Entries can only be created for a single company at a time.'))
if orders.currency_id and len(orders.currency_id) > 1:
raise UserError(_('Cannot create an accrual entry with orders in different currencies.'))
orders_with_entries = []
fnames = []
total_balance = 0.0
for order in orders:
product_lines = order.order_line.filtered(lambda x: x.product_id)
if len(orders) == 1 and product_lines and self.amount and order.order_line:
total_balance = self.amount
order_line = product_lines[0]
account = self._get_computed_account(order, order_line.product_id, is_purchase)
distribution = order_line.analytic_distribution if order_line.analytic_distribution else {}
if not is_purchase and order.analytic_account_id:
analytic_account_id = str(order.analytic_account_id.id)
distribution[analytic_account_id] = distribution.get(analytic_account_id, 0) + 100.0
values = _get_aml_vals(order, self.amount, 0, account.id, label=_('Manual entry'), analytic_distribution=distribution)
move_lines.append(Command.create(values))
else:
other_currency = self.company_id.currency_id != order.currency_id
rate = order.currency_id._get_rates(self.company_id, self.date).get(order.currency_id.id) if other_currency else 1.0
# create a virtual order that will allow to recompute the qty delivered/received (and dependancies)
# without actually writing anything on the real record (field is computed and stored)
o = order.new(origin=order)
if is_purchase:
o.order_line.with_context(accrual_entry_date=self.date)._compute_qty_received()
o.order_line.with_context(accrual_entry_date=self.date)._compute_qty_invoiced()
else:
o.order_line.with_context(accrual_entry_date=self.date)._compute_qty_delivered()
o.order_line.with_context(accrual_entry_date=self.date)._compute_qty_invoiced()
o.order_line.with_context(accrual_entry_date=self.date)._compute_untaxed_amount_invoiced()
o.order_line.with_context(accrual_entry_date=self.date)._compute_qty_to_invoice()
lines = o.order_line.filtered(
lambda l: l.display_type not in ['line_section', 'line_note'] and
fields.Float.compare(
l.qty_to_invoice,
0,
precision_rounding=l.product_uom.rounding,
) != 0
)
for order_line in lines:
if is_purchase:
account = self._get_computed_account(order, order_line.product_id, is_purchase)
if any(tax.price_include for tax in order_line.taxes_id):
# As included taxes are not taken into account in the price_unit, we need to compute the price_subtotal
price_subtotal = order_line.taxes_id.compute_all(
order_line.price_unit,
currency=order_line.order_id.currency_id,
quantity=order_line.qty_to_invoice,
product=order_line.product_id,
partner=order_line.order_id.partner_id)['total_excluded']
else:
price_subtotal = order_line.qty_to_invoice * order_line.price_unit
amount = self.company_id.currency_id.round(price_subtotal / rate)
amount_currency = order_line.currency_id.round(price_subtotal)
fnames = ['qty_to_invoice', 'qty_received', 'qty_invoiced', 'invoice_lines']
label = _('%s - %s; %s Billed, %s Received at %s each', order.name, _ellipsis(order_line.name, 20), order_line.qty_invoiced, order_line.qty_received, formatLang(self.env, order_line.price_unit, currency_obj=order.currency_id))
else:
account = self._get_computed_account(order, order_line.product_id, is_purchase)
amount = self.company_id.currency_id.round(order_line.untaxed_amount_to_invoice / rate)
amount_currency = order_line.untaxed_amount_to_invoice
fnames = ['qty_to_invoice', 'untaxed_amount_to_invoice', 'qty_invoiced', 'qty_delivered', 'invoice_lines']
label = _('%s - %s; %s Invoiced, %s Delivered at %s each', order.name, _ellipsis(order_line.name, 20), order_line.qty_invoiced, order_line.qty_delivered, formatLang(self.env, order_line.price_unit, currency_obj=order.currency_id))
distribution = order_line.analytic_distribution if order_line.analytic_distribution else {}
if not is_purchase and order.analytic_account_id:
analytic_account_id = str(order.analytic_account_id.id)
distribution[analytic_account_id] = distribution.get(analytic_account_id, 0) + 100.0
values = _get_aml_vals(order, amount, amount_currency, account.id, label=label, analytic_distribution=distribution)
move_lines.append(Command.create(values))
total_balance += amount
# must invalidate cache or o can mess when _create_invoices().action_post() of original order after this
order.order_line.invalidate_model(fnames)
if not self.company_id.currency_id.is_zero(total_balance):
# globalized counterpart for the whole orders selection
analytic_distribution = {}
total = sum(order.amount_total for order in orders)
for line in orders.order_line:
ratio = line.price_total / total
if not is_purchase and line.order_id.analytic_account_id:
account_id = str(line.order_id.analytic_account_id.id)
analytic_distribution.update({account_id: analytic_distribution.get(account_id, 0) +100.0*ratio})
if not line.analytic_distribution:
continue
for account_id, distribution in line.analytic_distribution.items():
analytic_distribution.update({account_id : analytic_distribution.get(account_id, 0) + distribution*ratio})
values = _get_aml_vals(orders, -total_balance, 0.0, self.account_id.id, label=_('Accrued total'), analytic_distribution=analytic_distribution)
move_lines.append(Command.create(values))
move_type = _('Expense') if is_purchase else _('Revenue')
move_vals = {
'ref': _('Accrued %s entry as of %s', move_type, format_date(self.env, self.date)),
'journal_id': self.journal_id.id,
'date': self.date,
'line_ids': move_lines,
'currency_id': orders.currency_id.id or self.company_id.currency_id.id,
}
return move_vals, orders_with_entries
def create_entries(self):
self.ensure_one()
if self.reversal_date <= self.date:
raise UserError(_('Reversal date must be posterior to date.'))
move_vals, orders_with_entries = self._compute_move_vals()
move = self.env['account.move'].create(move_vals)
move._post()
reverse_move = move._reverse_moves(default_values_list=[{
'ref': _('Reversal of: %s', move.ref),
'date': self.reversal_date,
}])
reverse_move._post()
for order in orders_with_entries:
body = _(
'Accrual entry created on %(date)s: %(accrual_entry)s.\
And its reverse entry: %(reverse_entry)s.',
date=self.date,
accrual_entry=move._get_html_link(),
reverse_entry=reverse_move._get_html_link(),
)
order.message_post(body=body)
return {
'name': _('Accrual Moves'),
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'view_mode': 'tree,form',
'domain': [('id', 'in', (move.id, reverse_move.id))],
}

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_account_accrued_orders_wizard" model="ir.ui.view">
<field name="name">account.accrued.orders.wizard.view</field>
<field name="model">account.accrued.orders.wizard</field>
<field name="arch" type="xml">
<form string="Make Accrual Entries">
<field name="company_id" invisible="1"/>
<group>
<div class="alert alert-info" colspan="4" role="alert" attrs="{'invisible': [('display_amount', '!=', True)]}">
There doesn't appear to be anything to invoice for the selected order. However, you can use the amount field to force an accrual entry.
</div>
<group>
<field name="journal_id"/>
<field name="account_id"/>
<field name="amount" attrs="{'invisible': [('display_amount', '!=', True)]}"/>
<field name="display_amount" invisible="1"/>
</group>
<group>
<field name="date"/>
<field name="reversal_date"/>
</group>
</group>
<field name="preview_data" widget="grouped_view_widget" class="w-100"/>
<footer>
<button string='Create Entry' name="create_entries" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View file

@ -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

View file

@ -0,0 +1,11 @@
from odoo import models
class MergePartnerAutomatic(models.TransientModel):
_inherit = 'base.partner.merge.automatic.wizard'
def _get_summable_fields(self):
"""Add to summable fields list, fields created in this module.
- customer_rank and supplier_rank will have a better ranking for the merged partner
"""
return super()._get_summable_fields() + ['customer_rank', 'supplier_rank']

View file

@ -0,0 +1,149 @@
# -*- 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",
domain=lambda self: [('type', '=', 'bank'), ('bank_account_id', '=', False), ('company_id', '=', self.env.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_create_multi
def create(self, vals_list):
""" 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.
"""
for vals in vals_list:
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().create(vals_list)
@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):
for journal_id in self.env['account.journal'].search([('type', '=', 'bank'), ('bank_account_id', '=', False)]):
empty_journal_count = self.env['account.move'].search_count([('journal_id', '=', journal_id.id)])
if empty_journal_count == 0:
return journal_id.id
return False
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()
return {'type': 'ir.actions.client', 'tag': 'soft_reload'}

View file

@ -0,0 +1,91 @@
<?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="text-center me-2" style="width: 20% !important;"/>
<field name="fiscalyear_last_month" class="w-75"/>
</div>
</group>
</group>
</sheet>
<footer>
<button name="action_save_onboarding_fiscal_year" string="Apply"
class="oe_highlight" type="object" data-hotkey="q" />
<button special="cancel" data-hotkey="z" 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" colspan="2">
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" data-hotkey="q"/>
<button string="Cancel" special="cancel" data-hotkey="z"/>
</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" decoration-muted="opening_debit == 0 and opening_credit == 0">
<field name="code"/>
<field name="name"/>
<field name="company_id" invisible="1"/>
<field name="account_type" widget="account_type_selection"/>
<field name="reconcile" widget="boolean_toggle"/>
<field name="opening_debit" options="{'no_symbol': True}"/>
<field name="opening_credit" options="{'no_symbol': True}"/>
<field name="opening_balance" optional="hide" options="{'no_symbol': True}"/>
<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-end btn-secondary"/>
</tree>
</field>
</record>
</data>
</odoo>