Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,378 @@
# 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