diff --git a/app/routes/transactions.py b/app/routes/transactions.py index 4ea4a36..5688ca4 100644 --- a/app/routes/transactions.py +++ b/app/routes/transactions.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify +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 @@ -61,39 +61,10 @@ class TransferForm(FlaskForm): 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 = '' - +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_( @@ -125,6 +96,39 @@ def index(): 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) @@ -483,3 +487,159 @@ def ocr_receipt_file(): '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('//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) diff --git a/app/templates/budgets/index.html b/app/templates/budgets/index.html index 8c95e33..5337cae 100644 --- a/app/templates/budgets/index.html +++ b/app/templates/budgets/index.html @@ -58,6 +58,17 @@ {% endif %} + +{% set chart_cats = summary | selectattr('has_budget') | list %} +{% if chart_cats %} +
+
Budget vs Actual
+
+ +
+
+{% endif %} + {% if summary %}
@@ -148,3 +159,74 @@
{% endif %} {% endblock %} + +{% block extra_js %} +{% set chart_cats = summary | selectattr('has_budget') | list %} +{% if chart_cats %} + +{% endif %} +{% endblock %} diff --git a/app/templates/investments/index.html b/app/templates/investments/index.html index b63ca55..878ebbf 100644 --- a/app/templates/investments/index.html +++ b/app/templates/investments/index.html @@ -79,7 +79,7 @@ {% else %} -