237 lines
9.3 KiB
Python
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}'))
|