Files
Personal-Finance-Management/app/routes/transactions.py
T
2026-06-05 17:50:46 -04:00

646 lines
24 KiB
Python

from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, Response, stream_with_context
from flask_login import login_required
from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, TextAreaField, SubmitField, DecimalField, DateField, HiddenField
from wtforms.validators import DataRequired, Optional, NumberRange
from app.extensions import db
from app.models.transaction import Transaction
from app.models.account import Account
from app.models.category import Category
from app.services.account_service import calc_balance
from datetime import date, datetime
import os
from sqlalchemy import or_
transactions_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
def _account_choices():
return [(str(a.id), a.name)
for a in Account.query.filter_by(is_active=True).order_by(Account.name).all()]
def _category_choices(cat_type):
cats = Category.query.filter(
Category.category_type.in_([cat_type, 'both']),
Category.is_active == True,
Category.parent_id == None
).order_by(Category.name).all()
return [('', '— None —')] + [(str(c.id), c.name) for c in cats]
def _category_choices_json(cat_type):
cats = Category.query.filter(
Category.category_type.in_([cat_type, 'both']),
Category.is_active == True,
Category.parent_id == None
).order_by(Category.name).all()
return [{'id': str(c.id), 'name': c.name} for c in cats]
class TransactionForm(FlaskForm):
transaction_type = HiddenField(default='expense')
account_id = SelectField('Account', validators=[DataRequired()])
category_id = SelectField('Category', validators=[Optional()])
amount = DecimalField('Amount', validators=[DataRequired(), NumberRange(min=0.01)],
places=2)
description = StringField('Description', validators=[DataRequired()])
date = DateField('Date', validators=[DataRequired()], default=date.today)
notes = TextAreaField('Notes', validators=[Optional()])
submit = SubmitField('Save')
class TransferForm(FlaskForm):
from_account_id = SelectField('From Account', validators=[DataRequired()])
to_account_id = SelectField('To Account', validators=[DataRequired()])
amount = DecimalField('Amount', validators=[DataRequired(), NumberRange(min=0.01)],
places=2)
description = StringField('Description', default='Transfer')
date = DateField('Date', validators=[DataRequired()], default=date.today)
notes = TextAreaField('Notes', validators=[Optional()])
submit = SubmitField('Transfer')
def _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min='', amount_max=''):
query = Transaction.query.filter(
Transaction.transaction_type == tab
).order_by(Transaction.date.desc(), Transaction.id.desc())
if search:
query = query.filter(
or_(
Transaction.description.ilike(f'%{search}%'),
Transaction.notes.ilike(f'%{search}%'),
)
)
try:
if category_id:
query = query.filter(Transaction.category_id == int(category_id))
if account_id:
query = query.filter(Transaction.account_id == int(account_id))
except (ValueError, TypeError):
pass
if date_from:
try:
query = query.filter(Transaction.date >= datetime.strptime(date_from, '%Y-%m-%d').date())
except ValueError:
pass
if date_to:
try:
query = query.filter(Transaction.date <= datetime.strptime(date_to, '%Y-%m-%d').date())
except ValueError:
pass
try:
if amount_min:
query = query.filter(Transaction.amount >= float(amount_min))
if amount_max:
query = query.filter(Transaction.amount <= float(amount_max))
except (ValueError, TypeError):
pass
return query
@transactions_bp.route('/')
@login_required
def index():
tab = request.args.get('tab', 'expense') # 'income' | 'expense'
page = request.args.get('page', 1, type=int)
search = request.args.get('q', '').strip()
category_id = request.args.get('category_id', '', type=str)
account_id = request.args.get('account_id', '', type=str)
date_from = request.args.get('date_from', '')
date_to = request.args.get('date_to', '')
amount_min = request.args.get('amount_min', '')
amount_max = request.args.get('amount_max', '')
from datetime import timedelta
today = date.today()
this_month_from = today.replace(day=1).strftime('%Y-%m-%d')
this_month_to = today.strftime('%Y-%m-%d')
last_month_last = today.replace(day=1) - timedelta(days=1)
last_month_first = last_month_last.replace(day=1)
last_month_from = last_month_first.strftime('%Y-%m-%d')
last_month_to = last_month_last.strftime('%Y-%m-%d')
if date_from == this_month_from and date_to == this_month_to:
active_quick = 'this_month'
elif date_from == last_month_from and date_to == last_month_to:
active_quick = 'last_month'
else:
active_quick = ''
query = _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min, amount_max)
pagination = query.paginate(page=page, per_page=30, error_out=False)
# Counts for tabs
income_count = Transaction.query.filter_by(transaction_type='income').count()
expense_count = Transaction.query.filter_by(transaction_type='expense').count()
accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all()
categories = Category.query.filter(
Category.category_type.in_([tab, 'both']),
Category.is_active == True
).order_by(Category.name).all()
return render_template('transactions/index.html',
pagination=pagination,
transactions=pagination.items,
tab=tab,
income_count=income_count,
expense_count=expense_count,
accounts=accounts,
categories=categories,
search=search,
category_id=category_id,
account_id=account_id,
date_from=date_from,
date_to=date_to,
amount_min=amount_min,
amount_max=amount_max,
active_quick=active_quick,
this_month_from=this_month_from,
this_month_to=this_month_to,
last_month_from=last_month_from,
last_month_to=last_month_to)
@transactions_bp.route('/new', methods=['GET', 'POST'])
@login_required
def new():
txn_type = request.args.get('type', 'expense')
if txn_type not in ('income', 'expense'):
txn_type = 'expense'
form = TransactionForm()
if request.method == 'GET':
form.transaction_type.data = txn_type
elif request.method == 'POST':
submitted = request.form.get('transaction_type', txn_type)
if submitted in ('income', 'expense'):
txn_type = submitted
form.account_id.choices = _account_choices()
form.category_id.choices = _category_choices(txn_type)
if form.validate_on_submit():
saved_type = form.transaction_type.data
txn = Transaction(
transaction_type=saved_type,
account_id=int(form.account_id.data),
category_id=int(form.category_id.data) if form.category_id.data else None,
amount=form.amount.data,
description=form.description.data.strip(),
date=form.date.data,
notes=form.notes.data,
)
db.session.add(txn)
db.session.commit()
calc_balance(txn.account_id)
flash(f'{"Income" if saved_type == "income" else "Expense"} added.', 'success')
if saved_type == 'expense':
from app.services.alert_service import check_and_flash_budget_alerts
check_and_flash_budget_alerts(flash)
return redirect(url_for('transactions.index', tab=saved_type))
return render_template('transactions/form.html',
form=form,
txn_type=txn_type,
income_cats=_category_choices_json('income'),
expense_cats=_category_choices_json('expense'),
title=f'New {"Income" if txn_type == "income" else "Expense"}')
@transactions_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
txn = db.get_or_404(Transaction, id)
# Determine active type: from POST toggle or existing record
if request.method == 'POST':
submitted_type = request.form.get('transaction_type', txn.transaction_type)
active_type = submitted_type if submitted_type in ('income', 'expense') else txn.transaction_type
else:
active_type = txn.transaction_type
form = TransactionForm(obj=txn)
form.account_id.choices = _account_choices()
form.category_id.choices = _category_choices(active_type)
if request.method == 'GET':
form.transaction_type.data = txn.transaction_type
form.account_id.data = str(txn.account_id)
form.category_id.data = str(txn.category_id) if txn.category_id else ''
next_url = request.form.get('next') or request.args.get('next', '')
# Only allow relative URLs to prevent open-redirect
if not next_url.startswith('/'):
next_url = ''
if form.validate_on_submit():
old_account_id = txn.account_id
txn.transaction_type = active_type
txn.account_id = int(form.account_id.data)
txn.category_id = int(form.category_id.data) if form.category_id.data else None
txn.amount = form.amount.data
txn.description = form.description.data.strip()
txn.date = form.date.data
txn.notes = form.notes.data
db.session.commit()
calc_balance(old_account_id)
calc_balance(txn.account_id)
flash('Transaction updated.', 'success')
if txn.transaction_type == 'expense':
from app.services.alert_service import check_and_flash_budget_alerts
check_and_flash_budget_alerts(flash)
return redirect(next_url or url_for('transactions.index', tab=txn.transaction_type))
return render_template('transactions/form.html',
form=form,
txn=txn,
txn_type=active_type,
next_url=next_url,
income_cats=_category_choices_json('income'),
expense_cats=_category_choices_json('expense'),
title='Edit Transaction')
@transactions_bp.route('/bulk-action', methods=['POST'])
@login_required
def bulk_action():
"""AJAX endpoint: delete or set-category for a list of transaction IDs."""
data = request.get_json(silent=True) or {}
ids = data.get('ids', [])
action = data.get('action', '')
if not ids or not isinstance(ids, list):
return jsonify({'ok': False, 'error': 'No IDs provided'}), 400
# Validate all IDs are integers
try:
ids = [int(i) for i in ids]
except (ValueError, TypeError):
return jsonify({'ok': False, 'error': 'Invalid IDs'}), 400
txns = Transaction.query.filter(Transaction.id.in_(ids)).all()
if not txns:
return jsonify({'ok': False, 'error': 'No transactions found'}), 404
if action == 'delete':
affected_accounts = {t.account_id for t in txns}
for txn in txns:
db.session.delete(txn)
db.session.commit()
for acct_id in affected_accounts:
if acct_id:
calc_balance(acct_id)
return jsonify({'ok': True, 'deleted': len(txns)})
elif action == 'set_category':
cat_raw = data.get('category_id')
cat_id = int(cat_raw) if cat_raw else None
# Validate category exists
if cat_id and not Category.query.get(cat_id):
return jsonify({'ok': False, 'error': 'Invalid category'}), 400
for txn in txns:
txn.category_id = cat_id
db.session.commit()
return jsonify({'ok': True, 'updated': len(txns)})
return jsonify({'ok': False, 'error': 'Unknown action'}), 400
@transactions_bp.route('/<int:id>/set-category', methods=['POST'])
@login_required
def set_category(id):
"""AJAX endpoint: update only the category of a transaction."""
txn = db.get_or_404(Transaction, id)
data = request.get_json(silent=True) or {}
raw = data.get('category_id')
txn.category_id = int(raw) if raw else None
db.session.commit()
return jsonify({'ok': True, 'category_id': txn.category_id})
@transactions_bp.route('/<int:id>/delete', methods=['POST'])
@login_required
def delete(id):
txn = db.get_or_404(Transaction, id)
account_id = txn.account_id
txn_type = txn.transaction_type
db.session.delete(txn)
db.session.commit()
calc_balance(account_id)
flash('Transaction deleted.', 'info')
return redirect(url_for('transactions.index', tab=txn_type))
@transactions_bp.route('/transfer', methods=['GET', 'POST'])
@login_required
def transfer():
form = TransferForm()
form.from_account_id.choices = _account_choices()
form.to_account_id.choices = _account_choices()
if request.method == 'GET':
prefill_to = request.args.get('to_account_id', '')
prefill_desc = request.args.get('description', '')
if prefill_to:
form.to_account_id.data = prefill_to
if prefill_desc:
form.description.data = prefill_desc
if form.validate_on_submit():
if form.from_account_id.data == form.to_account_id.data:
flash('Source and destination accounts must be different.', 'warning')
else:
txn = Transaction(
transaction_type='transfer',
account_id=int(form.from_account_id.data),
to_account_id=int(form.to_account_id.data),
amount=form.amount.data,
description=form.description.data.strip() or 'Transfer',
date=form.date.data,
notes=form.notes.data,
)
db.session.add(txn)
db.session.commit()
calc_balance(txn.account_id)
calc_balance(txn.to_account_id)
flash('Transfer recorded.', 'success')
return redirect(url_for('transactions.index'))
return render_template('transactions/transfer.html', form=form)
@transactions_bp.route('/ocr', methods=['POST'])
@login_required
def ocr_receipt():
"""
POST a receipt image, get back extracted transaction data as JSON.
Used by both new expense form and edit form.
"""
from app.services.ocr_service import extract_from_bytes
from app.models.category import Category
if 'receipt' not in request.files:
return jsonify({'error': 'No file uploaded'}), 400
f = request.files['receipt']
if not f.filename:
return jsonify({'error': 'Empty filename'}), 400
ext = os.path.splitext(f.filename)[1].lower()
allowed = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
if ext not in allowed:
return jsonify({'error': f'Unsupported type: {ext}. Use JPG, PNG, GIF, WEBP'}), 400
# Read bytes — limit 10MB
f.seek(0, 2)
size = f.tell()
f.seek(0)
if size > 10 * 1024 * 1024:
return jsonify({'error': 'File too large (max 10MB)'}), 400
# Validate actual file content via magic bytes
from app.routes.settings import _check_magic
header = f.read(12)
f.seek(0)
magic_ext, magic_mime = _check_magic(header)
if magic_ext is None or magic_ext not in ('jpg', 'jpeg', 'png', 'gif', 'webp'):
return jsonify({'error': 'File content does not match an allowed image type'}), 400
mime_type = magic_mime
image_bytes = f.read()
result = extract_from_bytes(image_bytes, mime_type)
if result['error']:
return jsonify({'error': result['error']}), 422
# Look up category ID from suggestion
category_id = None
if result['category_suggestion']:
cat = Category.query.filter(
Category.name.ilike(result['category_suggestion']),
Category.is_active == True,
).first()
if cat:
category_id = cat.id
return jsonify({
'amount': result['amount'],
'date': result['date'],
'description': result['merchant'] or result['notes'] or '',
'notes': result['notes'],
'category_suggestion': result['category_suggestion'],
'category_id': category_id,
})
@transactions_bp.route('/ocr-file', methods=['POST'])
@login_required
def ocr_receipt_file():
"""
Re-extract from an already-uploaded receipt file stored on disk.
Body: { "filename": "abc123.jpg" }
"""
from app.services.ocr_service import extract_from_file
from app.models.category import Category
from flask import current_app
from app.models.receipt import Receipt
data = request.get_json()
if not data or not data.get('filename'):
return jsonify({'error': 'No filename provided'}), 400
# Security: only allow basenames, no path traversal
filename = os.path.basename(data['filename'])
# Ownership check: filename must exist in receipts table (single-user, but
# prevents OCR extraction from arbitrary files on disk via a crafted request)
receipt_record = Receipt.query.filter_by(filename=filename).first()
if not receipt_record:
return jsonify({'error': 'Receipt not found'}), 404
upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads')
file_path = os.path.join(upload_dir, filename)
result = extract_from_file(file_path)
if result['error']:
return jsonify({'error': result['error']}), 422
category_id = None
if result['category_suggestion']:
cat = Category.query.filter(
Category.name.ilike(result['category_suggestion']),
Category.is_active == True,
).first()
if cat:
category_id = cat.id
return jsonify({
'amount': result['amount'],
'date': result['date'],
'description': result['merchant'] or result['notes'] or '',
'notes': result['notes'],
'category_suggestion': result['category_suggestion'],
'category_id': category_id,
})
# ── Export filtered transactions ──────────────────────────────────────────────
@transactions_bp.route('/export/csv')
@login_required
def export_csv():
from app.services.export_service import transactions_csv_stream
tab = request.args.get('tab', 'expense')
search = request.args.get('q', '').strip()
category_id = request.args.get('category_id', '')
account_id = request.args.get('account_id', '')
date_from = request.args.get('date_from', '')
date_to = request.args.get('date_to', '')
amount_min = request.args.get('amount_min', '')
amount_max = request.args.get('amount_max', '')
query = _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min, amount_max)
filename = f'transactions_{tab}_{date.today().strftime("%Y%m%d")}.csv'
return Response(
stream_with_context(transactions_csv_stream(query)),
mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
@transactions_bp.route('/export/excel')
@login_required
def export_excel():
from app.services.export_service import transactions_to_excel
tab = request.args.get('tab', 'expense')
search = request.args.get('q', '').strip()
category_id = request.args.get('category_id', '')
account_id = request.args.get('account_id', '')
date_from = request.args.get('date_from', '')
date_to = request.args.get('date_to', '')
amount_min = request.args.get('amount_min', '')
amount_max = request.args.get('amount_max', '')
query = _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min, amount_max)
label = f'{tab.title()} {date_from or "all"}{"-" + date_to if date_to else ""}'[:31]
excel_bytes = transactions_to_excel(query, label)
filename = f'transactions_{tab}_{date.today().strftime("%Y%m%d")}.xlsx'
return Response(
excel_bytes,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
# ── Duplicate detection ───────────────────────────────────────────────────────
@transactions_bp.route('/api/check-duplicate')
@login_required
def check_duplicate():
txn_date = request.args.get('date', '')
amount = request.args.get('amount', '')
txn_type = request.args.get('type', 'expense')
exclude_id = request.args.get('exclude_id', '', type=str)
if not txn_date or not amount:
return jsonify({'duplicates': []})
try:
d = datetime.strptime(txn_date, '%Y-%m-%d').date()
amt = float(amount)
except (ValueError, TypeError):
return jsonify({'duplicates': []})
q = Transaction.query.filter(
Transaction.transaction_type == txn_type,
Transaction.date == d,
Transaction.amount == amt,
)
if exclude_id:
try:
q = q.filter(Transaction.id != int(exclude_id))
except (ValueError, TypeError):
pass
dupes = q.limit(5).all()
return jsonify({'duplicates': [
{'id': t.id, 'description': t.description, 'date': t.date.strftime('%b %d, %Y'),
'account': t.account.name if t.account else ''}
for t in dupes
]})
# ── Split transaction ─────────────────────────────────────────────────────────
@transactions_bp.route('/<int:id>/split', methods=['GET', 'POST'])
@login_required
def split(id):
txn = db.get_or_404(Transaction, id)
all_cats = Category.query.filter(
Category.category_type.in_([txn.transaction_type, 'both']),
Category.is_active == True,
).order_by(Category.name).all()
if request.method == 'POST':
amounts = request.form.getlist('split_amount')
cat_ids = request.form.getlist('split_category')
descs = request.form.getlist('split_description')
parts = []
total_split = 0.0
for amt_str, cid_str, desc_str in zip(amounts, cat_ids, descs):
try:
amt = float(amt_str)
except (ValueError, TypeError):
flash('Invalid amount in split.', 'danger')
return redirect(url_for('transactions.split', id=id))
if amt <= 0:
continue
parts.append({
'amount': amt,
'category_id': int(cid_str) if cid_str else None,
'description': desc_str.strip() or txn.description,
})
total_split += amt
if not parts:
flash('Add at least one split row.', 'danger')
return redirect(url_for('transactions.split', id=id))
if abs(total_split - float(txn.amount)) > 0.005:
flash(f'Split total {total_split:.2f} must equal original {float(txn.amount):.2f}.', 'danger')
return redirect(url_for('transactions.split', id=id))
account_id = txn.account_id
txn_type = txn.transaction_type
txn_date = txn.date
txn_notes = txn.notes
db.session.delete(txn)
db.session.flush()
for part in parts:
new_txn = Transaction(
transaction_type=txn_type,
account_id=account_id,
category_id=part['category_id'],
amount=part['amount'],
description=part['description'],
date=txn_date,
notes=txn_notes,
)
db.session.add(new_txn)
db.session.commit()
calc_balance(account_id)
flash(f'Transaction split into {len(parts)} part(s).', 'success')
return redirect(url_for('transactions.index', tab=txn_type))
return render_template('transactions/split.html', txn=txn, categories=all_cats)