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, DecimalField, DateField, SelectField, IntegerField, TextAreaField, SubmitField) from wtforms.validators import DataRequired, Optional, NumberRange, Length from sqlalchemy.exc import IntegrityError from datetime import date, timedelta import logging from app.extensions import db from app.models.utility import (UtilityProvider, UtilityBill, UTILITY_TYPE_META, UTILITY_TYPES) from app.models.account import Account from app.models.category import Category from app.models.transaction import Transaction from app.services.account_service import calc_balance from app.services import utility_service utilities_bp = Blueprint('utilities', __name__, url_prefix='/utilities') log = logging.getLogger('app.utilities') UTILITY_COLORS = ['#f59e0b', '#0ea5e9', '#ef4444', '#6366f1', '#8b5cf6', '#10b981', '#ec4899', '#64748b'] UTILITY_ICONS = [ 'bi-lightning-charge', 'bi-droplet', 'bi-fire', 'bi-wifi', 'bi-phone', 'bi-trash3', 'bi-plug', 'bi-thermometer-half', 'bi-tv', 'bi-router', ] def _account_choices(): accts = Account.query.filter_by(is_active=True).order_by(Account.name).all() return [('', '— None —')] + [(str(a.id), a.name) for a in accts] def _category_choices(): cats = (Category.query .filter(Category.is_active == True, Category.category_type.in_(['expense', 'both'])) .order_by(Category.name).all()) return [('', '— None —')] + [(str(c.id), c.name) for c in cats] def _provider_choices(include_inactive=False): q = UtilityProvider.query if not include_inactive: q = q.filter_by(is_active=True) provs = q.order_by(UtilityProvider.name).all() return [(str(p.id), f'{p.name} · {p.type_label}') for p in provs] def _type_defaults(): """Per-type unit/icon/color suggestions for the provider form's JS.""" return {k: {'unit': v[3], 'icon': v[1], 'color': v[2]} for k, v in UTILITY_TYPE_META.items()} def _provider_units(): """provider id -> usage unit, so the bill form can label consumption fields.""" return {str(p.id): (p.usage_unit or '') for p in UtilityProvider.query.all()} def _safe_int(value, default=None): try: return int(value) except (TypeError, ValueError): return default # ── forms ──────────────────────────────────────────────────────────────────── class ProviderForm(FlaskForm): name = StringField('Provider Name', validators=[DataRequired(), Length(1, 100)]) utility_type = SelectField('Utility Type', validators=[DataRequired()], choices=[(k, v[0]) for k, v in UTILITY_TYPE_META.items()]) account_number = StringField('Account Number', validators=[Optional(), Length(max=100)]) usage_unit = StringField('Usage Unit', validators=[Optional(), Length(max=20)]) default_account_id = SelectField('Pay From Account', validators=[Optional()]) category_id = SelectField('Expense Category', validators=[Optional()]) billing_day = IntegerField('Billing Day', validators=[Optional(), NumberRange(min=1, max=31)]) color = StringField('Color', default='#8b5cf6') icon = StringField('Icon', default='bi-lightning-charge') notes = TextAreaField('Notes', validators=[Optional()]) submit = SubmitField('Save Provider') class BillForm(FlaskForm): provider_id = SelectField('Provider', validators=[DataRequired()]) period_start = DateField('Period Start', validators=[DataRequired()]) period_end = DateField('Period End', validators=[DataRequired()]) amount = DecimalField('Amount', validators=[DataRequired(), NumberRange(min=0)], places=2) due_date = DateField('Due Date', validators=[Optional()]) usage = DecimalField('Usage', validators=[Optional(), NumberRange(min=0)], places=3) meter_start = DecimalField('Meter Start', validators=[Optional(), NumberRange(min=0)], places=3) meter_end = DecimalField('Meter End', validators=[Optional(), NumberRange(min=0)], places=3) notes = TextAreaField('Notes', validators=[Optional()]) submit = SubmitField('Save Bill') def validate(self, extra_validators=None): if not super().validate(extra_validators): return False ok = True if self.period_end.data and self.period_start.data and \ self.period_end.data < self.period_start.data: self.period_end.errors.append('Period end must be on or after period start.') ok = False if self.meter_start.data is not None and self.meter_end.data is not None and \ self.meter_end.data < self.meter_start.data: self.meter_end.errors.append('Meter end reading is lower than the start reading.') ok = False return ok class PayForm(FlaskForm): account_id = SelectField('Pay From Account', validators=[DataRequired()]) category_id = SelectField('Category', validators=[Optional()]) paid_date = DateField('Payment Date', validators=[DataRequired()], default=date.today) submit = SubmitField('Mark Paid') # ── index ──────────────────────────────────────────────────────────────────── @utilities_bp.route('/') @login_required def index(): providers = (UtilityProvider.query .filter_by(is_active=True) .order_by(UtilityProvider.utility_type, UtilityProvider.name) .all()) summary = utility_service.dashboard_summary() summaries = {p.id: utility_service.provider_summary(p) for p in providers} return render_template('utilities/index.html', providers=providers, summaries=summaries, summary=summary, chart=utility_service.monthly_series(12), type_totals=utility_service.type_totals(12), type_meta=UTILITY_TYPE_META) # ── providers ──────────────────────────────────────────────────────────────── @utilities_bp.route('/providers') @login_required def providers(): provs = (UtilityProvider.query .order_by(UtilityProvider.is_active.desc(), UtilityProvider.utility_type, UtilityProvider.name) .all()) counts = {p.id: p.bills.count() for p in provs} return render_template('utilities/providers.html', providers=provs, counts=counts) @utilities_bp.route('/providers/new', methods=['GET', 'POST']) @login_required def provider_new(): form = ProviderForm() form.default_account_id.choices = _account_choices() form.category_id.choices = _category_choices() if request.method == 'GET': # Default to the seeded "Utilities" expense category when it exists util_cat = Category.query.filter(Category.name == 'Utilities').first() if util_cat: form.category_id.data = str(util_cat.id) if form.validate_on_submit(): meta = UTILITY_TYPE_META.get(form.utility_type.data, UTILITY_TYPE_META['other']) prov = UtilityProvider( name=form.name.data.strip(), utility_type=form.utility_type.data, account_number=(form.account_number.data or '').strip() or None, usage_unit=(form.usage_unit.data or '').strip() or None, default_account_id=_safe_int(form.default_account_id.data), category_id=_safe_int(form.category_id.data), billing_day=form.billing_day.data, color=form.color.data or meta[2], icon=form.icon.data or meta[1], notes=form.notes.data, ) db.session.add(prov) db.session.commit() flash(f'Provider "{prov.name}" added.', 'success') return redirect(url_for('utilities.provider_detail', id=prov.id)) return render_template('utilities/provider_form.html', form=form, title='New Provider', colors=UTILITY_COLORS, icons=UTILITY_ICONS, type_defaults=_type_defaults()) @utilities_bp.route('/providers//edit', methods=['GET', 'POST']) @login_required def provider_edit(id): prov = db.get_or_404(UtilityProvider, id) form = ProviderForm(obj=prov) form.default_account_id.choices = _account_choices() form.category_id.choices = _category_choices() if request.method == 'GET': form.default_account_id.data = str(prov.default_account_id) if prov.default_account_id else '' form.category_id.data = str(prov.category_id) if prov.category_id else '' if form.validate_on_submit(): prov.name = form.name.data.strip() prov.utility_type = form.utility_type.data prov.account_number = (form.account_number.data or '').strip() or None prov.usage_unit = (form.usage_unit.data or '').strip() or None prov.default_account_id = _safe_int(form.default_account_id.data) prov.category_id = _safe_int(form.category_id.data) prov.billing_day = form.billing_day.data prov.color = form.color.data or prov.color prov.icon = form.icon.data or prov.icon prov.notes = form.notes.data db.session.commit() flash('Provider updated.', 'success') return redirect(url_for('utilities.provider_detail', id=prov.id)) return render_template('utilities/provider_form.html', form=form, title='Edit Provider', provider=prov, colors=UTILITY_COLORS, icons=UTILITY_ICONS, type_defaults=_type_defaults()) @utilities_bp.route('/providers//toggle', methods=['POST']) @login_required def provider_toggle(id): prov = db.get_or_404(UtilityProvider, id) prov.is_active = not prov.is_active db.session.commit() flash(f'Provider "{prov.name}" {"reactivated" if prov.is_active else "archived"}.', 'info') return redirect(url_for('utilities.providers')) @utilities_bp.route('/providers//delete', methods=['POST']) @login_required def provider_delete(id): prov = db.get_or_404(UtilityProvider, id) count = prov.bills.count() if count and request.form.get('confirm_bills') != 'yes': flash(f'"{prov.name}" still has {count} bill(s). Archive it instead, ' f'or confirm deletion from the provider page.', 'warning') return redirect(url_for('utilities.providers')) name = prov.name db.session.delete(prov) # cascades to its bills db.session.commit() flash(f'Provider "{name}" and {count} bill(s) deleted.', 'info') return redirect(url_for('utilities.providers')) @utilities_bp.route('/providers/') @login_required def provider_detail(id): prov = db.get_or_404(UtilityProvider, id) page = _safe_int(request.args.get('page'), 1) or 1 pagination = (prov.bills .order_by(UtilityBill.period_start.desc()) .paginate(page=page, per_page=24, error_out=False)) return render_template('utilities/detail.html', provider=prov, bills=pagination.items, pagination=pagination, stats=utility_service.provider_summary(prov), series=utility_service.usage_series(prov, 24)) # ── bills ──────────────────────────────────────────────────────────────────── @utilities_bp.route('/bills') @login_required def bills(): page = _safe_int(request.args.get('page'), 1) or 1 provider_id = _safe_int(request.args.get('provider_id')) utility_type = request.args.get('utility_type') or '' status = request.args.get('status') or '' year = _safe_int(request.args.get('year')) q = UtilityBill.query.join(UtilityProvider) if provider_id: q = q.filter(UtilityBill.provider_id == provider_id) if utility_type in UTILITY_TYPES: q = q.filter(UtilityProvider.utility_type == utility_type) if year: q = q.filter(UtilityBill.period_start >= date(year, 1, 1), UtilityBill.period_start <= date(year, 12, 31)) if status == 'paid': q = q.filter(UtilityBill.is_paid == True) elif status == 'unpaid': q = q.filter(UtilityBill.is_paid == False) elif status == 'overdue': q = q.filter(UtilityBill.is_paid == False, UtilityBill.due_date.isnot(None), UtilityBill.due_date < date.today()) pagination = (q.order_by(UtilityBill.period_start.desc(), UtilityBill.id.desc()) .paginate(page=page, per_page=30, error_out=False)) years = sorted({row[0].year for row in db.session.query(UtilityBill.period_start).all()}, reverse=True) return render_template('utilities/bills.html', bills=pagination.items, pagination=pagination, providers=UtilityProvider.query.order_by(UtilityProvider.name).all(), type_meta=UTILITY_TYPE_META, provider_id=provider_id, utility_type=utility_type, status=status, year=year, years=years) def _apply_bill_form(bill, form): bill.provider_id = int(form.provider_id.data) bill.period_start = form.period_start.data bill.period_end = form.period_end.data bill.amount = form.amount.data bill.due_date = form.due_date.data bill.meter_start = form.meter_start.data bill.meter_end = form.meter_end.data bill.usage = form.usage.data bill.notes = form.notes.data provider = db.session.get(UtilityProvider, bill.provider_id) bill.usage_unit = provider.usage_unit if provider else None # Meter readings win over a typed usage figure bill.sync_usage_from_meter() @utilities_bp.route('/bills/new', methods=['GET', 'POST']) @login_required def bill_new(): form = BillForm() form.provider_id.choices = _provider_choices() if not form.provider_id.choices: flash('Add a utility provider before recording bills.', 'warning') return redirect(url_for('utilities.provider_new')) preset = _safe_int(request.args.get('provider_id')) if request.method == 'GET': if preset: form.provider_id.data = str(preset) # Default to last month's billing period today = date.today() first_this = today.replace(day=1) form.period_end.data = first_this - timedelta(days=1) form.period_start.data = form.period_end.data.replace(day=1) if form.validate_on_submit(): bill = UtilityBill() _apply_bill_form(bill, form) db.session.add(bill) try: db.session.commit() except IntegrityError: db.session.rollback() flash('A bill for that provider and period start already exists.', 'danger') return render_template('utilities/bill_form.html', form=form, title='New Bill', provider_units=_provider_units()) flash('Bill recorded.', 'success') if request.form.get('pay_now') == 'yes': return redirect(url_for('utilities.bill_pay', id=bill.id)) return redirect(url_for('utilities.provider_detail', id=bill.provider_id)) return render_template('utilities/bill_form.html', form=form, title='New Bill', provider_units=_provider_units()) @utilities_bp.route('/bills//edit', methods=['GET', 'POST']) @login_required def bill_edit(id): bill = db.get_or_404(UtilityBill, id) form = BillForm(obj=bill) form.provider_id.choices = _provider_choices(include_inactive=True) if request.method == 'GET': form.provider_id.data = str(bill.provider_id) if form.validate_on_submit(): _apply_bill_form(bill, form) try: db.session.commit() except IntegrityError: db.session.rollback() flash('A bill for that provider and period start already exists.', 'danger') return render_template('utilities/bill_form.html', form=form, title='Edit Bill', bill=bill, provider_units=_provider_units()) flash('Bill updated.', 'success') return redirect(url_for('utilities.provider_detail', id=bill.provider_id)) return render_template('utilities/bill_form.html', form=form, title='Edit Bill', bill=bill, provider_units=_provider_units()) @utilities_bp.route('/bills//delete', methods=['POST']) @login_required def bill_delete(id): bill = db.get_or_404(UtilityBill, id) provider_id = bill.provider_id # Only remove the payment transaction if this feature created it txn = bill.transaction drop_txn = utility_service.is_generated_payment(bill) account_id = txn.account_id if txn else None db.session.delete(bill) if drop_txn: db.session.delete(txn) db.session.commit() if drop_txn and account_id: calc_balance(account_id) flash('Bill and its payment transaction deleted.', 'info') else: flash('Bill deleted.', 'info') return redirect(url_for('utilities.provider_detail', id=provider_id)) # ── payment ────────────────────────────────────────────────────────────────── @utilities_bp.route('/bills//pay', methods=['GET', 'POST']) @login_required def bill_pay(id): bill = db.get_or_404(UtilityBill, id) if bill.is_paid: flash('That bill is already marked paid.', 'info') return redirect(url_for('utilities.provider_detail', id=bill.provider_id)) form = PayForm() form.account_id.choices = [c for c in _account_choices() if c[0]] form.category_id.choices = _category_choices() if not form.account_id.choices: flash('Create an active account before paying bills.', 'warning') return redirect(url_for('accounts.index')) if request.method == 'GET': prov = bill.provider if prov.default_account_id: form.account_id.data = str(prov.default_account_id) if prov.category_id: form.category_id.data = str(prov.category_id) form.paid_date.data = bill.due_date or date.today() if form.validate_on_submit(): txn = utility_service.build_payment_transaction( bill, account_id=int(form.account_id.data), paid_date=form.paid_date.data, category_id=_safe_int(form.category_id.data), ) db.session.flush() # need txn.id before linking bill.is_paid = True bill.paid_date = form.paid_date.data bill.transaction_id = txn.id db.session.commit() calc_balance(txn.account_id) log.info('[utilities] bill %s marked paid — txn %s', bill.id, txn.id) from app.services.alert_service import check_and_flash_budget_alerts check_and_flash_budget_alerts(flash) flash(f'Bill paid — expense of {bill.amount} recorded.', 'success') return redirect(url_for('utilities.provider_detail', id=bill.provider_id)) return render_template('utilities/pay.html', form=form, bill=bill) @utilities_bp.route('/bills//link', methods=['GET', 'POST']) @login_required def bill_link(id): bill = db.get_or_404(UtilityBill, id) if request.method == 'POST': txn_id = _safe_int(request.form.get('transaction_id')) txn = db.session.get(Transaction, txn_id) if txn_id else None if not txn: flash('Select a transaction to link.', 'warning') return redirect(url_for('utilities.bill_link', id=bill.id)) clash = UtilityBill.query.filter(UtilityBill.transaction_id == txn.id, UtilityBill.id != bill.id).first() if clash: flash('That transaction is already linked to another bill.', 'danger') return redirect(url_for('utilities.bill_link', id=bill.id)) bill.transaction_id = txn.id bill.is_paid = True bill.paid_date = txn.date db.session.commit() flash('Payment linked.', 'success') return redirect(url_for('utilities.provider_detail', id=bill.provider_id)) return render_template('utilities/link.html', bill=bill, candidates=utility_service.candidate_transactions(bill)) @utilities_bp.route('/bills//unpay', methods=['POST']) @login_required def bill_unpay(id): bill = db.get_or_404(UtilityBill, id) txn = bill.transaction drop_txn = utility_service.is_generated_payment(bill) account_id = txn.account_id if txn else None bill.is_paid = False bill.paid_date = None bill.transaction_id = None if drop_txn: db.session.delete(txn) db.session.commit() if drop_txn and account_id: calc_balance(account_id) flash('Bill reopened — its payment transaction was removed.', 'info') else: flash('Bill reopened — the linked transaction was left in place.', 'info') return redirect(url_for('utilities.provider_detail', id=bill.provider_id)) # ── api ────────────────────────────────────────────────────────────────────── @utilities_bp.route('/api/usage/') @login_required def api_usage(provider_id): prov = db.get_or_404(UtilityProvider, provider_id) months = _safe_int(request.args.get('months'), 24) or 24 return jsonify(utility_service.usage_series(prov, min(max(months, 3), 60)))