268 lines
9.9 KiB
Python
268 lines
9.9 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
|
|
from app.models.schwab_connection import SchwabAccount
|
|
all_accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all()
|
|
pfm_ids = [a.id for a in all_accounts]
|
|
|
|
# Build sync-provider maps before the calc_balance loop.
|
|
# Accounts linked to Teller or Schwab get their balance from the provider,
|
|
# not from transaction summation, so we skip calc_balance for them.
|
|
teller_accounts = TellerAccount.query.filter(
|
|
TellerAccount.pfm_account_id.in_(pfm_ids),
|
|
TellerAccount.is_active == True,
|
|
).all()
|
|
teller_map = {ta.pfm_account_id: ta for ta in teller_accounts}
|
|
|
|
schwab_accounts = SchwabAccount.query.filter(
|
|
SchwabAccount.pfm_account_id.in_(pfm_ids),
|
|
SchwabAccount.is_active == True,
|
|
).all()
|
|
schwab_map = {sa.pfm_account_id: sa for sa in schwab_accounts}
|
|
|
|
provider_ids = set(teller_map) | set(schwab_map)
|
|
for a in all_accounts:
|
|
if a.id not in provider_ids:
|
|
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,
|
|
schwab_map=schwab_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)
|