first commit

This commit is contained in:
Suherdy Yacob 2025-10-25 21:44:56 +07:00
commit 302f15fa98
21 changed files with 469 additions and 0 deletions

2
__init__.py Normal file
View File

@ -0,0 +1,2 @@
from . import models
from . import wizards

29
__manifest__.py Normal file
View File

@ -0,0 +1,29 @@
{
'name': 'Bank Statement Reconciliation',
'version': '17.0.1.0.0',
'category': 'Accounting',
'summary': 'Reconcile bank statement lines with journal entries',
'description': """
This module allows users to reconcile bank statement lines with journal entries.
Features:
- Menu to access bank statement lines
- Filter by bank journal
- Select multiple bank lines to reconcile
- Wizard to select journal entries for reconciliation
- Automatic creation of reconciliation journal entries
""",
'author': 'Suherdy Yacob',
'depends': [
'account',
'base',
],
'data': [
'security/ir.model.access.csv',
'views/bank_statement_line_views.xml',
'views/bank_statement_selector_views.xml',
'wizards/bank_reconcile_wizard_views.xml',
'views/menu.xml',
],
'installable': True,
'auto_install': False,
}

Binary file not shown.

3
models/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from . import bank_statement_line
from . import bank_statement_selector
from . import account_bank_statement_line

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,35 @@
from odoo import models, fields, api
from odoo.exceptions import UserError
class AccountBankStatementLine(models.Model):
_inherit = 'account.bank.statement.line'
def action_reconcile_selected_lines(self):
"""Open the reconciliation wizard for selected lines"""
# Get the selected records from the context
active_ids = self.env.context.get('active_ids')
active_model = self.env.context.get('active_model')
if active_model == 'account.bank.statement.line' and active_ids:
selected_lines = self.browse(active_ids)
else:
# If called from a single record, use self
selected_lines = self
# Filter out already reconciled lines by checking if they have a move_id with reconciliation in the name
unreconciled_lines = selected_lines.filtered(lambda line: not (line.move_id and 'Reconciliation:' in line.move_id.name))
if not unreconciled_lines:
raise UserError("All selected bank statement lines have already been reconciled.")
return {
'name': 'Select Journal Entry to Reconcile',
'type': 'ir.actions.act_window',
'res_model': 'bank.reconcile.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_bank_line_ids': unreconciled_lines.ids,
}
}

View File

@ -0,0 +1,53 @@
from odoo import models, fields, api
from odoo.exceptions import UserError
class BankStatementLine(models.Model):
_name = 'bank.statement.line'
_description = 'Bank Statement Line Selection'
@api.model
def default_get(self, fields):
res = super().default_get(fields)
active_model = self.env.context.get('active_model')
active_ids = self.env.context.get('active_ids')
if active_model == 'account.bank.statement.line' and active_ids:
statement_lines = self.env['account.bank.statement.line'].browse(active_ids)
res['line_ids'] = [(6, 0, statement_lines.ids)]
return res
journal_id = fields.Many2one('account.journal', string='Bank Journal',
domain=[('type', '=', 'bank')])
line_ids = fields.Many2many('account.bank.statement.line', string='Bank Statement Lines')
selected_line_ids = fields.Many2many('account.bank.statement.line',
'bank_statement_line_rel',
'wizard_id', 'line_id',
string='Selected Bank Lines')
@api.onchange('journal_id')
def _onchange_journal_id(self):
if self.journal_id:
statement_lines = self.env['account.bank.statement.line'].search([
('journal_id', '=', self.journal_id.id)
])
self.line_ids = statement_lines
else:
self.line_ids = False
def action_open_reconcile_wizard(self):
"""Open the reconciliation wizard"""
if not self.selected_line_ids:
raise UserError("Please select at least one bank statement line to reconcile.")
return {
'name': 'Select Journal Entry to Reconcile',
'type': 'ir.actions.act_window',
'res_model': 'bank.reconcile.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_bank_line_ids': self.selected_line_ids.ids,
}
}

View File

@ -0,0 +1,29 @@
from odoo import models, fields, api
class BankStatementSelector(models.TransientModel):
_name = 'bank.statement.selector'
_description = 'Bank Statement Selector'
journal_id = fields.Many2one('account.journal',
string='Bank Journal',
domain=[('type', '=', 'bank')],
required=True)
def action_show_statement_lines(self):
"""Open the bank statement lines for the selected journal"""
action = {
'type': 'ir.actions.act_window',
'name': 'Bank Statement Lines',
'res_model': 'account.bank.statement.line',
'view_mode': 'tree,form',
'domain': [('journal_id', '=', self.journal_id.id)],
'context': {
'search_default_journal_id': self.journal_id.id,
},
'views': [
(self.env.ref('bank_statement_reconciliation.view_account_bank_statement_line_tree').id, 'tree'),
(self.env.ref('bank_statement_reconciliation.view_account_bank_statement_line_form').id, 'form')
]
}
return action

View File

@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_bank_statement_line,access_bank_statement_line,model_bank_statement_line,account.group_account_user,1,1,1,1
access_bank_reconcile_wizard,access_bank_reconcile_wizard,model_bank_reconcile_wizard,account.group_account_user,1,1
access_bank_statement_selector,access_bank_statement_selector,model_bank_statement_selector,account.group_account_user,1,1,1,1
1 id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
2 access_bank_statement_line,access_bank_statement_line,model_bank_statement_line,account.group_account_user,1,1,1,1
3 access_bank_reconcile_wizard,access_bank_reconcile_wizard,model_bank_reconcile_wizard,account.group_account_user,1,1
4 access_bank_statement_selector,access_bank_statement_selector,model_bank_statement_selector,account.group_account_user,1,1,1,1

0
views/__init__.py Normal file
View File

View File

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Action to open the bank statement lines view -->
<record id="action_bank_statement_lines" model="ir.actions.act_window">
<field name="name">Bank Statement Lines</field>
<field name="res_model">account.bank.statement.line</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Select a bank journal to view its statement lines
</p>
</field>
</record>
<!-- Server action for reconciliation -->
<record id="action_reconcile_bank_lines" model="ir.actions.server">
<field name="name">Reconcile Selected Lines</field>
<field name="model_id" ref="account.model_account_bank_statement_line"/>
<field name="binding_model_id" ref="account.model_account_bank_statement_line"/>
<field name="state">code</field>
<field name="code">
action = {
'name': 'Select Journal Entry to Reconcile',
'type': 'ir.actions.act_window',
'res_model': 'bank.reconcile.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_bank_line_ids': env.context.get('active_ids'),
}
}
</field>
</record>
<!-- Tree view for bank statement lines -->
<record id="view_account_bank_statement_line_tree" model="ir.ui.view">
<field name="name">account.bank.statement.line.tree</field>
<field name="model">account.bank.statement.line</field>
<field name="arch" type="xml">
<tree string="Bank Statement Lines" create="0" delete="0" decoration-danger="amount &lt; 0" decoration-muted="move_id and 'Reconciliation:' in move_id.name">
<field name="date"/>
<field name="name"/>
<field name="partner_id"/>
<field name="amount"/>
<field name="journal_id"/>
<field name="statement_id"/>
<field name="move_id"/>
</tree>
</field>
</record>
<!-- Form view for bank statement lines -->
<record id="view_account_bank_statement_line_form" model="ir.ui.view">
<field name="name">account.bank.statement.line.form</field>
<field name="model">account.bank.statement.line</field>
<field name="arch" type="xml">
<form string="Bank Statement Line">
<header>
<button name="action_reconcile_selected_lines" type="object" string="Reconcile" class="btn-primary" invisible="move_id and 'Reconciliation:' in move_id.name"/>
</header>
<sheet>
<group>
<group>
<field name="date"/>
<field name="name"/>
<field name="ref"/>
</group>
<group>
<field name="amount"/>
<field name="partner_id"/>
<field name="journal_id" readonly="1"/>
</group>
</group>
<group>
<field name="statement_id"/>
<field name="move_id"/>
<field name="company_id" invisible="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Search view for bank statement lines -->
<record id="view_account_bank_statement_line_search" model="ir.ui.view">
<field name="name">account.bank.statement.line.search</field>
<field name="model">account.bank.statement.line</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="partner_id"/>
<field name="journal_id"/>
<field name="date"/>
<filter name="positive_amount" string="Income" domain="[('amount', '&gt;', 0)]"/>
<filter name="negative_amount" string="Expense" domain="[('amount', '&lt;', 0)]"/>
<group expand="0" string="Group By">
<filter name="group_by_journal" string="Journal" context="{'group_by': 'journal_id'}"/>
<filter name="group_by_date" string="Date" context="{'group_by': 'date'}"/>
</group>
</search>
</field>
</record>
</odoo>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form view for bank statement selector -->
<record id="view_bank_statement_selector_form" model="ir.ui.view">
<field name="name">bank.statement.selector.form</field>
<field name="model">bank.statement.selector</field>
<field name="arch" type="xml">
<form string="Select Bank Journal">
<group>
<field name="journal_id" options="{'no_create': True}"/>
</group>
<footer>
<button name="action_show_statement_lines" type="object" string="Show Statement Lines"
class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Action to open the bank statement selector -->
<record id="action_bank_statement_selector" model="ir.actions.act_window">
<field name="name">Select Bank Journal</field>
<field name="res_model">bank.statement.selector</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="view_id" ref="view_bank_statement_selector_form"/>
</record>
</odoo>

14
views/menu.xml Normal file
View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Menu item for Bank Statement Reconciliation -->
<menuitem id="menu_bank_statement_root"
name="Bank Reconciliation"
sequence="10"
groups="account.group_account_user"/>
<menuitem id="menu_bank_statement_lines"
name="Bank Statement Lines"
parent="menu_bank_statement_root"
action="action_bank_statement_selector"
sequence="10"/>
</odoo>

1
wizards/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import bank_reconcile_wizard

Binary file not shown.

View File

@ -0,0 +1,131 @@
from odoo import models, fields, api
from odoo.exceptions import UserError
class BankReconcileWizard(models.TransientModel):
_name = 'bank.reconcile.wizard'
_description = 'Bank Reconcile Wizard'
bank_line_ids = fields.Many2many('account.bank.statement.line',
string='Selected Bank Lines',
readonly=True)
journal_entry_id = fields.Many2one('account.move',
string='Journal Entry to Reconcile',
domain=[('state', '=', 'posted')])
journal_entry_line_id = fields.Many2one('account.move.line',
string='Journal Entry Line to Reconcile',
domain="[('move_id', '=', journal_entry_id), ('reconciled', '=', False)]")
total_bank_line_amount = fields.Float(string='Total Bank Line Amount', compute='_compute_total_bank_line_amount', store=True)
@api.onchange('journal_entry_id')
def _onchange_journal_entry_id(self):
"""Reset journal entry line when journal entry changes"""
if self.journal_entry_id:
self.journal_entry_line_id = False
else:
self.journal_entry_line_id = False
@api.depends('bank_line_ids')
def _compute_total_bank_line_amount(self):
"""Compute total amount of selected bank lines"""
for record in self:
record.total_bank_line_amount = sum(line.amount for line in record.bank_line_ids)
def action_reconcile(self):
"""Perform the reconciliation for each selected bank line"""
if not self.journal_entry_id:
raise UserError("Please select a journal entry to reconcile.")
if not self.journal_entry_line_id:
raise UserError("Please select a journal entry line to reconcile.")
# Validate that the total of individual line amounts doesn't exceed the journal entry line amount
journal_line_amount = self.journal_entry_line_id.debit or self.journal_entry_line_id.credit
if len(self.bank_line_ids) > 1:
total_bank_amount = sum(abs(line.amount) for line in self.bank_line_ids)
if total_bank_amount > journal_line_amount:
raise UserError(f"Total bank line amounts ({total_bank_amount}) cannot exceed the journal entry line amount ({journal_line_amount}).")
# Process each selected bank line individually
for bank_line in self.bank_line_ids:
self._reconcile_single_line(bank_line, self.journal_entry_line_id)
return {'type': 'ir.actions.act_window_close'}
def _reconcile_single_line(self, bank_line, journal_entry_line):
"""Reconcile a single bank line with a journal entry line"""
# Get the account from the journal entry line that will be on the opposite side
reconcile_account = journal_entry_line.account_id
# Use the individual bank line amount for each line
reconcile_amount = abs(bank_line.amount)
# According to requirement #5: if the bank line is debit,
# then the new journal entry after reconcile will have that bank account in debit
bank_account = bank_line.journal_id.default_account_id
bank_line_amount = bank_line.amount
# Determine if the bank line is debit (positive) or credit (negative)
if bank_line_amount >= 0: # Bank line is debit (money coming in)
# Bank account should be debited (requirement #5)
debit_account = bank_account
# The account from the selected journal entry line goes to credit (requirement #6)
credit_account = reconcile_account
else: # Bank line is credit (money going out)
# Bank account should be credited
credit_account = bank_account
# The account from the selected journal entry line goes to debit
debit_account = reconcile_account
# Set the amounts - make sure they are balanced
debit_amount = reconcile_amount
credit_amount = reconcile_amount
# Create a new journal entry for reconciliation with lines
reconciling_move = self.env['account.move'].create({
'journal_id': bank_line.journal_id.id,
'date': bank_line.date,
'ref': f'Reconciliation: {bank_line.name or "Bank Line"}',
'move_type': 'entry',
'line_ids': [
(0, 0, {
'account_id': debit_account.id,
'debit': debit_amount,
'credit': 0,
'name': f'Bank Reconciliation: {bank_line.name or ""}',
}),
(0, 0, {
'account_id': credit_account.id,
'debit': 0,
'credit': credit_amount,
'name': f'Bank Reconciliation: {bank_line.name or ""}',
})
]
})
# Post the reconciling journal entry
reconciling_move.action_post()
# Link the bank line to the reconciling move
bank_line.sudo().write({
'move_id': reconciling_move.id,
})
# Mark the bank line as reconciled in a separate operation
bank_line.sudo().write({
'is_reconciled': True,
})
# Try to reconcile the selected journal entry line with the corresponding line in the reconciling move
# Find the line in the reconciling move that has the same account as the journal entry line
reconciling_line = reconciling_move.line_ids.filtered(lambda l: l.account_id.id == reconcile_account.id and l.credit > 0)
# Try to reconcile the journal entry line with our reconciling line
if reconciling_line:
try:
(journal_entry_line + reconciling_line).reconcile()
except:
# If reconcile fails, we'll just link the moves
pass

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Wizard form view -->
<record id="view_bank_reconcile_wizard_form" model="ir.ui.view">
<field name="name">bank.reconcile.wizard.form</field>
<field name="model">bank.reconcile.wizard</field>
<field name="arch" type="xml">
<form string="Reconcile Bank Lines">
<group>
<field name="bank_line_ids" widget="many2many" options="{'no_create': True}" readonly="1"/>
</group>
<group>
<field name="total_bank_line_amount" readonly="1"/>
<field name="journal_entry_id" options="{'no_create': True}"/>
<field name="journal_entry_line_id"
domain="[('move_id', '=', journal_entry_id), ('reconciled', '=', False)]"
invisible="journal_entry_id == False"/>
</group>
<footer>
<button name="action_reconcile" type="object" string="Reconcile"
class="btn-primary"
invisible="journal_entry_id == False or journal_entry_line_id == False"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Action to open the wizard -->
<record id="action_bank_reconcile_wizard" model="ir.actions.act_window">
<field name="name">Select Journal Entry to Reconcile</field>
<field name="res_model">bank.reconcile.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>