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