Files
Personal-Finance-Management/app/services/utility_service.py
T

237 lines
9.3 KiB
Python

"""
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}'))