# Fusion Accounting - Unified Bank Statement Import Wizard # Accepts file uploads, auto-detects format (CSV, OFX, QIF, CAMT.053), # routes to the correct parser, and creates bank statement lines. import base64 import logging from odoo import _, api, fields, models from odoo.exceptions import UserError from ..models.bank_statement_import_ofx import FusionOFXParser from ..models.bank_statement_import_qif import FusionQIFParser from ..models.bank_statement_import_camt import FusionCAMTParser _log = logging.getLogger(__name__) class FusionBankStatementImportWizard(models.TransientModel): """Transient wizard that provides a unified interface for importing bank statements from multiple file formats. The wizard: 1. Accepts a binary file upload from the user 2. Auto-detects the file format (OFX, QIF, CAMT.053, or CSV) 3. Routes the file to the appropriate parser 4. Creates ``account.bank.statement`` and ``account.bank.statement.line`` records from the parsed data 5. Reports the import results (or errors) to the user """ _name = 'fusion.bank.statement.import' _description = 'Import Bank Statement' # ---- Fields ---- journal_id = fields.Many2one( 'account.journal', string='Bank Journal', required=True, domain="[('type', 'in', ('bank', 'credit', 'cash'))]", default=lambda self: self._default_journal_id(), help="The journal to import the bank statement into.", ) data_file = fields.Binary( string='Bank Statement File', required=True, help=( "Upload a bank statement file. Supported formats: " "OFX, QIF, CAMT.053 (XML), and CSV." ), ) filename = fields.Char( string='Filename', help="Name of the uploaded file.", ) detected_format = fields.Char( string='Detected Format', readonly=True, help="The file format detected by the auto-detection engine.", ) import_result = fields.Text( string='Import Result', readonly=True, help="Summary of the import operation.", ) # ---- Defaults ---- @api.model def _default_journal_id(self): """Default to the journal from context, or the first bank journal.""" journal_id = self.env.context.get('default_journal_id') if journal_id: return journal_id return self.env['account.journal'].search( [('type', '=', 'bank')], limit=1, ).id or False # ---- Onchange: auto-detect on file upload ---- @api.onchange('data_file', 'filename') def _onchange_data_file(self): """Auto-detect the file format when a file is uploaded.""" if not self.data_file: self.detected_format = '' return raw_data = base64.b64decode(self.data_file) fmt = self._detect_format(raw_data, self.filename or '') self.detected_format = fmt # ---- Format detection ---- @staticmethod def _detect_format(raw_data, filename=''): """Examine the file content (and optionally the filename) to determine the file format. Returns one of: ``'OFX'``, ``'QIF'``, ``'CAMT.053'``, ``'CSV'``, or ``'Unknown'``. """ # Decode a preview of the file for text-based detection try: preview = raw_data[:8192].decode('utf-8-sig', errors='ignore') except Exception: preview = '' preview_upper = preview.upper().strip() fname_lower = (filename or '').lower().strip() # --- CAMT.053 (XML with ISO 20022 namespace) --- if 'camt.053' in preview.lower() or 'BkToCstmrStmt' in preview: return 'CAMT.053' # --- OFX (v1 SGML or v2 XML) --- if (preview_upper.startswith('' in preview_upper or 'OFXHEADER:' in preview_upper): return 'OFX' if fname_lower.endswith('.ofx'): return 'OFX' # --- QIF --- if preview_upper.startswith('!TYPE:') or preview_upper.startswith('!ACCOUNT:'): return 'QIF' if fname_lower.endswith('.qif'): return 'QIF' # --- CSV / spreadsheet --- if fname_lower.endswith(('.csv', '.xls', '.xlsx')): return 'CSV' # Content-based CSV heuristic: looks for comma-separated or # semicolon-separated tabular data lines = preview.split('\n') if len(lines) >= 2: first_line = lines[0].strip() if (',' in first_line or ';' in first_line or '\t' in first_line): # Check second line has a similar number of separators sep = ',' if ',' in first_line else (';' if ';' in first_line else '\t') if abs(first_line.count(sep) - lines[1].strip().count(sep)) <= 1: return 'CSV' return 'Unknown' # ---- Main import action ---- def action_import(self): """Main entry point: decode the file, detect format, parse, and create bank statement records.""" self.ensure_one() if not self.data_file: raise UserError(_("Please upload a bank statement file.")) raw_data = base64.b64decode(self.data_file) if not raw_data: raise UserError(_("The uploaded file is empty.")) fmt = self._detect_format(raw_data, self.filename or '') self.detected_format = fmt if fmt == 'CSV': return self._import_csv(raw_data) elif fmt == 'OFX': return self._import_parsed(raw_data, FusionOFXParser(), 'parse_ofx', 'OFX') elif fmt == 'QIF': return self._import_qif(raw_data) elif fmt == 'CAMT.053': return self._import_parsed(raw_data, FusionCAMTParser(), 'parse_camt', 'CAMT.053') else: raise UserError( _("Could not determine the file format. " "Supported formats are: OFX, QIF, CAMT.053, and CSV.") ) # ---- Format-specific import handlers ---- def _import_parsed(self, raw_data, parser, method_name, label): """Generic handler for parsers that return a list of statement dicts (OFX, CAMT.053).""" try: parse_fn = getattr(parser, method_name) statements = parse_fn(raw_data) except UserError: raise except Exception as exc: _log.exception("%s parsing failed", label) raise UserError( _("Failed to parse the %(format)s file: %(error)s", format=label, error=str(exc)) ) from exc if not statements: raise UserError( _("No statements found in the %(format)s file.", format=label) ) return self._create_statements_from_parsed(statements, label) def _import_qif(self, raw_data): """Handle QIF import — the parser returns a single dict.""" parser = FusionQIFParser() try: stmt = parser.parse_qif(raw_data) except UserError: raise except Exception as exc: _log.exception("QIF parsing failed") raise UserError( _("Failed to parse the QIF file: %s", str(exc)) ) from exc if not stmt: raise UserError(_("No transactions found in the QIF file.")) return self._create_statements_from_parsed([stmt], 'QIF') def _import_csv(self, raw_data): """Redirect CSV files to the existing base_import column-mapping wizard, which provides interactive field mapping for CSV data.""" attachment = self.env['ir.attachment'].create({ 'name': self.filename or 'bank_statement.csv', 'type': 'binary', 'raw': raw_data, 'mimetype': 'text/csv', }) return self.journal_id._import_bank_statement(attachment) # ---- Statement creation ---- def _create_statements_from_parsed(self, statements, format_label): """Create bank statement records from parsed statement dicts and open the reconciliation widget for the imported lines. This method delegates to the journal's existing import pipeline for proper duplicate detection, partner matching, and statement creation. """ journal = self.journal_id if not journal: raise UserError(_("Please select a bank journal.")) if not journal.default_account_id: raise UserError( _("You must set a Default Account for the journal: %s", journal.name) ) # Extract currency / account from first statement currency_code = None account_number = None for stmt in statements: if stmt.get('currency_code'): currency_code = stmt['currency_code'] if stmt.get('account_number'): account_number = stmt['account_number'] if currency_code and account_number: break # Validate through the journal pipeline journal._check_parsed_data(statements, account_number) # Find / validate journal match target_journal = journal._find_additional_data(currency_code, account_number) # Create an attachment reference for the import attachment = self.env['ir.attachment'].create({ 'name': self.filename or f'{format_label}_import', 'type': 'binary', 'raw': base64.b64decode(self.data_file), 'mimetype': 'application/octet-stream', }) # Complete statement values statements = target_journal._complete_bank_statement_vals( statements, target_journal, account_number, attachment, ) # Create statement records stmt_ids, line_ids, notifications = target_journal._create_bank_statements( statements, ) if not stmt_ids: raise UserError( _("No new transactions were imported. " "The file may contain only duplicates.") ) # Build a summary message stmt_records = self.env['account.bank.statement'].browse(stmt_ids) total_lines = len(line_ids) total_stmts = len(stmt_ids) summary = _( "Successfully imported %(lines)d transaction(s) into " "%(stmts)d statement(s) from %(format)s file.", lines=total_lines, stmts=total_stmts, format=format_label, ) for notif in notifications: summary += f"\n{notif.get('message', '')}" # Open the reconciliation widget for the new lines return stmt_records.line_ids._action_open_bank_reconciliation_widget( extra_domain=[('statement_id', 'in', stmt_ids)], default_context={ 'search_default_not_matched': True, 'default_journal_id': target_journal.id, 'notifications': { self.filename or format_label: summary, }, }, )