Files
Personal-Finance-Management/app/routes/transactions.py
T
2026-06-05 15:23:23 -04:00

486 lines
18 KiB
Python

from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
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')
@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 = 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
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,
})