170 lines
5.0 KiB
Python
170 lines
5.0 KiB
Python
"""
|
|
Import Service — parses CSV files for bulk transaction import.
|
|
Expected columns: date, type, description, category, account, amount, notes
|
|
Date formats: YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY
|
|
"""
|
|
|
|
import csv
|
|
import io
|
|
import logging
|
|
from datetime import datetime
|
|
from app.extensions import db
|
|
from app.models.transaction import Transaction
|
|
from app.models.category import Category
|
|
from app.models.account import Account
|
|
from app.services.account_service import calc_balance
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
REQUIRED_COLS = {'date', 'type', 'description', 'amount'}
|
|
DATE_FORMATS = ['%Y-%m-%d', '%m/%d/%Y', '%d/%m/%Y', '%Y/%m/%d', '%d-%m-%Y']
|
|
|
|
|
|
def _parse_date(s):
|
|
s = s.strip()
|
|
for fmt in DATE_FORMATS:
|
|
try:
|
|
return datetime.strptime(s, fmt).date()
|
|
except ValueError:
|
|
continue
|
|
raise ValueError(f'Unrecognised date format: {s!r}')
|
|
|
|
|
|
def _match_category(name, txn_type):
|
|
if not name:
|
|
return None
|
|
cat = Category.query.filter(
|
|
Category.name.ilike(name.strip()),
|
|
Category.category_type.in_([txn_type, 'both']),
|
|
Category.is_active == True,
|
|
).first()
|
|
return cat.id if cat else None
|
|
|
|
|
|
def _match_account(name):
|
|
if not name:
|
|
return None
|
|
acct = Account.query.filter(
|
|
Account.name.ilike(name.strip()),
|
|
Account.is_active == True,
|
|
).first()
|
|
return acct.id if acct else None
|
|
|
|
|
|
def parse_csv(file_content, default_account_id=None):
|
|
"""
|
|
Parse CSV content (str or bytes).
|
|
Returns (preview_rows, errors, column_map)
|
|
preview_rows: list of dicts ready for import
|
|
errors: list of error strings
|
|
"""
|
|
if isinstance(file_content, bytes):
|
|
file_content = file_content.decode('utf-8-sig') # handle BOM
|
|
|
|
reader = csv.DictReader(io.StringIO(file_content))
|
|
headers = {h.strip().lower() for h in (reader.fieldnames or [])}
|
|
|
|
missing = REQUIRED_COLS - headers
|
|
if missing:
|
|
return [], [f'Missing required columns: {", ".join(missing)}'], {}
|
|
|
|
rows = []
|
|
errors = []
|
|
|
|
for i, row in enumerate(reader, start=2):
|
|
clean = {k.strip().lower(): v.strip() for k, v in row.items()}
|
|
row_errors = []
|
|
|
|
# Date
|
|
try:
|
|
txn_date = _parse_date(clean.get('date', ''))
|
|
except ValueError as e:
|
|
row_errors.append(f'Row {i}: {e}')
|
|
continue
|
|
|
|
# Type
|
|
txn_type = clean.get('type', '').lower()
|
|
if txn_type not in ('income', 'expense'):
|
|
row_errors.append(f'Row {i}: type must be "income" or "expense", got {txn_type!r}')
|
|
continue
|
|
|
|
# Amount
|
|
try:
|
|
amount = float(clean.get('amount', '0').replace(',', '').replace('$', '').strip())
|
|
if amount <= 0:
|
|
raise ValueError('Amount must be > 0')
|
|
except ValueError as e:
|
|
row_errors.append(f'Row {i}: invalid amount — {e}')
|
|
continue
|
|
|
|
# Description
|
|
description = clean.get('description', '').strip()
|
|
if not description:
|
|
row_errors.append(f'Row {i}: description is required')
|
|
continue
|
|
|
|
# Optional fields
|
|
category_id = _match_category(clean.get('category', ''), txn_type)
|
|
account_id = _match_account(clean.get('account', '')) or default_account_id
|
|
notes = clean.get('notes', '')
|
|
|
|
if row_errors:
|
|
errors.extend(row_errors)
|
|
else:
|
|
rows.append({
|
|
'date': txn_date,
|
|
'transaction_type': txn_type,
|
|
'description': description,
|
|
'amount': amount,
|
|
'category_id': category_id,
|
|
'account_id': account_id,
|
|
'notes': notes,
|
|
'category_name': clean.get('category', ''),
|
|
'account_name': clean.get('account', ''),
|
|
})
|
|
|
|
return rows, errors
|
|
|
|
|
|
def import_rows(rows, skip_duplicates=True):
|
|
"""
|
|
Insert parsed rows into DB.
|
|
Returns (imported_count, skipped_count)
|
|
"""
|
|
imported = 0
|
|
skipped = 0
|
|
affected_accounts = set()
|
|
|
|
for row in rows:
|
|
if skip_duplicates:
|
|
existing = Transaction.query.filter_by(
|
|
date=row['date'],
|
|
description=row['description'],
|
|
amount=row['amount'],
|
|
transaction_type=row['transaction_type'],
|
|
).first()
|
|
if existing:
|
|
skipped += 1
|
|
continue
|
|
|
|
txn = Transaction(
|
|
date=row['date'],
|
|
transaction_type=row['transaction_type'],
|
|
description=row['description'],
|
|
amount=row['amount'],
|
|
category_id=row.get('category_id'),
|
|
account_id=row.get('account_id'),
|
|
notes=row.get('notes', ''),
|
|
)
|
|
db.session.add(txn)
|
|
if row.get('account_id'):
|
|
affected_accounts.add(row['account_id'])
|
|
imported += 1
|
|
|
|
db.session.commit()
|
|
|
|
for account_id in affected_accounts:
|
|
calc_balance(account_id)
|
|
|
|
return imported, skipped
|