This commit is contained in:
gsinghpal
2026-04-02 23:40:34 -04:00
parent 1c560c6df2
commit 4cd7357aa0
73 changed files with 7076 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,22 @@
{
'name': 'Fusion Bank Statements',
'version': '19.0.1.0.0',
'category': 'Accounting',
'summary': 'Import OFX/QFX bank statements with automatic duplicate detection',
'description': 'Upload OFX, QFX, or QBO files exported from your bank '
'(ScotiaConnect, TD, RBC, etc.) and import them as bank '
'statement lines. Smart duplicate detection using the bank\'s '
'transaction ID (fitid). No external server communication.',
'author': 'Fusion Central',
'website': 'https://fusionsoft.ca',
'license': 'LGPL-3',
'depends': ['account'],
'data': [
'security/ir.model.access.csv',
'wizard/import_statement_views.xml',
'views/account_journal_views.xml',
],
'external_dependencies': {'python': ['ofxparse']},
'installable': True,
'auto_install': False,
}

View File

@@ -0,0 +1,2 @@
from . import import_log
from . import account_journal

View File

@@ -0,0 +1,16 @@
from odoo import models
class AccountJournal(models.Model):
_inherit = 'account.journal'
def action_open_statement_import(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Import Bank Statement',
'res_model': 'fusion.statement.import',
'view_mode': 'form',
'target': 'new',
'context': {'default_journal_id': self.id},
}

View File

@@ -0,0 +1,26 @@
from odoo import fields, models
class FusionStatementImportLog(models.Model):
_name = 'fusion.statement.import.log'
_description = 'Imported Bank Transaction Log'
_order = 'date desc, id desc'
_rec_name = 'fitid'
journal_id = fields.Many2one(
'account.journal', required=True, ondelete='cascade', index=True,
)
fitid = fields.Char(string='Bank Transaction ID', required=True, index=True)
date = fields.Date()
amount = fields.Float(digits=(16, 2))
payment_ref = fields.Char(string='Description')
import_date = fields.Datetime(default=fields.Datetime.now, readonly=True)
statement_line_id = fields.Many2one('account.bank.statement.line', ondelete='set null')
company_id = fields.Many2one(
'res.company', required=True, default=lambda self: self.env.company,
)
_sql_constraints = [
('journal_fitid_unique', 'UNIQUE(journal_id, fitid)',
'This transaction has already been imported for this journal.'),
]

View File

@@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_import_log_accountant,fusion.statement.import.log accountant,model_fusion_statement_import_log,account.group_account_invoice,1,1,1,0
access_fusion_import_log_manager,fusion.statement.import.log manager,model_fusion_statement_import_log,account.group_account_manager,1,1,1,1
access_fusion_import_wizard,fusion.statement.import wizard,model_fusion_statement_import,account.group_account_invoice,1,1,1,1
access_fusion_import_line,fusion.statement.import.line wizard,model_fusion_statement_import_line,account.group_account_invoice,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_import_log_accountant fusion.statement.import.log accountant model_fusion_statement_import_log account.group_account_invoice 1 1 1 0
3 access_fusion_import_log_manager fusion.statement.import.log manager model_fusion_statement_import_log account.group_account_manager 1 1 1 1
4 access_fusion_import_wizard fusion.statement.import wizard model_fusion_statement_import account.group_account_invoice 1 1 1 1
5 access_fusion_import_line fusion.statement.import.line wizard model_fusion_statement_import_line account.group_account_invoice 1 1 1 1

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add "Import Statement" button to bank journal form view -->
<record id="view_account_journal_form_inherit_fusion" model="ir.ui.view">
<field name="name">account.journal.form.fusion.statements</field>
<field name="model">account.journal</field>
<field name="inherit_id" ref="account.view_account_journal_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_open_statement_import"
type="object"
class="oe_stat_button"
icon="fa-upload"
invisible="type != 'bank'">
<span class="o_stat_text">Import Statement</span>
</button>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,243 @@
import base64
import io
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
try:
from ofxparse import OfxParser
except ImportError:
OfxParser = None
_logger.warning("ofxparse library not installed — OFX import disabled.")
class FusionStatementImportLine(models.TransientModel):
_name = 'fusion.statement.import.line'
_description = 'Statement Import Preview Line'
_order = 'date desc, id desc'
wizard_id = fields.Many2one('fusion.statement.import', ondelete='cascade')
selected = fields.Boolean(default=True)
is_duplicate = fields.Boolean(readonly=True)
fitid = fields.Char(string='Transaction ID', readonly=True)
date = fields.Date(readonly=True)
payment_ref = fields.Char(string='Description', readonly=True)
amount = fields.Float(digits=(16, 2), readonly=True)
class FusionStatementImport(models.TransientModel):
_name = 'fusion.statement.import'
_description = 'Import Bank Statement'
step = fields.Selection([
('upload', 'Upload'),
('review', 'Review'),
], default='upload', readonly=True)
journal_id = fields.Many2one(
'account.journal', string='Bank Journal', required=True,
domain="[('type', '=', 'bank')]",
)
data_file = fields.Binary(string='Statement File', attachment=False)
filename = fields.Char()
line_ids = fields.One2many('fusion.statement.import.line', 'wizard_id')
total_new = fields.Integer(compute='_compute_counts')
total_duplicate = fields.Integer(compute='_compute_counts')
total_selected = fields.Integer(compute='_compute_counts')
balance_start = fields.Float(digits=(16, 2), readonly=True)
balance_end = fields.Float(digits=(16, 2), readonly=True)
currency_code = fields.Char(readonly=True)
account_number = fields.Char(readonly=True)
@api.depends('line_ids.selected', 'line_ids.is_duplicate')
def _compute_counts(self):
for rec in self:
lines = rec.line_ids
rec.total_new = len(lines.filtered(lambda l: not l.is_duplicate))
rec.total_duplicate = len(lines.filtered(lambda l: l.is_duplicate))
rec.total_selected = len(lines.filtered(lambda l: l.selected))
# ------------------------------------------------------------------
# Step 1 → Step 2: Parse file
# ------------------------------------------------------------------
def action_parse(self):
self.ensure_one()
if not self.data_file:
raise UserError(_("Please upload a statement file."))
if not OfxParser:
raise UserError(_(
"The 'ofxparse' Python library is not installed. "
"Ask your administrator to run: pip install ofxparse"
))
raw = base64.b64decode(self.data_file)
try:
ofx = OfxParser.parse(io.BytesIO(raw))
except Exception as e:
raise UserError(_(
"Could not parse the file. Make sure it is a valid "
"OFX/QFX/QBO file.\n\nError: %s"
) % str(e)) from e
if not ofx.accounts:
raise UserError(_("No accounts found in the file."))
account = ofx.accounts[0]
transactions = account.statement.transactions
if not transactions:
raise UserError(_("No transactions found in the file."))
ImportLog = self.env['fusion.statement.import.log']
existing_fitids = set(
ImportLog.search([
('journal_id', '=', self.journal_id.id),
]).mapped('fitid')
)
lines = []
for tx in transactions:
fitid = str(tx.id).strip()
payee = tx.payee or ''
if tx.checknum:
payee += ' ' + tx.checknum
if tx.memo:
payee += ' : ' + tx.memo
is_dup = fitid in existing_fitids
lines.append((0, 0, {
'fitid': fitid,
'date': tx.date.date() if hasattr(tx.date, 'date') else tx.date,
'payment_ref': payee.strip(),
'amount': float(tx.amount),
'is_duplicate': is_dup,
'selected': not is_dup,
}))
balance = float(account.statement.balance)
total_amt = sum(float(tx.amount) for tx in transactions)
self.write({
'step': 'review',
'line_ids': [(5, 0, 0)] + lines,
'balance_end': balance,
'balance_start': balance - total_amt,
'currency_code': account.statement.currency or '',
'account_number': account.number or '',
})
return self._reopen()
# ------------------------------------------------------------------
# Step 2: Import selected lines
# ------------------------------------------------------------------
def action_import(self):
self.ensure_one()
selected = self.line_ids.filtered(lambda l: l.selected)
if not selected:
raise UserError(_("No transactions selected for import."))
journal = self.journal_id
statement = self.env['account.bank.statement'].create({
'name': self.filename or 'OFX Import',
'reference': self.filename or '',
'journal_id': journal.id,
'balance_start': self.balance_start,
'balance_end_real': self.balance_end,
})
ImportLog = self.env['fusion.statement.import.log']
created_lines = self.env['account.bank.statement.line']
for line in selected.sorted('date'):
st_line = self.env['account.bank.statement.line'].create({
'journal_id': journal.id,
'date': line.date,
'payment_ref': line.payment_ref,
'amount': line.amount,
'statement_id': statement.id,
})
created_lines |= st_line
ImportLog.create({
'journal_id': journal.id,
'fitid': line.fitid,
'date': line.date,
'amount': line.amount,
'payment_ref': line.payment_ref,
'statement_line_id': st_line.id,
'company_id': journal.company_id.id,
})
all_lines = self.line_ids
dup_count = len(all_lines.filtered(lambda l: l.is_duplicate))
manual_skip = len(all_lines.filtered(lambda l: not l.selected and not l.is_duplicate))
date_min = min(selected.mapped('date'))
date_max = max(selected.mapped('date'))
parts = ['%d transactions imported.' % len(selected)]
if dup_count:
parts.append('%d duplicates detected.' % dup_count)
if manual_skip:
parts.append('%d manually excluded.' % manual_skip)
parts.append('Date range: %s to %s' % (date_min, date_max))
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Bank Statement Imported'),
'message': ' '.join(parts),
'type': 'success',
'sticky': False,
'next': {
'type': 'ir.actions.act_window',
'name': _('Imported Statement'),
'res_model': 'account.bank.statement',
'res_id': statement.id,
'views': [(False, 'form')],
},
},
}
# ------------------------------------------------------------------
# Navigation helpers
# ------------------------------------------------------------------
def action_back(self):
self.ensure_one()
self.write({'step': 'upload', 'line_ids': [(5, 0, 0)]})
return self._reopen()
def action_select_all_new(self):
self.ensure_one()
for line in self.line_ids:
line.selected = not line.is_duplicate
return self._reopen()
def action_select_none(self):
self.ensure_one()
self.line_ids.write({'selected': False})
return self._reopen()
def action_select_all(self):
self.ensure_one()
self.line_ids.write({'selected': True})
return self._reopen()
def _reopen(self):
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'views': [(False, 'form')],
'target': 'new',
}

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="fusion_statement_import_form" model="ir.ui.view">
<field name="name">fusion.statement.import.form</field>
<field name="model">fusion.statement.import</field>
<field name="arch" type="xml">
<form string="Import Bank Statement">
<!-- Step 1: Upload -->
<group invisible="step != 'upload'">
<group>
<field name="journal_id"/>
<field name="data_file" filename="filename"/>
<field name="filename" invisible="1"/>
</group>
<group>
<p class="text-muted">
Upload an OFX, QFX, or QBO file exported from your bank portal.
Duplicate transactions will be detected automatically.
</p>
</group>
</group>
<!-- Step 2: Review -->
<group invisible="step != 'review'" string="File Summary">
<group>
<field name="account_number" readonly="1"/>
<field name="currency_code" readonly="1"/>
</group>
<group>
<field name="balance_start" readonly="1"/>
<field name="balance_end" readonly="1"/>
</group>
</group>
<div invisible="step != 'review'" class="mb-2">
<div class="d-flex gap-2 align-items-center mb-3">
<span class="badge text-bg-success fs-6">
New: <field name="total_new" class="d-inline" readonly="1"/>
</span>
<span class="badge text-bg-warning fs-6">
Duplicates: <field name="total_duplicate" class="d-inline" readonly="1"/>
</span>
<span class="badge text-bg-primary fs-6">
Selected: <field name="total_selected" class="d-inline" readonly="1"/>
</span>
<span class="flex-grow-1"/>
<button name="action_select_all_new" type="object"
class="btn btn-secondary btn-sm">
Select New Only
</button>
<button name="action_select_all" type="object"
class="btn btn-secondary btn-sm">
Select All
</button>
<button name="action_select_none" type="object"
class="btn btn-secondary btn-sm">
Deselect All
</button>
</div>
<field name="line_ids" nolabel="1">
<list editable="bottom"
decoration-danger="is_duplicate and selected"
decoration-muted="is_duplicate and not selected"
decoration-success="not is_duplicate and selected">
<field name="selected"/>
<field name="is_duplicate" string="Dup?" widget="boolean"/>
<field name="date"/>
<field name="payment_ref"/>
<field name="amount"/>
<field name="fitid"/>
</list>
</field>
</div>
<field name="step" invisible="1"/>
<footer>
<button name="action_parse" type="object"
string="Parse File" class="btn-primary"
invisible="step != 'upload'"/>
<button name="action_import" type="object"
string="Import Selected" class="btn-primary"
invisible="step != 'review'"/>
<button name="action_back" type="object"
string="Back" class="btn-secondary"
invisible="step != 'review'"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fusion_statement_import" model="ir.actions.act_window">
<field name="name">Import Bank Statement</field>
<field name="res_model">fusion.statement.import</field>
<field name="view_mode">form</field>
<field name="view_id" ref="fusion_statement_import_form"/>
<field name="target">new</field>
</record>
<menuitem id="menu_fusion_statement_import"
name="Import Bank Statement (OFX)"
parent="account.account_transactions_menu"
action="action_fusion_statement_import"
sequence="90"/>
</odoo>