# Fusion Accounting - QIF Bank Statement Parser # Original implementation for Quicken Interchange Format files # Based on the published QIF specification import logging import re from datetime import datetime from odoo import _, models from odoo.exceptions import UserError _log = logging.getLogger(__name__) class FusionQIFParser: """Standalone parser for QIF (Quicken Interchange Format) files. QIF is a plain-text format where each field occupies its own line, prefixed by a single-character code: D Date of the transaction T Amount (net) U Amount (duplicate field, same meaning as T) P Payee name N Check number or reference M Memo / description L Category or transfer account A Address line (up to 6 lines) C Cleared status (*/c/X/R) ^ End-of-record separator Sections are introduced by a ``!Type:`` header line. This is an **original** implementation written from the published QIF specification — it is not derived from Odoo Enterprise. """ # Supported QIF date formats (US mm/dd/yyyy is most common, but # dd/mm/yyyy and yyyy-mm-dd also appear in the wild). _DATE_FORMATS = [ '%m/%d/%Y', # 01/31/2025 '%m/%d/%y', # 01/31/25 '%m-%d-%Y', # 01-31-2025 '%m-%d-%y', # 01-31-25 '%d/%m/%Y', # 31/01/2025 '%d/%m/%y', # 31/01/25 '%d-%m-%Y', # 31-01-2025 '%d-%m-%y', # 31-01-25 '%Y-%m-%d', # 2025-01-31 '%Y/%m/%d', # 2025/01/31 "%m/%d'%Y", # 1/31'2025 (Quicken short-year) "%m/%d'%y", # 1/31'25 ] # ------------------------------------------------------------------- # Public API # ------------------------------------------------------------------- def parse_qif(self, data_file): """Parse a QIF file and return a statement dict compatible with the Fusion Accounting import pipeline. Returns a **single** dict (QIF files describe one account): - ``name`` : generated statement identifier - ``date`` : last transaction date - ``balance_start`` : 0.0 (QIF does not carry balances) - ``balance_end_real``: 0.0 - ``transactions`` : list of transaction dicts Transaction dicts contain: - ``date`` : transaction date (datetime.date) - ``payment_ref`` : payee / memo - ``ref`` : check number / reference - ``amount`` : signed float - ``unique_import_id`` : generated unique key """ text = self._to_text(data_file) lines = text.splitlines() # Detect account type from the header (optional) account_type = self._detect_account_type(lines) # Split the record stream at ``^`` separators records = self._split_records(lines) if not records: raise UserError( _("The QIF file contains no transaction records.") ) transactions = [] for idx, rec in enumerate(records): txn = self._parse_record(rec, idx) if txn: transactions.append(txn) if not transactions: raise UserError( _("No valid transactions could be extracted from the QIF file.") ) # Build statement metadata dates = [t['date'] for t in transactions if t.get('date')] last_date = max(dates) if dates else None first_date = min(dates) if dates else None stmt_name = "QIF Import" if last_date: stmt_name = f"QIF {last_date.strftime('%Y-%m-%d')}" return { 'name': stmt_name, 'date': last_date, 'balance_start': 0.0, 'balance_end_real': 0.0, 'account_type': account_type, 'transactions': transactions, } # ------------------------------------------------------------------- # Text handling # ------------------------------------------------------------------- @staticmethod def _to_text(data_file): """Ensure *data_file* is a string.""" if isinstance(data_file, bytes): for encoding in ('utf-8-sig', 'utf-8', 'latin-1'): try: return data_file.decode(encoding) except UnicodeDecodeError: continue return data_file # ------------------------------------------------------------------- # Account-type detection # ------------------------------------------------------------------- @staticmethod def _detect_account_type(lines): """Return the QIF account type from a ``!Type:`` header, or ``'Bank'`` as the default.""" for line in lines: stripped = line.strip() if stripped.upper().startswith('!TYPE:'): return stripped[6:].strip() return 'Bank' # ------------------------------------------------------------------- # Record splitting # ------------------------------------------------------------------- @staticmethod def _split_records(lines): """Split *lines* into a list of record-lists, using ``^`` as the record separator. Header lines (``!``) are skipped.""" records = [] current = [] for line in lines: stripped = line.strip() if not stripped: continue if stripped.startswith('!'): # Header / type declaration — skip continue if stripped == '^': if current: records.append(current) current = [] else: current.append(stripped) # Trailing record without final ``^`` if current: records.append(current) return records # ------------------------------------------------------------------- # Single-record parsing # ------------------------------------------------------------------- def _parse_record(self, field_lines, record_index): """Parse a list of single-char-prefixed field lines into a transaction dict.""" fields = {} address_lines = [] for line in field_lines: if len(line) < 1: continue code = line[0] value = line[1:].strip() if code == 'D': fields['date_str'] = value elif code == 'T': fields['amount'] = value elif code == 'U': # Duplicate amount field — use only if T is missing if 'amount' not in fields: fields['amount'] = value elif code == 'P': fields['payee'] = value elif code == 'N': fields['number'] = value elif code == 'M': fields['memo'] = value elif code == 'L': fields['category'] = value elif code == 'C': fields['cleared'] = value elif code == 'A': address_lines.append(value) # Other codes (S, E, $, %) are split-transaction markers; # they are uncommon in bank exports and are ignored here. if address_lines: fields['address'] = ', '.join(address_lines) # Amount is mandatory amount = self._parse_amount(fields.get('amount', '')) if amount is None: return None txn_date = self._parse_qif_date(fields.get('date_str', '')) payee = fields.get('payee', '') memo = fields.get('memo', '') number = fields.get('number', '') # Build description description = payee if memo and memo != payee: description = f"{payee} - {memo}" if payee else memo # Generate a unique import ID from available data unique_parts = [ txn_date.isoformat() if txn_date else str(record_index), str(amount), payee or memo or str(record_index), ] if number: unique_parts.append(number) unique_id = 'QIF-' + '-'.join(unique_parts) return { 'date': txn_date, 'payment_ref': description or number or '/', 'ref': number, 'amount': amount, 'unique_import_id': unique_id, } # ------------------------------------------------------------------- # Date parsing # ------------------------------------------------------------------- @classmethod def _parse_qif_date(cls, date_str): """Try multiple date formats and return the first successful parse as a ``datetime.date``, or ``None``.""" if not date_str: return None # Normalise Quicken apostrophe-year notation: 1/31'2025 → 1/31/2025 normalised = date_str.replace("'", "/") for fmt in cls._DATE_FORMATS: try: return datetime.strptime(normalised, fmt).date() except ValueError: continue _log.warning("Unparseable QIF date: %s", date_str) return None # ------------------------------------------------------------------- # Amount parsing # ------------------------------------------------------------------- @staticmethod def _parse_amount(raw): """Parse a QIF amount string. Handles commas as thousand separators or as decimal separators (European style).""" if not raw: return None # Remove currency symbols and whitespace cleaned = re.sub(r'[^\d.,\-+]', '', raw) if not cleaned: return None # Determine decimal separator heuristic: # If both comma and period present, the last one is the decimal sep. if ',' in cleaned and '.' in cleaned: last_comma = cleaned.rfind(',') last_period = cleaned.rfind('.') if last_comma > last_period: # European: 1.234,56 cleaned = cleaned.replace('.', '').replace(',', '.') else: # US: 1,234.56 cleaned = cleaned.replace(',', '') elif ',' in cleaned: # Could be thousand separator (1,234) or decimal (1,23) parts = cleaned.split(',') if len(parts) == 2 and len(parts[1]) <= 2: # Likely decimal separator cleaned = cleaned.replace(',', '.') else: # Likely thousand separator cleaned = cleaned.replace(',', '') try: return float(cleaned) except ValueError: return None class FusionJournalQIFImport(models.Model): """Register QIF as an available bank-statement import format and implement the parser hook on ``account.journal``.""" _inherit = 'account.journal' # ---- Format Registration ---- def _get_bank_statements_available_import_formats(self): """Append QIF to the list of importable formats.""" formats = super()._get_bank_statements_available_import_formats() formats.append('QIF') return formats # ---- Parser Hook ---- def _parse_bank_statement_file(self, attachment): """Attempt to parse *attachment* as QIF. Falls through to ``super()`` when the file is not recognised as QIF.""" raw_data = attachment.raw if not self._is_qif_file(raw_data): return super()._parse_bank_statement_file(attachment) parser = FusionQIFParser() try: stmt = parser.parse_qif(raw_data) except UserError: raise except Exception as exc: _log.exception("QIF parsing error") raise UserError( _("Could not parse the QIF file: %s", str(exc)) ) from exc # QIF does not carry account-number or currency metadata currency_code = None account_number = None # Wrap the single statement in a list for the pipeline return currency_code, account_number, [stmt] # ---- Detection ---- @staticmethod def _is_qif_file(raw_data): """Heuristic check: does *raw_data* look like a QIF file?""" try: text = raw_data.decode('utf-8-sig', errors='ignore')[:2048] except (UnicodeDecodeError, AttributeError): text = str(raw_data)[:2048] # QIF files almost always start with a !Type: or !Account: header # and contain ``^`` record separators. text_upper = text.upper().strip() if text_upper.startswith('!TYPE:') or text_upper.startswith('!ACCOUNT:'): return True # Fallback: look for the ``^`` separator combined with D/T field codes if '^' in text: has_date_field = bool(re.search(r'^D\d', text, re.MULTILINE)) has_amount_field = bool(re.search(r'^T[\d\-+]', text, re.MULTILINE)) if has_date_field and has_amount_field: return True return False