Files
Personal-Finance-Management/app/routes/accounts.py
T

258 lines
9.5 KiB
Python

from datetime import date
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
from wtforms.validators import DataRequired, Length, Optional, NumberRange
from sqlalchemy import func, extract
from app.extensions import db
from app.models.account import Account
from app.models.transaction import Transaction
from app.services.account_service import calc_balance
accounts_bp = Blueprint('accounts', __name__, url_prefix='/accounts')
ACCOUNT_TYPES = [
('checking', 'Checking'),
('savings', 'Savings'),
('cash', 'Cash'),
('credit_card', 'Credit Card'),
('crypto', 'Crypto Wallet'),
('investment', 'Investment'),
('other', 'Other'),
]
ACCOUNT_ICONS = [
('bi-bank', 'Bank'),
('bi-wallet2', 'Wallet'),
('bi-cash-stack', 'Cash'),
('bi-credit-card', 'Credit Card'),
('bi-currency-bitcoin', 'Crypto'),
('bi-graph-up', 'Investment'),
('bi-safe', 'Safe'),
]
ACCOUNT_COLORS = [
'#4F81C7', '#10B981', '#F59E0B', '#EF4444',
'#8B5CF6', '#EC4899', '#06B6D4', '#64748B',
]
class AccountForm(FlaskForm):
name = StringField('Account Name', validators=[DataRequired(), Length(1, 100)])
account_type = SelectField('Type', choices=ACCOUNT_TYPES, validators=[DataRequired()])
color = StringField('Color', default='#4F81C7')
icon = StringField('Icon', default='bi-bank')
notes = TextAreaField('Notes', validators=[Optional(), Length(max=500)])
opening_balance = DecimalField('Opening Balance', places=2, default=0,
validators=[Optional()])
submit = SubmitField('Save')
BANK_TYPES = {'checking', 'savings', 'cash', 'investment', 'crypto', 'other'}
@accounts_bp.route('/')
@login_required
def index():
tab = request.args.get('tab', 'bank')
if tab not in ('bank', 'credit'):
tab = 'bank'
from app.models.teller_enrollment import TellerAccount
all_accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all()
# Build Teller map first so we can skip calc_balance for Teller-linked accounts.
# Their balance comes from Teller (live refresh or transaction sync) and must not
# be overwritten by the transaction-computed sum on every page load.
teller_accounts = TellerAccount.query.filter(
TellerAccount.pfm_account_id.in_([a.id for a in all_accounts]),
TellerAccount.is_active == True,
).all()
teller_map = {ta.pfm_account_id: ta for ta in teller_accounts}
for a in all_accounts:
if a.id not in teller_map:
calc_balance(a.id)
all_accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all()
bank_accounts = [a for a in all_accounts if a.account_type in BANK_TYPES]
credit_accounts = [a for a in all_accounts if a.account_type == 'credit_card']
accounts = credit_accounts if tab == 'credit' else bank_accounts
# Monthly charges per credit card (current month expenses)
today = date.today()
monthly_charges = {}
if credit_accounts:
rows = db.session.query(
Transaction.account_id,
func.sum(Transaction.amount)
).filter(
Transaction.transaction_type == 'expense',
extract('year', Transaction.date) == today.year,
extract('month', Transaction.date) == today.month,
Transaction.account_id.in_([a.id for a in credit_accounts])
).group_by(Transaction.account_id).all()
monthly_charges = {row[0]: float(row[1]) for row in rows}
return render_template('accounts/index.html',
accounts=accounts,
tab=tab,
bank_count=len(bank_accounts),
credit_count=len(credit_accounts),
monthly_charges=monthly_charges,
teller_map=teller_map)
@accounts_bp.route('/new', methods=['GET', 'POST'])
@login_required
def new():
form = AccountForm()
if form.validate_on_submit():
account = Account(
name=form.name.data.strip(),
account_type=form.account_type.data,
color=form.color.data or '#4F81C7',
icon=form.icon.data or 'bi-bank',
notes=form.notes.data,
balance=0,
)
db.session.add(account)
db.session.flush() # get account.id before creating transaction
opening = float(form.opening_balance.data or 0)
if opening != 0:
from app.models.transaction import Transaction
txn_type = 'income' if opening > 0 else 'expense'
db.session.add(Transaction(
account_id=account.id,
transaction_type=txn_type,
amount=abs(opening),
description='Opening Balance',
date=date.today(),
notes='Initial account balance',
))
db.session.commit()
calc_balance(account.id)
flash(f'Account "{account.name}" created.', 'success')
return redirect(url_for('accounts.index'))
return render_template('accounts/form.html', form=form, title='New Account',
colors=ACCOUNT_COLORS, icons=ACCOUNT_ICONS)
@accounts_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
account = db.get_or_404(Account, id)
form = AccountForm(obj=account)
if form.validate_on_submit():
account.name = form.name.data.strip()
account.account_type = form.account_type.data
account.color = form.color.data or account.color
account.icon = form.icon.data or account.icon
account.notes = form.notes.data
db.session.commit()
flash(f'Account "{account.name}" updated.', 'success')
return redirect(url_for('accounts.index'))
return render_template('accounts/form.html', form=form, title='Edit Account',
account=account, colors=ACCOUNT_COLORS, icons=ACCOUNT_ICONS)
@accounts_bp.route('/<int:id>/delete', methods=['POST'])
@login_required
def delete(id):
account = db.get_or_404(Account, id)
# Soft delete
account.is_active = False
db.session.commit()
flash(f'Account "{account.name}" removed.', 'info')
return redirect(url_for('accounts.index'))
@accounts_bp.route('/<int:id>/payments')
@login_required
def payments(id):
account = db.get_or_404(Account, id)
page = request.args.get('page', 1, type=int)
pagination = Transaction.query.filter(
Transaction.transaction_type == 'transfer',
Transaction.to_account_id == account.id,
).order_by(Transaction.date.desc(), Transaction.id.desc()).paginate(
page=page, per_page=30, error_out=False
)
# Running total paid this month
today = date.today()
paid_this_month = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == 'transfer',
Transaction.to_account_id == account.id,
extract('year', Transaction.date) == today.year,
extract('month', Transaction.date) == today.month,
).scalar()
return render_template('accounts/payments.html',
account=account,
pagination=pagination,
payments=pagination.items,
paid_this_month=float(paid_this_month))
@accounts_bp.route('/<int:id>/adjust', methods=['GET', 'POST'])
@login_required
def adjust_balance(id):
account = db.get_or_404(Account, id)
calc_balance(account.id)
db.session.refresh(account)
current_balance = float(account.balance)
if request.method == 'POST':
from app.models.transaction import Transaction
from app.models.category import Category
from wtforms import ValidationError
try:
new_balance = float(request.form.get('new_balance', '').replace(',', ''))
except (ValueError, AttributeError):
flash('Invalid amount entered.', 'danger')
return redirect(url_for('accounts.adjust_balance', id=id))
difference = round(new_balance - current_balance, 2)
if difference == 0:
flash('Balance is already at that amount — no adjustment needed.', 'info')
return redirect(url_for('accounts.index',
tab='credit' if account.account_type == 'credit_card' else 'bank'))
txn_type = 'income' if difference > 0 else 'expense'
amount = abs(difference)
notes = request.form.get('notes', '').strip() or 'Manual balance adjustment'
txn = Transaction(
account_id=account.id,
transaction_type=txn_type,
amount=amount,
description='Balance Adjustment',
date=date.today(),
notes=notes,
)
db.session.add(txn)
db.session.commit()
calc_balance(account.id)
flash(
f'Balance adjusted by '
f'{"+" if difference > 0 else ""}{difference:,.2f}. '
f'New balance: {new_balance:,.2f}.',
'success'
)
return redirect(url_for('accounts.index',
tab='credit' if account.account_type == 'credit_card' else 'bank'))
return render_template('accounts/adjust.html',
account=account,
current_balance=current_balance)