646 lines
24 KiB
Python
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)
|