330 lines
12 KiB
Python
330 lines
12 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]
|
|
|
|
|
|
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', '')
|
|
|
|
query = Transaction.query.filter(
|
|
Transaction.transaction_type == tab
|
|
).order_by(Transaction.date.desc(), Transaction.id.desc())
|
|
|
|
if search:
|
|
query = query.filter(Transaction.description.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
|
|
|
|
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)
|
|
|
|
|
|
@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()
|
|
form.transaction_type.data = txn_type
|
|
form.account_id.choices = _account_choices()
|
|
form.category_id.choices = _category_choices(txn_type)
|
|
|
|
if form.validate_on_submit():
|
|
txn = Transaction(
|
|
transaction_type=form.transaction_type.data,
|
|
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 txn_type == "income" else "Expense"} added.', 'success')
|
|
return redirect(url_for('transactions.index', tab=txn_type))
|
|
|
|
return render_template('transactions/form.html',
|
|
form=form,
|
|
txn_type=txn_type,
|
|
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)
|
|
form = TransactionForm(obj=txn)
|
|
form.transaction_type.data = txn.transaction_type
|
|
form.account_id.choices = _account_choices()
|
|
form.category_id.choices = _category_choices(txn.transaction_type)
|
|
|
|
# Pre-populate foreign keys as strings for SelectField
|
|
if request.method == 'GET':
|
|
form.account_id.data = str(txn.account_id)
|
|
form.category_id.data = str(txn.category_id) if txn.category_id else ''
|
|
|
|
if form.validate_on_submit():
|
|
old_account_id = txn.account_id
|
|
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')
|
|
return redirect(url_for('transactions.index', tab=txn.transaction_type))
|
|
|
|
return render_template('transactions/form.html',
|
|
form=form,
|
|
txn=txn,
|
|
txn_type=txn.transaction_type,
|
|
title='Edit Transaction')
|
|
|
|
|
|
@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 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
|
|
|
|
mime_map = {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
'.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp'}
|
|
mime_type = mime_map.get(ext, 'image/jpeg')
|
|
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
|
|
|
|
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'])
|
|
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,
|
|
})
|