Aug 17 - Update with utilities management

This commit is contained in:
2026-08-17 12:53:24 -04:00
parent 790eb9894e
commit ceb1d1395d
17 changed files with 2375 additions and 6 deletions
+14 -2
View File
@@ -116,6 +116,7 @@ def create_app(config_name=None):
from app.routes.plaid import plaid_bp
from app.routes.logs import logs_bp
from app.routes.bank_import import bank_import_bp
from app.routes.utilities import utilities_bp
app.register_blueprint(health_bp)
app.register_blueprint(auth_bp)
@@ -134,13 +135,14 @@ def create_app(config_name=None):
app.register_blueprint(plaid_bp)
app.register_blueprint(logs_bp)
app.register_blueprint(bank_import_bp)
app.register_blueprint(utilities_bp)
with app.app_context():
from app.models import (
User, Account, Category, Receipt, RecurringRule,
Transaction, Budget, Goal, GoalContribution,
Investment, InvestmentTransaction, NetWorthSnapshot,
AiInsight, FxRate
AiInsight, FxRate, UtilityProvider, UtilityBill
)
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
from app.models.schwab_connection import SchwabConnection, SchwabAccount
@@ -152,7 +154,7 @@ def create_app(config_name=None):
@app.context_processor
def inject_globals():
from flask_login import current_user as _u
ctx = {'plaid_review_count': 0}
ctx = {'plaid_review_count': 0, 'utility_due_count': 0}
if _u.is_authenticated:
try:
from app.models.transaction import Transaction as _T
@@ -162,6 +164,16 @@ def create_app(config_name=None):
).count()
except Exception:
pass
try:
from app.models.utility import UtilityBill as _UB, DUE_SOON_DAYS
from datetime import date as _d, timedelta as _td
ctx['utility_due_count'] = _UB.query.filter(
_UB.is_paid == False,
_UB.due_date.isnot(None),
_UB.due_date <= _d.today() + _td(days=DUE_SOON_DAYS),
).count()
except Exception:
pass
return ctx
# ── Session idle timeout ──────────────────────────────────────────────────
+5
View File
@@ -10,5 +10,10 @@ from app.models.investment import Investment, InvestmentTransaction
from app.models.net_worth_snapshot import NetWorthSnapshot
from app.models.ai_insight import AiInsight
from app.models.fx_rate import FxRate
from app.models.utility import UtilityProvider, UtilityBill
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
from app.models.schwab_connection import SchwabConnection, SchwabAccount
from app.models.plaid_item import PlaidItem, PlaidAccount, PlaidSyncPreview
from app.models.app_log import AppLog
from app.models.audit_log import AuditLog
+160
View File
@@ -0,0 +1,160 @@
from app.extensions import db
from datetime import datetime, date
# type key -> (label, icon, color, default usage unit)
UTILITY_TYPE_META = {
'electricity': ('Electricity', 'bi-lightning-charge', '#f59e0b', 'kWh'),
'water': ('Water', 'bi-droplet', '#0ea5e9', ''),
'gas': ('Gas', 'bi-fire', '#ef4444', 'therms'),
'internet': ('Internet', 'bi-wifi', '#6366f1', 'GB'),
'phone': ('Phone', 'bi-phone', '#8b5cf6', 'GB'),
'trash': ('Trash', 'bi-trash3', '#64748b', ''),
'other': ('Other', 'bi-plug', '#94a3b8', ''),
}
UTILITY_TYPES = list(UTILITY_TYPE_META.keys())
# A bill is flagged "due soon" this many days before its due date
DUE_SOON_DAYS = 7
class UtilityProvider(db.Model):
"""A utility company / service you receive bills from (PG&E, Comcast, ...)."""
__tablename__ = 'utility_providers'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
utility_type = db.Column(
db.Enum(*UTILITY_TYPES),
nullable=False,
default='electricity'
)
account_number = db.Column(db.String(100), nullable=True) # customer/meter account no.
usage_unit = db.Column(db.String(20), nullable=True) # kWh, m³, GB — blank = no usage tracking
# Where bills get paid from, and what expense category they land in
default_account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=True)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
billing_day = db.Column(db.Integer, nullable=True) # day of month the bill arrives
color = db.Column(db.String(7), default='#8b5cf6')
icon = db.Column(db.String(50), default='bi-lightning-charge')
is_active = db.Column(db.Boolean, default=True)
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
default_account = db.relationship('Account')
category = db.relationship('Category')
bills = db.relationship('UtilityBill', back_populates='provider',
lazy='dynamic', cascade='all, delete-orphan')
@property
def type_label(self):
return UTILITY_TYPE_META.get(self.utility_type, UTILITY_TYPE_META['other'])[0]
@property
def tracks_usage(self):
return bool(self.usage_unit)
def __repr__(self):
return f'<UtilityProvider {self.name} ({self.utility_type})>'
class UtilityBill(db.Model):
"""One billing period from a provider — amount, due date, and consumption."""
__tablename__ = 'utility_bills'
__table_args__ = (
db.UniqueConstraint('provider_id', 'period_start', name='uq_utility_bill_period'),
)
id = db.Column(db.Integer, primary_key=True)
provider_id = db.Column(db.Integer, db.ForeignKey('utility_providers.id'), nullable=False)
period_start = db.Column(db.Date, nullable=False, index=True)
period_end = db.Column(db.Date, nullable=False)
amount = db.Column(db.Numeric(15, 2), nullable=False)
due_date = db.Column(db.Date, nullable=True, index=True)
is_paid = db.Column(db.Boolean, default=False, index=True)
paid_date = db.Column(db.Date, nullable=True)
transaction_id = db.Column(db.Integer, db.ForeignKey('transactions.id'), nullable=True)
# Consumption. usage is either typed directly or derived from meter readings.
# Column is named usage_amount — USAGE is a reserved word in MySQL.
usage = db.Column('usage_amount', db.Numeric(15, 3), nullable=True)
usage_unit = db.Column(db.String(20), nullable=True) # snapshot of provider unit at entry time
meter_start = db.Column(db.Numeric(15, 3), nullable=True)
meter_end = db.Column(db.Numeric(15, 3), nullable=True)
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
provider = db.relationship('UtilityProvider', back_populates='bills')
transaction = db.relationship('Transaction')
# ── derived ──────────────────────────────────────────────────────────────
@property
def period_label(self):
if self.period_start.year == self.period_end.year:
return f"{self.period_start.strftime('%b %d')} {self.period_end.strftime('%b %d, %Y')}"
return f"{self.period_start.strftime('%b %d, %Y')} {self.period_end.strftime('%b %d, %Y')}"
@property
def period_month(self):
"""Month the bill is attributed to, for grouping — the period start month."""
return self.period_start.strftime('%Y-%m')
@property
def days_until_due(self):
if not self.due_date or self.is_paid:
return None
return (self.due_date - date.today()).days
@property
def status(self):
"""paid | overdue | due_soon | unpaid"""
if self.is_paid:
return 'paid'
days = self.days_until_due
if days is None:
return 'unpaid'
if days < 0:
return 'overdue'
if days <= DUE_SOON_DAYS:
return 'due_soon'
return 'unpaid'
@property
def rate_per_unit(self):
"""Cost per kWh / m³ / GB for this period, or None when usage isn't tracked."""
if not self.usage:
return None
u = float(self.usage)
if u <= 0:
return None
return float(self.amount) / u
@property
def days_in_period(self):
return max((self.period_end - self.period_start).days + 1, 1)
@property
def daily_cost(self):
return float(self.amount) / self.days_in_period
def sync_usage_from_meter(self):
"""If both meter readings are present, usage is the difference between them."""
if self.meter_start is not None and self.meter_end is not None:
diff = float(self.meter_end) - float(self.meter_start)
if diff >= 0:
self.usage = diff
return self.usage
def __repr__(self):
return f'<UtilityBill provider={self.provider_id} {self.period_start} {self.amount}>'
+533
View File
@@ -0,0 +1,533 @@
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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>')
@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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:id>/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/<int:provider_id>')
@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)))
+236
View File
@@ -0,0 +1,236 @@
"""
Utility Service — bill roll-ups, usage trends, and payment matching.
All aggregation is done in Python rather than SQL: a household has a few
hundred bills at most, and grouping by billing period in the DB would mean
MySQL-specific date functions.
"""
from datetime import date, timedelta
from decimal import Decimal
from dateutil.relativedelta import relativedelta
from sqlalchemy import func
from app.extensions import db
from app.models.utility import UtilityProvider, UtilityBill, UTILITY_TYPE_META
from app.models.transaction import Transaction
def _month_keys(months):
"""['2026-01', ...] ending with the current month."""
today = date.today().replace(day=1)
return [(today - relativedelta(months=i)).strftime('%Y-%m')
for i in range(months - 1, -1, -1)]
def _pct_change(current, previous):
if previous in (None, 0) or current is None:
return None
return round(((float(current) - float(previous)) / abs(float(previous))) * 100, 1)
# ── dashboard ────────────────────────────────────────────────────────────────
def dashboard_summary():
"""Headline numbers for the utilities index page."""
today = date.today()
month_start = today.replace(day=1)
last_month_start = month_start - relativedelta(months=1)
year_start = today.replace(month=1, day=1)
bills = UtilityBill.query.all()
this_month = sum(float(b.amount) for b in bills if b.period_start >= month_start)
last_month = sum(float(b.amount) for b in bills
if last_month_start <= b.period_start < month_start)
ytd = sum(float(b.amount) for b in bills if b.period_start >= year_start)
unpaid = [b for b in bills if not b.is_paid]
overdue = [b for b in unpaid if b.status == 'overdue']
# Next bill coming due — unpaid, has a due date, soonest first
upcoming = sorted([b for b in unpaid if b.due_date], key=lambda b: b.due_date)
# 12-month average of full months (excludes the in-progress current month)
twelve_ago = month_start - relativedelta(months=12)
past = [b for b in bills if twelve_ago <= b.period_start < month_start]
months_span = len({b.period_month for b in past}) or 1
avg_monthly = sum(float(b.amount) for b in past) / months_span
return {
'this_month': this_month,
'last_month': last_month,
'month_change_pct': _pct_change(this_month, last_month),
'ytd': ytd,
'avg_monthly': avg_monthly,
'unpaid_count': len(unpaid),
'unpaid_total': sum(float(b.amount) for b in unpaid),
'overdue_count': len(overdue),
'next_due': upcoming[0] if upcoming else None,
'upcoming': upcoming[:5],
}
def monthly_series(months=12):
"""
Stacked bar data: one dataset per utility type, one point per month.
Returns {labels, datasets:[{label, key, color, data}]}.
"""
keys = _month_keys(months)
index = {k: i for i, k in enumerate(keys)}
cutoff = date.today().replace(day=1) - relativedelta(months=months - 1)
bills = (UtilityBill.query
.join(UtilityProvider)
.filter(UtilityBill.period_start >= cutoff)
.all())
buckets = {}
for b in bills:
i = index.get(b.period_month)
if i is None:
continue
t = b.provider.utility_type
buckets.setdefault(t, [0.0] * len(keys))[i] += float(b.amount)
datasets = []
for t, meta in UTILITY_TYPE_META.items():
if t not in buckets:
continue
datasets.append({
'label': meta[0],
'key': t,
'color': meta[2],
'data': [round(v, 2) for v in buckets[t]],
})
labels = [date(int(k[:4]), int(k[5:]), 1).strftime('%b %y') for k in keys]
return {'labels': labels, 'datasets': datasets}
def type_totals(months=12):
"""Spend per utility type over the window, biggest first."""
cutoff = date.today().replace(day=1) - relativedelta(months=months - 1)
rows = (db.session.query(
UtilityProvider.utility_type,
func.coalesce(func.sum(UtilityBill.amount), 0))
.join(UtilityBill, UtilityBill.provider_id == UtilityProvider.id)
.filter(UtilityBill.period_start >= cutoff)
.group_by(UtilityProvider.utility_type)
.all())
out = []
for t, total in rows:
meta = UTILITY_TYPE_META.get(t, UTILITY_TYPE_META['other'])
out.append({'key': t, 'label': meta[0], 'icon': meta[1],
'color': meta[2], 'total': float(total)})
out.sort(key=lambda r: r['total'], reverse=True)
return out
# ── per-provider ─────────────────────────────────────────────────────────────
def provider_summary(provider):
"""Latest bill, averages, and period-over-period movement for one provider."""
bills = provider.bills.order_by(UtilityBill.period_start.desc()).all()
if not bills:
return {
'latest': None, 'previous': None, 'year_ago': None,
'bill_count': 0, 'avg_amount': 0, 'avg_usage': None,
'amount_change_pct': None, 'usage_change_pct': None,
'yoy_change_pct': None, 'total_12mo': 0, 'unpaid_count': 0,
}
latest = bills[0]
previous = bills[1] if len(bills) > 1 else None
# Same period one year earlier (within a 20-day window of the start date)
target = latest.period_start - relativedelta(years=1)
year_ago = next((b for b in bills if abs((b.period_start - target).days) <= 20), None)
cutoff = date.today() - relativedelta(months=12)
recent = [b for b in bills if b.period_start >= cutoff]
with_usage = [b for b in recent if b.usage]
return {
'latest': latest,
'previous': previous,
'year_ago': year_ago,
'bill_count': len(bills),
'avg_amount': (sum(float(b.amount) for b in recent) / len(recent)) if recent else 0,
'avg_usage': (sum(float(b.usage) for b in with_usage) / len(with_usage)) if with_usage else None,
'amount_change_pct': _pct_change(latest.amount, previous.amount) if previous else None,
'usage_change_pct': (_pct_change(latest.usage, previous.usage)
if previous and latest.usage and previous.usage else None),
'yoy_change_pct': _pct_change(latest.amount, year_ago.amount) if year_ago else None,
'total_12mo': sum(float(b.amount) for b in recent),
'unpaid_count': sum(1 for b in bills if not b.is_paid),
}
def usage_series(provider, months=24):
"""Amount / usage / unit-rate history for a provider's detail chart."""
cutoff = date.today() - relativedelta(months=months)
bills = (provider.bills
.filter(UtilityBill.period_start >= cutoff)
.order_by(UtilityBill.period_start.asc())
.all())
return {
'labels': [b.period_start.strftime('%b %y') for b in bills],
'amounts': [float(b.amount) for b in bills],
'usage': [float(b.usage) if b.usage else None for b in bills],
'rates': [round(b.rate_per_unit, 4) if b.rate_per_unit else None for b in bills],
'unit': provider.usage_unit or '',
'has_usage': any(b.usage for b in bills),
}
# ── payment matching ─────────────────────────────────────────────────────────
def candidate_transactions(bill, window_days=45, limit=25):
"""
Expense transactions that plausibly paid this bill: near the due date (or
period end), not already attached to another bill. Closest amount first.
"""
anchor = bill.due_date or bill.period_end
start = anchor - timedelta(days=window_days)
end = anchor + timedelta(days=window_days)
linked = {row[0] for row in
db.session.query(UtilityBill.transaction_id)
.filter(UtilityBill.transaction_id.isnot(None),
UtilityBill.id != bill.id).all()}
q = (Transaction.query
.filter(Transaction.transaction_type == 'expense',
Transaction.date >= start,
Transaction.date <= end)
.order_by(Transaction.date.desc()))
target = float(bill.amount)
rows = [t for t in q.limit(300).all() if t.id not in linked]
rows.sort(key=lambda t: (abs(float(t.amount) - target), abs((t.date - anchor).days)))
return rows[:limit]
def build_payment_transaction(bill, account_id, paid_date, category_id=None):
"""Create (but don't commit) the expense transaction for a bill payment."""
provider = bill.provider
txn = Transaction(
account_id=account_id,
category_id=category_id if category_id else provider.category_id,
transaction_type='expense',
amount=bill.amount,
description=f'{provider.name}{provider.type_label}',
date=paid_date,
notes=f'Utility:{bill.id}',
)
db.session.add(txn)
return txn
def is_generated_payment(bill):
"""True when the linked transaction was created by mark-paid (so unpaying may delete it)."""
txn = bill.transaction
return bool(txn and (txn.notes or '').strip().startswith(f'Utility:{bill.id}'))
+11
View File
@@ -322,6 +322,17 @@
>
<i class="bi bi-wallet2"></i><span class="lt">Accounts</span>
</a>
<a
href="{{ url_for('utilities.index') }}"
class="sb-link {% if request.blueprint == 'utilities' %}active{% endif %}"
>
<i class="bi bi-lightning-charge"></i
><span class="lt">Utilities
{% if utility_due_count > 0 %}
<span style="margin-left:6px;background:#f59e0b;color:#fff;font-size:10px;font-weight:700;padding:1px 5px;border-radius:10px;line-height:1.4;">{{ utility_due_count }}</span>
{% endif %}
</span>
</a>
<a
href="{{ url_for('bank_import.index') }}"
class="sb-link {% if request.blueprint == 'bank_import' %}active{% endif %}"
+148
View File
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block page_title %}{{ title }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('utilities.bills') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Bills</a>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-9 col-lg-7">
<div class="pcard">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.provider_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.provider_id(class="form-select" + (" is-invalid" if form.provider_id.errors else ""), id="providerSelect") }}
{% for e in form.provider_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="row g-3 mb-3">
<div class="col-6">
{{ form.period_start.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.period_start(class="form-control" + (" is-invalid" if form.period_start.errors else "")) }}
{% for e in form.period_start.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="col-6">
{{ form.period_end.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.period_end(class="form-control" + (" is-invalid" if form.period_end.errors else "")) }}
{% for e in form.period_end.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6">
{{ form.amount.label(class="form-label fw-medium", style="font-size:13px;") }}
<div class="input-group">
<span class="input-group-text" style="font-size:13px;">{{ current_user.currency_symbol }}</span>
{{ form.amount(class="form-control" + (" is-invalid" if form.amount.errors else ""), placeholder="0.00", id="amountInput") }}
</div>
{% for e in form.amount.errors %}<div class="text-danger" style="font-size:12px;">{{ e }}</div>{% endfor %}
</div>
<div class="col-6">
{{ form.due_date.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.due_date(class="form-control") }}
</div>
</div>
<!-- Usage -->
<div id="usageBlock" style="border-top:1px solid var(--border);padding-top:16px;">
<div class="d-flex justify-content-between align-items-center mb-3">
<label class="form-label fw-medium mb-0" style="font-size:13px;">Consumption <span class="text-muted" style="font-weight:400;">— optional</span></label>
<span class="badge" style="background:#f1f5f9;color:#475569;font-size:11px;" id="unitBadge"></span>
</div>
<div class="row g-3 mb-2">
<div class="col-12 col-sm-4">
{{ form.usage.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.usage(class="form-control", placeholder="0.000", id="usageInput") }}
</div>
<div class="col-6 col-sm-4">
{{ form.meter_start.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.meter_start(class="form-control", placeholder="Previous reading", id="meterStart") }}
</div>
<div class="col-6 col-sm-4">
{{ form.meter_end.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.meter_end(class="form-control" + (" is-invalid" if form.meter_end.errors else ""), placeholder="Current reading", id="meterEnd") }}
{% for e in form.meter_end.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
</div>
<small class="text-muted" style="font-size:11px;">
Enter usage directly, or both meter readings — readings win and fill in usage automatically.
</small>
<div class="mt-3 p-2" id="rateHint" style="display:none;background:#f8fafc;border-radius:8px;font-size:12px;">
<i class="bi bi-calculator me-1"></i>Unit rate: <span class="mono fw-bold" id="rateValue"></span>
</div>
</div>
<div class="mb-4 mt-3">
{{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.notes(class="form-control", rows=2, placeholder="Rate plan, unusual charges, meter notes…") }}
</div>
<div class="d-flex gap-2 flex-wrap">
{{ form.submit(class="btn btn-primary") }}
{% if not bill %}
<button type="submit" name="pay_now" value="yes" class="btn btn-outline-primary">Save &amp; Mark Paid</button>
{% endif %}
<a href="{{ url_for('utilities.bills') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
// provider id -> usage unit, so the form can label and validate consumption
const UNITS = {{ provider_units | tojson }};
const sym = {{ current_user.currency_symbol | tojson }};
const sel = document.getElementById('providerSelect');
const block = document.getElementById('usageBlock');
const badge = document.getElementById('unitBadge');
const usage = document.getElementById('usageInput');
const mStart = document.getElementById('meterStart');
const mEnd = document.getElementById('meterEnd');
const amount = document.getElementById('amountInput');
const hint = document.getElementById('rateHint');
const rateVal = document.getElementById('rateValue');
function unit() { return UNITS[sel.value] || ''; }
function syncUnit() {
const u = unit();
badge.textContent = u || 'no usage tracked';
block.style.opacity = u ? '1' : '.55';
}
function syncUsage() {
const a = parseFloat(mStart.value), b = parseFloat(mEnd.value);
if (!isNaN(a) && !isNaN(b) && b >= a) usage.value = (b - a).toFixed(3);
syncRate();
}
function syncRate() {
const amt = parseFloat(amount.value), u = parseFloat(usage.value);
if (!isNaN(amt) && !isNaN(u) && u > 0) {
rateVal.textContent = sym + (amt / u).toFixed(4) + ' per ' + (unit() || 'unit');
hint.style.display = 'block';
} else {
hint.style.display = 'none';
}
}
sel.addEventListener('change', syncUnit);
[mStart, mEnd].forEach(el => el.addEventListener('input', syncUsage));
[amount, usage].forEach(el => el.addEventListener('input', syncRate));
syncUnit();
syncRate();
})();
</script>
{% endblock %}
+143
View File
@@ -0,0 +1,143 @@
{% extends "base.html" %}
{% block title %}Utility Bills{% endblock %}
{% block page_title %}Utility Bills{% endblock %}
{% block extra_css %}
.util-chip { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 10px; text-transform: uppercase; letter-spacing: .05em; }
{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('utilities.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Utilities</a>
<a href="{{ url_for('utilities.bill_new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i><span class="btn-label">New Bill</span></a>
{% endblock %}
{% block content %}
<!-- Filters -->
<div class="pcard mb-3">
<form method="GET" class="row g-2 align-items-end">
<div class="col-6 col-md-3">
<label class="form-label fw-medium" style="font-size:12px;">Provider</label>
<select name="provider_id" class="form-select form-select-sm">
<option value="">All providers</option>
{% for p in providers %}
<option value="{{ p.id }}" {% if provider_id == p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label fw-medium" style="font-size:12px;">Type</label>
<select name="utility_type" class="form-select form-select-sm">
<option value="">All types</option>
{% for key, meta in type_meta.items() %}
<option value="{{ key }}" {% if utility_type == key %}selected{% endif %}>{{ meta[0] }}</option>
{% endfor %}
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label fw-medium" style="font-size:12px;">Status</label>
<select name="status" class="form-select form-select-sm">
<option value="">All</option>
<option value="unpaid" {% if status == 'unpaid' %}selected{% endif %}>Unpaid</option>
<option value="overdue" {% if status == 'overdue' %}selected{% endif %}>Overdue</option>
<option value="paid" {% if status == 'paid' %}selected{% endif %}>Paid</option>
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label fw-medium" style="font-size:12px;">Year</label>
<select name="year" class="form-select form-select-sm">
<option value="">All years</option>
{% for y in years %}
<option value="{{ y }}" {% if year == y %}selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-3 d-flex gap-2">
<button type="submit" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-funnel me-1"></i>Filter</button>
<a href="{{ url_for('utilities.bills') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">Clear</a>
</div>
</form>
</div>
{% if bills %}
<div class="pcard p-0">
<div class="table-wrap">
<table class="pfm-table wide">
<thead>
<tr>
<th>Provider</th>
<th>Period</th>
<th class="d-mob-none">Due</th>
<th class="text-end">Amount</th>
<th class="text-end d-mob-none">Usage</th>
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for b in bills %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<div style="width:28px;height:28px;border-radius:7px;background:{{ b.provider.color }}22;color:{{ b.provider.color }};display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;">
<i class="bi {{ b.provider.icon }}"></i>
</div>
<div>
<a href="{{ url_for('utilities.provider_detail', id=b.provider_id) }}" style="font-size:13px;font-weight:600;text-decoration:none;color:inherit;">{{ b.provider.name }}</a>
<div style="font-size:11px;color:var(--muted);">{{ b.provider.type_label }}</div>
</div>
</div>
</td>
<td style="font-size:12px;">{{ b.period_label }}</td>
<td class="d-mob-none" style="font-size:12px;">{{ b.due_date.strftime('%b %d, %Y') if b.due_date else '—' }}</td>
<td class="text-end mono fw-bold" style="font-size:13px;">{{ b.amount | currency }}</td>
<td class="text-end mono d-mob-none" style="font-size:12px;">
{% if b.usage %}{{ '%.1f' | format(b.usage | float) }} <span style="color:var(--muted);">{{ b.usage_unit or '' }}</span>{% else %}—{% endif %}
</td>
<td>
{% if b.status == 'paid' %}
<span class="util-chip" style="background:#d1fae5;color:#065f46;">Paid</span>
{% elif b.status == 'overdue' %}
<span class="util-chip" style="background:#fee2e2;color:#991b1b;">{{ b.days_until_due | abs }}d late</span>
{% elif b.status == 'due_soon' %}
<span class="util-chip" style="background:#fef3c7;color:#92400e;">Due in {{ b.days_until_due }}d</span>
{% else %}
<span class="util-chip" style="background:#f1f5f9;color:#475569;">Unpaid</span>
{% endif %}
</td>
<td class="text-end">
{% if not b.is_paid %}
<a href="{{ url_for('utilities.bill_pay', id=b.id) }}" class="btn btn-sm btn-primary" style="font-size:11px;padding:3px 10px;">Pay</a>
{% endif %}
<a href="{{ url_for('utilities.bill_edit', id=b.id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;padding:3px 8px;"><i class="bi bi-pencil"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<div class="d-flex justify-content-between align-items-center p-3" style="border-top:1px solid var(--border);font-size:12px;color:var(--muted);">
<span>Page {{ pagination.page }} of {{ pagination.pages }} &nbsp;·&nbsp; {{ ((pagination.page-1)*30)+1 }}{{ [pagination.page*30, pagination.total]|min }} of {{ pagination.total }}</span>
<div class="d-flex gap-2">
{% if pagination.has_prev %}
<a href="{{ url_for('utilities.bills', page=pagination.prev_num, provider_id=provider_id, utility_type=utility_type, status=status, year=year) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">← Prev</a>
{% endif %}
{% if pagination.has_next %}
<a href="{{ url_for('utilities.bills', page=pagination.next_num, provider_id=provider_id, utility_type=utility_type, status=status, year=year) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">Next →</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% else %}
<div class="pcard text-center py-5">
<i class="bi bi-receipt text-muted" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">No bills match</h5>
<p class="text-muted small mb-3">Try clearing the filters, or record a new bill.</p>
<a href="{{ url_for('utilities.bill_new') }}" class="btn btn-primary btn-sm">New Bill</a>
</div>
{% endif %}
{% endblock %}
+280
View File
@@ -0,0 +1,280 @@
{% extends "base.html" %}
{% block title %}{{ provider.name }}{% endblock %}
{% block page_title %}{{ provider.name }}{% endblock %}
{% block extra_css %}
.util-chip { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 10px; text-transform: uppercase; letter-spacing: .05em; }
.trend-up { color: #ef4444; }
.trend-down { color: #10b981; }
{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('utilities.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Utilities</a>
<a href="{{ url_for('utilities.provider_edit', id=provider.id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"><i class="bi bi-pencil me-1"></i><span class="btn-label">Edit</span></a>
<a href="{{ url_for('utilities.bill_new', provider_id=provider.id) }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i><span class="btn-label">Add Bill</span></a>
{% endblock %}
{% block content %}
<!-- Header -->
<div class="pcard mb-4" style="border-left:4px solid {{ provider.color }};">
<div class="d-flex align-items-center gap-3 flex-wrap">
<div style="width:46px;height:46px;border-radius:11px;background:{{ provider.color }}22;color:{{ provider.color }};display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;">
<i class="bi {{ provider.icon }}"></i>
</div>
<div class="flex-grow-1">
<div style="font-size:16px;font-weight:600;">{{ provider.name }}</div>
<div style="font-size:12px;color:var(--muted);">
{{ provider.type_label }}
{% if provider.account_number %} · Acct <span class="mono">{{ provider.account_number }}</span>{% endif %}
{% if provider.default_account %} · Paid from {{ provider.default_account.name }}{% endif %}
{% if provider.billing_day %} · Bills on day {{ provider.billing_day }}{% endif %}
</div>
</div>
</div>
{% if provider.notes %}
<div style="font-size:12px;color:var(--muted);margin-top:10px;padding-top:10px;border-top:1px solid var(--border);">{{ provider.notes }}</div>
{% endif %}
</div>
<!-- Stats -->
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="stat-label">Latest Bill</div>
<div class="stat-value">{{ stats.latest.amount | currency if stats.latest else '—' }}</div>
{% if stats.amount_change_pct is not none %}
<small class="{% if stats.amount_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:11px;">
<i class="bi bi-arrow-{% if stats.amount_change_pct > 0 %}up{% else %}down{% endif %}-short"></i>{{ stats.amount_change_pct | abs }}% vs prev
</small>
{% endif %}
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="stat-label">Average Bill</div>
<div class="stat-value">{{ stats.avg_amount | currency }}</div>
<small class="text-muted" style="font-size:11px;">Last 12 months</small>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="stat-label">12-Month Total</div>
<div class="stat-value">{{ stats.total_12mo | currency }}</div>
<small class="text-muted" style="font-size:11px;">{{ stats.bill_count }} bill{{ '' if stats.bill_count == 1 else 's' }} on record</small>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="stat-label">{% if provider.tracks_usage %}Avg Usage{% else %}Year over Year{% endif %}</div>
{% if provider.tracks_usage %}
<div class="stat-value">{{ '%.1f' | format(stats.avg_usage) if stats.avg_usage else '—' }}<span style="font-size:13px;color:var(--muted);"> {{ provider.usage_unit }}</span></div>
{% if stats.usage_change_pct is not none %}
<small class="{% if stats.usage_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:11px;">
<i class="bi bi-arrow-{% if stats.usage_change_pct > 0 %}up{% else %}down{% endif %}-short"></i>{{ stats.usage_change_pct | abs }}% latest vs prev
</small>
{% endif %}
{% else %}
<div class="stat-value">{% if stats.yoy_change_pct is not none %}{{ '+' if stats.yoy_change_pct > 0 else '' }}{{ stats.yoy_change_pct }}%{% else %}—{% endif %}</div>
<small class="text-muted" style="font-size:11px;">Latest vs same period last year</small>
{% endif %}
</div>
</div>
</div>
<!-- Chart -->
{% if series.labels %}
<div class="pcard mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="pcard-title mb-0">Billing History</div>
{% if series.has_usage %}
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary active" style="font-size:11px;" data-mode="amount">Amount</button>
<button class="btn btn-outline-secondary" style="font-size:11px;" data-mode="usage">Usage</button>
<button class="btn btn-outline-secondary" style="font-size:11px;" data-mode="rate">Unit Rate</button>
</div>
{% endif %}
</div>
<div style="height:300px;"><canvas id="histChart"></canvas></div>
</div>
{% endif %}
<!-- Bills -->
<div class="pcard p-0">
<div class="d-flex justify-content-between align-items-center p-3" style="border-bottom:1px solid var(--border);">
<div class="pcard-title mb-0">Bills</div>
</div>
{% if bills %}
<div class="table-wrap">
<table class="pfm-table wide">
<thead>
<tr>
<th>Period</th>
<th class="d-mob-none">Due</th>
<th class="text-end">Amount</th>
{% if provider.tracks_usage %}
<th class="text-end d-mob-none">Usage</th>
<th class="text-end d-mob-none">Rate</th>
{% endif %}
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for b in bills %}
<tr>
<td style="font-size:12px;">
{{ b.period_label }}
{% if b.notes %}<div style="font-size:11px;color:var(--muted);">{{ b.notes | truncate(60) }}</div>{% endif %}
</td>
<td class="d-mob-none" style="font-size:12px;">{{ b.due_date.strftime('%b %d, %Y') if b.due_date else '—' }}</td>
<td class="text-end mono fw-bold" style="font-size:13px;">{{ b.amount | currency }}</td>
{% if provider.tracks_usage %}
<td class="text-end mono d-mob-none" style="font-size:12px;">
{% if b.usage %}{{ '%.1f' | format(b.usage | float) }} <span style="color:var(--muted);">{{ b.usage_unit or provider.usage_unit }}</span>{% else %}—{% endif %}
</td>
<td class="text-end mono d-mob-none" style="font-size:12px;">
{% if b.rate_per_unit %}{{ current_user.currency_symbol }}{{ '%.4f' | format(b.rate_per_unit) }}{% else %}—{% endif %}
</td>
{% endif %}
<td>
{% if b.status == 'paid' %}
<span class="util-chip" style="background:#d1fae5;color:#065f46;">Paid</span>
{% if b.paid_date %}<div style="font-size:10px;color:var(--muted);margin-top:2px;">{{ b.paid_date.strftime('%b %d') }}</div>{% endif %}
{% elif b.status == 'overdue' %}
<span class="util-chip" style="background:#fee2e2;color:#991b1b;">{{ b.days_until_due | abs }}d late</span>
{% elif b.status == 'due_soon' %}
<span class="util-chip" style="background:#fef3c7;color:#92400e;">Due in {{ b.days_until_due }}d</span>
{% else %}
<span class="util-chip" style="background:#f1f5f9;color:#475569;">Unpaid</span>
{% endif %}
</td>
<td class="text-end">
<div class="dropdown">
<button class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;" data-bs-toggle="dropdown"><i class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu dropdown-menu-end" style="font-size:13px;">
{% if not b.is_paid %}
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_pay', id=b.id) }}"><i class="bi bi-check2-circle me-2"></i>Mark Paid</a></li>
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_link', id=b.id) }}"><i class="bi bi-link-45deg me-2"></i>Link Existing Payment</a></li>
{% else %}
{% if b.transaction_id %}
<li><a class="dropdown-item" href="{{ url_for('transactions.edit', id=b.transaction_id) }}"><i class="bi bi-receipt me-2"></i>View Payment</a></li>
{% endif %}
<li>
<form method="POST" action="{{ url_for('utilities.bill_unpay', id=b.id) }}" onsubmit="return confirm('Reopen this bill? A payment transaction created by PFM will be deleted.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="dropdown-item"><i class="bi bi-arrow-counterclockwise me-2"></i>Reopen</button>
</form>
</li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_edit', id=b.id) }}"><i class="bi bi-pencil me-2"></i>Edit</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="POST" action="{{ url_for('utilities.bill_delete', id=b.id) }}" onsubmit="return confirm('Delete this bill?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="dropdown-item text-danger"><i class="bi bi-trash me-2"></i>Delete</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<div class="d-flex justify-content-between align-items-center p-3" style="border-top:1px solid var(--border);font-size:12px;color:var(--muted);">
<span>Page {{ pagination.page }} of {{ pagination.pages }} &nbsp;·&nbsp; {{ pagination.total }} bills</span>
<div class="d-flex gap-2">
{% if pagination.has_prev %}
<a href="{{ url_for('utilities.provider_detail', id=provider.id, page=pagination.prev_num) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">← Prev</a>
{% endif %}
{% if pagination.has_next %}
<a href="{{ url_for('utilities.provider_detail', id=provider.id, page=pagination.next_num) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">Next →</a>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-receipt text-muted" style="font-size:2.5rem;"></i>
<h6 class="mt-3 mb-1">No bills recorded</h6>
<p class="text-muted small mb-3">Add a bill to start tracking cost{% if provider.tracks_usage %} and usage{% endif %}.</p>
<a href="{{ url_for('utilities.bill_new', provider_id=provider.id) }}" class="btn btn-primary btn-sm">Add First Bill</a>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
{% if series.labels %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
(function () {
const dark = document.documentElement.getAttribute('data-pfm-dark') === '1';
const grid = dark ? 'rgba(148,163,184,.15)' : 'rgba(148,163,184,.25)';
const tick = dark ? '#94a3b8' : '#64748b';
const sym = {{ current_user.currency_symbol | tojson }};
const color = {{ provider.color | tojson }};
const S = {{ series | tojson }};
const MODES = {
amount: { label: 'Amount', data: S.amounts, type: 'bar', fmt: function (v) { return sym + v.toFixed(2); } },
usage: { label: 'Usage (' + S.unit + ')', data: S.usage, type: 'line', fmt: function (v) { return v.toFixed(1) + ' ' + S.unit; } },
rate: { label: 'Rate per ' + S.unit, data: S.rates, type: 'line', fmt: function (v) { return sym + v.toFixed(4); } }
};
let chart = null;
function render(mode) {
const m = MODES[mode];
if (chart) chart.destroy();
chart = new Chart(document.getElementById('histChart'), {
type: m.type,
data: {
labels: S.labels,
datasets: [{
label: m.label,
data: m.data,
backgroundColor: m.type === 'bar' ? color : color + '22',
borderColor: color,
borderWidth: 2,
borderRadius: 4,
tension: .3,
fill: m.type === 'line',
pointRadius: 3,
spanGaps: true
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: function (c) { return m.label + ': ' + m.fmt(c.parsed.y); } } }
},
scales: {
x: { grid: { display: false }, ticks: { font: { size: 11 }, color: tick } },
y: { grid: { color: grid }, ticks: { font: { size: 11 }, color: tick,
callback: function (v) { return mode === 'amount' ? sym + v : v; } } }
}
}
});
}
render('amount');
document.querySelectorAll('[data-mode]').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('[data-mode]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
render(btn.dataset.mode);
});
});
})();
</script>
{% endif %}
{% endblock %}
+297
View File
@@ -0,0 +1,297 @@
{% extends "base.html" %}
{% block title %}Utilities{% endblock %}
{% block page_title %}Utilities{% endblock %}
{% block extra_css %}
.util-card { border-radius: 12px; border: 1px solid var(--border); background: var(--card-bg); padding: 16px 18px; height: 100%; }
.util-chip { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 10px; text-transform: uppercase; letter-spacing: .05em; }
.trend-up { color: #ef4444; }
.trend-down { color: #10b981; }
{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('utilities.bills') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"><i class="bi bi-list-ul me-1"></i><span class="btn-label">All Bills</span></a>
<a href="{{ url_for('utilities.providers') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"><i class="bi bi-building me-1"></i><span class="btn-label">Providers</span></a>
<a href="{{ url_for('utilities.bill_new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i><span class="btn-label">New Bill</span></a>
{% endblock %}
{% block content %}
<!-- Stat cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">This Month</div>
<div class="stat-value text-expense">{{ summary.this_month | currency }}</div>
{% if summary.month_change_pct is not none %}
<small class="{% if summary.month_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:11px;">
<i class="bi bi-arrow-{% if summary.month_change_pct > 0 %}up{% else %}down{% endif %}-short"></i>{{ summary.month_change_pct | abs }}% vs last month
</small>
{% endif %}
</div>
<div class="stat-icon" style="background:#ede9fe;color:#5b21b6;"><i class="bi bi-lightning-charge"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Monthly Average</div>
<div class="stat-value">{{ summary.avg_monthly | currency }}</div>
<small class="text-muted" style="font-size:11px;">Last 12 months</small>
</div>
<div class="stat-icon" style="background:#e0f2fe;color:#075985;"><i class="bi bi-bar-chart"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Year to Date</div>
<div class="stat-value">{{ summary.ytd | currency }}</div>
</div>
<div class="stat-icon" style="background:#fef3c7;color:#92400e;"><i class="bi bi-calendar3"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card" style="{% if summary.overdue_count %}border-color:#fecaca;{% endif %}">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Unpaid</div>
<div class="stat-value {% if summary.overdue_count %}text-expense{% endif %}">{{ summary.unpaid_total | currency }}</div>
<small class="text-muted" style="font-size:11px;">
{{ summary.unpaid_count }} bill{{ '' if summary.unpaid_count == 1 else 's' }}{% if summary.overdue_count %} · <span class="text-expense fw-bold">{{ summary.overdue_count }} overdue</span>{% endif %}
</small>
</div>
<div class="stat-icon" style="background:{% if summary.overdue_count %}#fee2e2;color:#991b1b{% else %}#f1f5f9;color:#475569{% endif %};"><i class="bi bi-receipt"></i></div>
</div>
</div>
</div>
</div>
<!-- Upcoming due -->
{% if summary.upcoming %}
<div class="pcard mb-4" style="border-left:4px solid {% if summary.overdue_count %}#ef4444{% else %}#f59e0b{% endif %};">
<div class="pcard-title mb-3">Bills Due</div>
<div class="table-wrap">
<table class="pfm-table">
<thead><tr><th>Provider</th><th>Period</th><th class="d-mob-none">Due</th><th class="text-end">Amount</th><th class="text-end">Action</th></tr></thead>
<tbody>
{% for b in summary.upcoming %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<div style="width:28px;height:28px;border-radius:7px;background:{{ b.provider.color }}22;color:{{ b.provider.color }};display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;">
<i class="bi {{ b.provider.icon }}"></i>
</div>
<a href="{{ url_for('utilities.provider_detail', id=b.provider_id) }}" style="font-size:13px;font-weight:600;text-decoration:none;color:inherit;">{{ b.provider.name }}</a>
</div>
</td>
<td style="font-size:12px;color:var(--muted);">{{ b.period_label }}</td>
<td class="d-mob-none" style="font-size:12px;">
{{ b.due_date.strftime('%b %d') }}
{% if b.status == 'overdue' %}
<span class="util-chip" style="background:#fee2e2;color:#991b1b;">{{ b.days_until_due | abs }}d late</span>
{% elif b.status == 'due_soon' %}
<span class="util-chip" style="background:#fef3c7;color:#92400e;">in {{ b.days_until_due }}d</span>
{% endif %}
</td>
<td class="text-end mono fw-bold" style="font-size:13px;">{{ b.amount | currency }}</td>
<td class="text-end">
<a href="{{ url_for('utilities.bill_pay', id=b.id) }}" class="btn btn-sm btn-primary" style="font-size:11px;padding:3px 10px;">Pay</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="row g-3 mb-4">
<!-- Monthly spend chart -->
<div class="col-12 col-xl-8">
<div class="pcard h-100">
<div class="pcard-title mb-3">Utility Spend — Last 12 Months</div>
{% if chart.datasets %}
<div style="height:280px;"><canvas id="monthlyChart"></canvas></div>
{% else %}
<div class="text-center text-muted py-5" style="font-size:13px;">No bills recorded yet.</div>
{% endif %}
</div>
</div>
<!-- By type -->
<div class="col-12 col-xl-4">
<div class="pcard h-100">
<div class="pcard-title mb-3">By Type — 12 Months</div>
{% if type_totals %}
{% set grand = type_totals | sum(attribute='total') %}
{% for t in type_totals %}
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<span style="font-size:12px;font-weight:600;"><i class="bi {{ t.icon }} me-1" style="color:{{ t.color }};"></i>{{ t.label }}</span>
<span class="mono" style="font-size:12px;">{{ t.total | currency }}</span>
</div>
<div style="height:6px;background:#f1f5f9;border-radius:3px;">
<div style="height:6px;border-radius:3px;background:{{ t.color }};width:{{ (t.total / grand * 100) if grand else 0 }}%;"></div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center text-muted py-4" style="font-size:13px;">No data yet.</div>
{% endif %}
</div>
</div>
</div>
<!-- Providers -->
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="pcard-title mb-0">Providers</div>
<a href="{{ url_for('utilities.provider_new') }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;"><i class="bi bi-plus-lg me-1"></i>Add Provider</a>
</div>
{% if providers %}
<div class="row g-3">
{% for p in providers %}
{% set s = summaries[p.id] %}
<div class="col-12 col-md-6 col-xl-4">
<div class="util-card" style="border-left:4px solid {{ p.color }};">
<div class="d-flex justify-content-between align-items-start mb-3">
<div class="d-flex align-items-center gap-2">
<div style="width:36px;height:36px;border-radius:9px;background:{{ p.color }}22;color:{{ p.color }};display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;">
<i class="bi {{ p.icon }}"></i>
</div>
<div>
<div style="font-size:14px;font-weight:600;">{{ p.name }}</div>
<div style="font-size:11px;color:var(--muted);">{{ p.type_label }}{% if s.unpaid_count %} · <span class="text-expense fw-bold">{{ s.unpaid_count }} unpaid</span>{% endif %}</div>
</div>
</div>
<div class="dropdown">
<button class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;" data-bs-toggle="dropdown"><i class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu dropdown-menu-end" style="font-size:13px;">
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_new', provider_id=p.id) }}"><i class="bi bi-plus-circle me-2"></i>Add Bill</a></li>
<li><a class="dropdown-item" href="{{ url_for('utilities.provider_detail', id=p.id) }}"><i class="bi bi-graph-up me-2"></i>History &amp; Usage</a></li>
<li><a class="dropdown-item" href="{{ url_for('utilities.provider_edit', id=p.id) }}"><i class="bi bi-pencil me-2"></i>Edit</a></li>
</ul>
</div>
</div>
{% if s.latest %}
<div class="d-flex justify-content-between align-items-end">
<div>
<div style="font-size:11px;color:var(--muted);">Latest · {{ s.latest.period_start.strftime('%b %Y') }}</div>
<div class="mono fw-bold" style="font-size:18px;">{{ s.latest.amount | currency }}</div>
</div>
<div class="text-end">
{% if s.amount_change_pct is not none %}
<div class="{% if s.amount_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:12px;font-weight:600;">
<i class="bi bi-arrow-{% if s.amount_change_pct > 0 %}up{% else %}down{% endif %}-short"></i>{{ s.amount_change_pct | abs }}%
</div>
<div style="font-size:10px;color:var(--muted);">vs prev period</div>
{% endif %}
</div>
</div>
{% if s.latest.usage %}
<div class="d-flex justify-content-between mt-2 pt-2" style="border-top:1px solid var(--border);font-size:12px;">
<span class="text-muted">Usage</span>
<span class="mono">{{ '%.1f' | format(s.latest.usage | float) }} {{ s.latest.usage_unit or p.usage_unit }}
{% if s.usage_change_pct is not none %}
<span class="{% if s.usage_change_pct > 0 %}trend-up{% else %}trend-down{% endif %}" style="font-size:11px;">({{ '+' if s.usage_change_pct > 0 else '' }}{{ s.usage_change_pct }}%)</span>
{% endif %}
</span>
</div>
{% endif %}
{% if s.latest.rate_per_unit %}
<div class="d-flex justify-content-between mt-1" style="font-size:12px;">
<span class="text-muted">Unit rate</span>
<span class="mono">{{ current_user.currency_symbol }}{{ '%.4f' | format(s.latest.rate_per_unit) }} / {{ s.latest.usage_unit or p.usage_unit }}</span>
</div>
{% endif %}
<div class="d-flex justify-content-between mt-1" style="font-size:12px;">
<span class="text-muted">12-month total</span>
<span class="mono">{{ s.total_12mo | currency }}</span>
</div>
{% if s.latest.status != 'paid' %}
<a href="{{ url_for('utilities.bill_pay', id=s.latest.id) }}" class="btn btn-sm w-100 mt-3" style="background:{{ p.color }}22;color:{{ p.color }};border:1px solid {{ p.color }}44;font-size:12px;">
<i class="bi bi-check2-circle me-1"></i>Mark Latest Paid
</a>
{% else %}
<a href="{{ url_for('utilities.bill_new', provider_id=p.id) }}" class="btn btn-sm w-100 mt-3" style="background:{{ p.color }}22;color:{{ p.color }};border:1px solid {{ p.color }}44;font-size:12px;">
<i class="bi bi-plus-lg me-1"></i>Add Bill
</a>
{% endif %}
{% else %}
<div class="text-center text-muted py-3" style="font-size:12px;">No bills yet</div>
<a href="{{ url_for('utilities.bill_new', provider_id=p.id) }}" class="btn btn-sm w-100" style="background:{{ p.color }}22;color:{{ p.color }};border:1px solid {{ p.color }}44;font-size:12px;">
<i class="bi bi-plus-lg me-1"></i>Add First Bill
</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="pcard text-center py-5">
<i class="bi bi-lightning-charge text-muted" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">No utility providers yet</h5>
<p class="text-muted small mb-3">Add your electricity, water, gas, and internet providers to track bills and usage.</p>
<a href="{{ url_for('utilities.provider_new') }}" class="btn btn-primary btn-sm">Add First Provider</a>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
{% if chart.datasets %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
(function () {
const dark = document.documentElement.getAttribute('data-pfm-dark') === '1';
const grid = dark ? 'rgba(148,163,184,.15)' : 'rgba(148,163,184,.25)';
const tick = dark ? '#94a3b8' : '#64748b';
const sym = {{ current_user.currency_symbol | tojson }};
new Chart(document.getElementById('monthlyChart'), {
type: 'bar',
data: {
labels: {{ chart.labels | tojson }},
datasets: {{ chart.datasets | tojson }}.map(function (d) {
return { label: d.label, data: d.data, backgroundColor: d.color, borderRadius: 4, borderSkipped: false };
})
},
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 10, boxHeight: 10, font: { size: 11 }, color: tick } },
tooltip: {
callbacks: {
label: function (c) { return c.dataset.label + ': ' + sym + c.parsed.y.toFixed(2); },
footer: function (items) {
const total = items.reduce(function (s, i) { return s + i.parsed.y; }, 0);
return 'Total: ' + sym + total.toFixed(2);
}
}
}
},
scales: {
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 11 }, color: tick } },
y: { stacked: true, grid: { color: grid }, ticks: { font: { size: 11 }, color: tick, callback: function (v) { return sym + v; } } }
}
}
});
})();
</script>
{% endif %}
{% endblock %}
+88
View File
@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}Link Payment{% endblock %}
{% block page_title %}Link Existing Payment{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('utilities.provider_detail', id=bill.provider_id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← {{ bill.provider.name }}</a>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-lg-9">
<!-- Bill summary -->
<div class="pcard mb-3" style="border-left:4px solid {{ bill.provider.color }};">
<div class="d-flex align-items-center gap-3">
<div style="width:42px;height:42px;border-radius:10px;background:{{ bill.provider.color }}22;color:{{ bill.provider.color }};display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0;">
<i class="bi {{ bill.provider.icon }}"></i>
</div>
<div class="flex-grow-1">
<div style="font-size:14px;font-weight:600;">{{ bill.provider.name }}</div>
<div style="font-size:12px;color:var(--muted);">{{ bill.period_label }}{% if bill.due_date %} · due {{ bill.due_date.strftime('%b %d, %Y') }}{% endif %}</div>
</div>
<div class="mono fw-bold" style="font-size:20px;">{{ bill.amount | currency }}</div>
</div>
</div>
<div class="pcard p-0">
<div class="p-3" style="border-bottom:1px solid var(--border);">
<div class="pcard-title mb-1">Candidate Transactions</div>
<div class="text-muted" style="font-size:12px;">
Expenses within 45 days of the due date, closest amount first. Transactions already linked to another bill are hidden.
</div>
</div>
{% if candidates %}
<form method="POST" id="linkForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="table-wrap">
<table class="pfm-table wide">
<thead>
<tr>
<th style="width:40px;"></th>
<th>Date</th>
<th>Description</th>
<th class="d-mob-none">Account</th>
<th class="d-mob-none">Category</th>
<th class="text-end">Amount</th>
<th class="text-end d-mob-none">Δ</th>
</tr>
</thead>
<tbody>
{% for t in candidates %}
{% set diff = (t.amount | float) - (bill.amount | float) %}
<tr style="cursor:pointer;" onclick="this.querySelector('input[type=radio]').checked = true;">
<td><input type="radio" name="transaction_id" value="{{ t.id }}" class="form-check-input" {% if loop.first %}checked{% endif %}></td>
<td style="font-size:12px;">{{ t.date.strftime('%b %d, %Y') }}</td>
<td style="font-size:13px;">{{ t.description | truncate(48) }}</td>
<td class="d-mob-none" style="font-size:12px;color:var(--muted);">{{ t.account.name if t.account else '—' }}</td>
<td class="d-mob-none" style="font-size:12px;color:var(--muted);">{{ t.category.name if t.category else '—' }}</td>
<td class="text-end mono fw-bold" style="font-size:13px;">{{ t.amount | currency }}</td>
<td class="text-end mono d-mob-none" style="font-size:12px;color:{% if diff == 0 %}#10b981{% else %}var(--muted){% endif %};">
{% if diff == 0 %}exact{% else %}{{ '+' if diff > 0 else '' }}{{ '%.2f' | format(diff) }}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="d-flex gap-2 p-3" style="border-top:1px solid var(--border);">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-link-45deg me-1"></i>Link Selected Payment</button>
<a href="{{ url_for('utilities.bill_pay', id=bill.id) }}" class="btn btn-outline-primary btn-sm">Create New Transaction Instead</a>
<a href="{{ url_for('utilities.provider_detail', id=bill.provider_id) }}" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
</form>
{% else %}
<div class="text-center py-5">
<i class="bi bi-search text-muted" style="font-size:2.5rem;"></i>
<h6 class="mt-3 mb-1">No matching transactions</h6>
<p class="text-muted small mb-3">Nothing unlinked was found near this bill's due date.</p>
<a href="{{ url_for('utilities.bill_pay', id=bill.id) }}" class="btn btn-primary btn-sm">Create a Payment Transaction</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+76
View File
@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}Pay Bill{% endblock %}
{% block page_title %}Mark Bill Paid{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('utilities.provider_detail', id=bill.provider_id) }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← {{ bill.provider.name }}</a>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-5">
<!-- Bill summary -->
<div class="pcard mb-3" style="border-left:4px solid {{ bill.provider.color }};">
<div class="d-flex align-items-center gap-3">
<div style="width:42px;height:42px;border-radius:10px;background:{{ bill.provider.color }}22;color:{{ bill.provider.color }};display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0;">
<i class="bi {{ bill.provider.icon }}"></i>
</div>
<div class="flex-grow-1">
<div style="font-size:14px;font-weight:600;">{{ bill.provider.name }}</div>
<div style="font-size:12px;color:var(--muted);">{{ bill.period_label }}</div>
</div>
<div class="text-end">
<div class="mono fw-bold" style="font-size:20px;">{{ bill.amount | currency }}</div>
{% if bill.due_date %}
<div style="font-size:11px;color:var(--muted);">Due {{ bill.due_date.strftime('%b %d, %Y') }}</div>
{% endif %}
</div>
</div>
{% if bill.usage %}
<div class="d-flex justify-content-between mt-3 pt-3" style="border-top:1px solid var(--border);font-size:12px;">
<span class="text-muted">Usage</span>
<span class="mono">{{ '%.1f' | format(bill.usage | float) }} {{ bill.usage_unit or '' }}
{% if bill.rate_per_unit %}· {{ current_user.currency_symbol }}{{ '%.4f' | format(bill.rate_per_unit) }} per {{ bill.usage_unit or 'unit' }}{% endif %}
</span>
</div>
{% endif %}
</div>
<div class="pcard">
<p class="text-muted" style="font-size:12px;">
This records an expense transaction for the bill amount and links the two together.
If the payment already came in through a bank sync,
<a href="{{ url_for('utilities.bill_link', id=bill.id) }}">link the existing transaction</a> instead to avoid a duplicate.
</p>
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.account_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.account_id(class="form-select" + (" is-invalid" if form.account_id.errors else "")) }}
{% for e in form.account_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="row g-3 mb-4">
<div class="col-7">
{{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.category_id(class="form-select") }}
</div>
<div class="col-5">
{{ form.paid_date.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.paid_date(class="form-control") }}
</div>
</div>
<div class="d-flex gap-2">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('utilities.provider_detail', id=bill.provider_id) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+148
View File
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block page_title %}{{ title }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('utilities.providers') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Providers</a>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="pcard">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="row g-3 mb-3">
<div class="col-12 col-sm-7">
{{ form.name.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.name(class="form-control" + (" is-invalid" if form.name.errors else ""), placeholder="e.g. Pacific Gas & Electric") }}
{% for e in form.name.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="col-12 col-sm-5">
{{ form.utility_type.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.utility_type(class="form-select", id="typeSelect") }}
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-12 col-sm-7">
{{ form.account_number.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.account_number(class="form-control", placeholder="Customer / meter account no.") }}
</div>
<div class="col-6 col-sm-5">
{{ form.billing_day.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.billing_day(class="form-control", type="number", min=1, max=31, placeholder="Day of month") }}
<small class="text-muted" style="font-size:11px;">Day the bill usually arrives</small>
</div>
</div>
<div class="mb-3">
{{ form.usage_unit.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.usage_unit(class="form-control", id="unitInput", placeholder="kWh, m³, GB — leave blank for no usage tracking") }}
<small class="text-muted" style="font-size:11px;">Bills for this provider will record consumption in this unit.</small>
</div>
<div class="row g-3 mb-3">
<div class="col-12 col-sm-6">
{{ form.default_account_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.default_account_id(class="form-select") }}
</div>
<div class="col-12 col-sm-6">
{{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.category_id(class="form-select") }}
</div>
</div>
<div class="mb-3">
{{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.notes(class="form-control", rows=2, placeholder="Plan details, contract end date, support number…") }}
</div>
<!-- Color -->
<div class="mb-3">
<label class="form-label fw-medium" style="font-size:13px;">Color</label>
<div class="d-flex gap-2 flex-wrap">
{% for c in colors %}
<label style="cursor:pointer;">
<input type="radio" name="color" value="{{ c }}" style="display:none;" {% if (provider and provider.color==c) or (not provider and loop.first) %}checked{% endif %}>
<div style="width:26px;height:26px;border-radius:50%;background:{{ c }};border:3px solid transparent;" class="color-swatch" data-color="{{ c }}"></div>
</label>
{% endfor %}
</div>
{{ form.color(type="hidden", id="colorInput") }}
</div>
<!-- Icon -->
<div class="mb-4">
<label class="form-label fw-medium" style="font-size:13px;">Icon</label>
<div class="d-flex gap-2 flex-wrap">
{% for icon_val in icons %}
<label style="cursor:pointer;">
<input type="radio" name="icon" value="{{ icon_val }}" style="display:none;" {% if provider and provider.icon==icon_val %}checked{% elif not provider and loop.first %}checked{% endif %}>
<div style="width:34px;height:34px;border-radius:8px;background:#f1f5f9;display:flex;align-items:center;justify-content:center;font-size:16px;border:2px solid transparent;" class="icon-swatch">
<i class="bi {{ icon_val }}"></i>
</div>
</label>
{% endfor %}
</div>
{{ form.icon(type="hidden", id="iconInput") }}
</div>
<div class="d-flex gap-2">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('utilities.providers') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Suggest a usage unit + color/icon when the utility type changes (new providers only)
const TYPE_DEFAULTS = {{ type_defaults | tojson }};
const isNew = {{ 'false' if provider else 'true' }};
document.getElementById('typeSelect').addEventListener('change', function () {
const d = TYPE_DEFAULTS[this.value];
if (!d) return;
const unit = document.getElementById('unitInput');
if (isNew || !unit.value) unit.value = d.unit;
if (isNew) {
document.getElementById('colorInput').value = d.color;
document.getElementById('iconInput').value = d.icon;
document.querySelectorAll('.color-swatch').forEach(function (s) {
const on = s.dataset.color.toLowerCase() === d.color.toLowerCase();
s.style.borderColor = on ? '#fff' : 'transparent';
s.style.outline = on ? '2px solid ' + d.color : 'none';
});
document.querySelectorAll('.icon-swatch').forEach(function (s) {
const on = s.querySelector('i').className.indexOf(d.icon) !== -1;
s.style.background = on ? '#dbeafe' : '#f1f5f9';
s.style.borderColor = on ? '#3b82f6' : 'transparent';
});
}
});
document.querySelectorAll('.color-swatch').forEach(function (sw) {
const inp = sw.closest('label').querySelector('input');
inp.addEventListener('change', function () {
document.getElementById('colorInput').value = this.value;
document.querySelectorAll('.color-swatch').forEach(s => { s.style.borderColor='transparent'; s.style.outline='none'; });
sw.style.borderColor='#fff'; sw.style.outline='2px solid '+this.value;
});
if (inp.checked) { sw.style.borderColor='#fff'; sw.style.outline='2px solid '+sw.dataset.color; document.getElementById('colorInput').value=sw.dataset.color; }
});
document.querySelectorAll('.icon-swatch').forEach(function (sw) {
const inp = sw.closest('label').querySelector('input');
inp.addEventListener('change', function () {
document.getElementById('iconInput').value = this.value;
document.querySelectorAll('.icon-swatch').forEach(s => { s.style.background='#f1f5f9'; s.style.borderColor='transparent'; });
sw.style.background='#dbeafe'; sw.style.borderColor='#3b82f6';
});
if (inp.checked) { sw.style.background='#dbeafe'; sw.style.borderColor='#3b82f6'; document.getElementById('iconInput').value=inp.value; }
});
</script>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}Utility Providers{% endblock %}
{% block page_title %}Utility Providers{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('utilities.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">← Utilities</a>
<a href="{{ url_for('utilities.provider_new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i><span class="btn-label">Add Provider</span></a>
{% endblock %}
{% block content %}
{% if providers %}
<div class="pcard p-0">
<div class="table-wrap">
<table class="pfm-table wide">
<thead>
<tr>
<th>Provider</th>
<th>Type</th>
<th class="d-mob-none">Account No.</th>
<th class="d-mob-none">Unit</th>
<th class="d-mob-none">Pays From</th>
<th class="text-end">Bills</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for p in providers %}
<tr {% if not p.is_active %}style="opacity:.55;"{% endif %}>
<td>
<div class="d-flex align-items-center gap-2">
<div style="width:30px;height:30px;border-radius:8px;background:{{ p.color }}22;color:{{ p.color }};display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0;">
<i class="bi {{ p.icon }}"></i>
</div>
<div>
<a href="{{ url_for('utilities.provider_detail', id=p.id) }}" style="font-size:13px;font-weight:600;text-decoration:none;color:inherit;">{{ p.name }}</a>
{% if not p.is_active %}<span class="badge bg-secondary" style="font-size:9px;">Archived</span>{% endif %}
{% if p.billing_day %}<div style="font-size:11px;color:var(--muted);">Bills on day {{ p.billing_day }}</div>{% endif %}
</div>
</div>
</td>
<td style="font-size:12px;">{{ p.type_label }}</td>
<td class="d-mob-none mono" style="font-size:12px;color:var(--muted);">{{ p.account_number or '—' }}</td>
<td class="d-mob-none" style="font-size:12px;">{{ p.usage_unit or '—' }}</td>
<td class="d-mob-none" style="font-size:12px;">{{ p.default_account.name if p.default_account else '—' }}</td>
<td class="text-end mono" style="font-size:12px;">{{ counts[p.id] }}</td>
<td class="text-end">
<div class="dropdown">
<button class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;" data-bs-toggle="dropdown"><i class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu dropdown-menu-end" style="font-size:13px;">
<li><a class="dropdown-item" href="{{ url_for('utilities.provider_detail', id=p.id) }}"><i class="bi bi-graph-up me-2"></i>History &amp; Usage</a></li>
<li><a class="dropdown-item" href="{{ url_for('utilities.bill_new', provider_id=p.id) }}"><i class="bi bi-plus-circle me-2"></i>Add Bill</a></li>
<li><a class="dropdown-item" href="{{ url_for('utilities.provider_edit', id=p.id) }}"><i class="bi bi-pencil me-2"></i>Edit</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="POST" action="{{ url_for('utilities.provider_toggle', id=p.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="dropdown-item">
<i class="bi bi-{{ 'arrow-counterclockwise' if not p.is_active else 'archive' }} me-2"></i>{{ 'Reactivate' if not p.is_active else 'Archive' }}
</button>
</form>
</li>
<li>
<form method="POST" action="{{ url_for('utilities.provider_delete', id=p.id) }}"
onsubmit="return confirm('Delete {{ p.name }}{% if counts[p.id] %} and its {{ counts[p.id] }} bill(s){% endif %}? This cannot be undone.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="confirm_bills" value="yes">
<button type="submit" class="dropdown-item text-danger"><i class="bi bi-trash me-2"></i>Delete</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="text-muted mt-3" style="font-size:12px;">
Archiving hides a provider from the utilities dashboard but keeps its bill history. Deleting removes the bills too — payment transactions created by PFM are left in place.
</p>
{% else %}
<div class="pcard text-center py-5">
<i class="bi bi-building text-muted" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">No providers yet</h5>
<p class="text-muted small mb-3">Add electricity, water, gas, and internet providers to start tracking.</p>
<a href="{{ url_for('utilities.provider_new') }}" class="btn btn-primary btn-sm">Add First Provider</a>
</div>
{% endif %}
{% endblock %}