changes
This commit is contained in:
2
fusion-statements/fusion_statements/__init__.py
Normal file
2
fusion-statements/fusion_statements/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import wizard
|
||||
22
fusion-statements/fusion_statements/__manifest__.py
Normal file
22
fusion-statements/fusion_statements/__manifest__.py
Normal 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,
|
||||
}
|
||||
2
fusion-statements/fusion_statements/models/__init__.py
Normal file
2
fusion-statements/fusion_statements/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import import_log
|
||||
from . import account_journal
|
||||
@@ -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},
|
||||
}
|
||||
26
fusion-statements/fusion_statements/models/import_log.py
Normal file
26
fusion-statements/fusion_statements/models/import_log.py
Normal 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.'),
|
||||
]
|
||||
@@ -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
|
||||
|
@@ -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>
|
||||
1
fusion-statements/fusion_statements/wizard/__init__.py
Normal file
1
fusion-statements/fusion_statements/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import import_statement
|
||||
243
fusion-statements/fusion_statements/wizard/import_statement.py
Normal file
243
fusion-statements/fusion_statements/wizard/import_statement.py
Normal 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',
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user