Spaces:
Running on Zero
Running on Zero
File size: 6,902 Bytes
bfd6783 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | """Telegram message rendering.
Uses Telegram's **HTML** parse mode rather than MarkdownV2. MarkdownV2 requires
escaping ``. - ( ) ! +`` and more, which appear in virtually every line of
financial output (``₹1,234.56``, ``-12%``, ``(30%)``); one missed escape and
Telegram rejects the whole message. HTML needs only ``< > &`` escaped, so the
failure surface is far smaller.
"""
from __future__ import annotations
import html
from datetime import date
from typing import Iterable
from .analytics import BudgetStatus, Recurring
from .categories import label as category_label
from .models import Expense, PendingImport, Summary
from .money import format_money
BAR_WIDTH = 10
def escape(text: str) -> str:
return html.escape(str(text or ""), quote=False)
def bar(fraction: float, width: int = BAR_WIDTH) -> str:
filled = max(0, min(width, round(fraction * width)))
return "█" * filled + "░" * (width - filled)
def money(minor: int, currency: str) -> str:
return escape(format_money(minor, currency))
def expense_line(expense: Expense) -> str:
label = expense.merchant or expense.description or "unlabelled"
return (
f"{money(expense.amount_minor, expense.currency)} · {escape(label)} "
f"<i>{escape(category_label(expense.category))}</i> · "
f"{expense.occurred_on.isoformat()}"
)
def logged(expense: Expense, note: str = "") -> str:
parts = [
f"✅ Logged {money(expense.amount_minor, expense.currency)} — "
f"{escape(expense.description or expense.merchant or 'unlabelled')}",
f"<i>{escape(category_label(expense.category))}</i> · {expense.occurred_on.isoformat()}",
]
if note:
parts.append(escape(note))
parts.append("<i>/undo to remove</i>")
return "\n".join(parts)
def summary(summ: Summary) -> str:
if summ.count == 0:
return (
f"<b>{escape(summ.label)}</b>\nNothing logged "
f"({summ.start.isoformat()} → {summ.end.isoformat()})."
)
lines = [
f"<b>{escape(summ.label)}</b> · {money(summ.total_minor, summ.currency)} "
f"<i>({summ.count} txns)</i>"
]
if (delta := summ.delta_pct) is not None:
arrow = "▲" if delta > 0 else "▼"
lines.append(
f"{arrow} {abs(delta):.0f}% vs previous "
f"({money(summ.previous_total_minor or 0, summ.currency)})"
)
lines.append("")
top = summ.by_category[:8]
for name, amount in top:
fraction = amount / summ.total_minor if summ.total_minor else 0
lines.append(
f"<code>{bar(fraction)}</code> {escape(category_label(name))} "
f"{money(amount, summ.currency)} <i>({fraction * 100:.0f}%)</i>"
)
if len(summ.by_category) > len(top):
rest = sum(amount for _, amount in summ.by_category[len(top):])
lines.append(f"<i>+{len(summ.by_category) - len(top)} more · {money(rest, summ.currency)}</i>")
return "\n".join(lines)
def expense_list(expenses: Iterable[Expense], title: str, limit: int = 20) -> str:
items = list(expenses)[:limit]
if not items:
return f"<b>{escape(title)}</b>\nNothing to show."
lines = [f"<b>{escape(title)}</b>"]
lines.extend(f"• {expense_line(e)}" for e in items)
return "\n".join(lines)
def pending_import(pending: PendingImport) -> str:
totals = pending.total_by_currency()
total_str = " + ".join(money(v, k) for k, v in sorted(totals.items()))
lines = [
f"<b>Found {len(pending.drafts)} transactions</b> in that "
f"{escape(pending.source.upper())} · {total_str}",
]
detail = []
if pending.duplicates:
detail.append(f"{pending.duplicates} already logged (skipped)")
if pending.skipped:
detail.append(f"{pending.skipped} unreadable")
if pending.label:
detail.append(pending.label)
if detail:
lines.append(f"<i>{escape(' · '.join(detail))}</i>")
lines.append("")
preview = pending.drafts[:8]
for draft in preview:
lines.append(
f"• {money(draft.amount_minor, draft.currency)} · "
f"{escape(draft.description[:40])} · "
f"<i>{escape(category_label(draft.category))}</i> · "
f"{draft.occurred_on.isoformat()}"
)
if len(pending.drafts) > len(preview):
lines.append(f"<i>…and {len(pending.drafts) - len(preview)} more</i>")
lines.append("")
lines.append("These were read by a model and <b>not saved yet</b>.")
lines.append("/confirm to save · /cancel to discard")
return "\n".join(lines)
def recurring_list(items: list[Recurring], currency: str) -> str:
if not items:
return (
"<b>Recurring charges</b>\nNone detected yet — this needs a few "
"months of history to spot a pattern."
)
lines = ["<b>Recurring charges</b>"]
for item in items[:15]:
lines.append(
f"• {money(item.typical_minor, item.currency)}/mo · {escape(item.label)} "
f"<i>({item.months} months, last {item.last_seen.isoformat()})</i>"
)
annual = sum(r.annualised_minor for r in items if r.currency == currency)
if annual:
lines.append("")
lines.append(f"<b>≈ {money(annual, currency)} a year</b> if all continue.")
return "\n".join(lines)
def budget_list(statuses: list[BudgetStatus]) -> str:
if not statuses:
return (
"<b>Budgets</b>\nNone set.\n"
"<code>/budget food_dining 8000</code> to set one."
)
lines = ["<b>Budgets this month</b>"]
for status in statuses:
mark = "⚠️" if status.over else ("🟡" if status.pct >= 80 else "🟢")
lines.append(
f"{mark} <code>{bar(status.pct / 100)}</code> "
f"{escape(category_label(status.category))} "
f"{money(status.spent_minor, status.currency)}/"
f"{money(status.limit_minor, status.currency)} "
f"<i>({status.pct:.0f}%)</i>"
)
return "\n".join(lines)
def csv_export(expenses: list[Expense]) -> str:
import csv
import io
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(
["date", "amount", "currency", "category", "merchant", "description", "source"]
)
for expense in expenses:
from .money import from_minor
writer.writerow(
[
expense.occurred_on.isoformat(),
f"{from_minor(expense.amount_minor, expense.currency):.2f}",
expense.currency,
expense.category,
expense.merchant,
expense.description,
expense.source,
]
)
return buffer.getvalue()
def period_footer(start: date, end: date) -> str:
return f"<i>{start.isoformat()} → {end.isoformat()}</i>"
|