spacedout-bits commited on
Commit
bfd6783
·
verified ·
1 Parent(s): 9b43357

Deploy coco-finbot

Browse files
.env.example ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # coco-finbot configuration.
2
+ # Locally: copy to .env. On Hugging Face: set these under Space Settings > Secrets.
3
+ # Never commit the real .env.
4
+
5
+ # --- Telegram -------------------------------------------------------------
6
+ # From @BotFather.
7
+ TELEGRAM_BOT_TOKEN=
8
+
9
+ # Random string you invent. Telegram echoes it back in the
10
+ # X-Telegram-Bot-Api-Secret-Token header; requests without it are rejected.
11
+ # Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"
12
+ TELEGRAM_WEBHOOK_SECRET=
13
+
14
+ # Comma-separated Telegram numeric user IDs allowed to use the bot.
15
+ # Leave EMPTY to allow nobody (safe default -- the bot replies with a refusal
16
+ # and tells the sender their id, so you can read your own id off the reply).
17
+ # Ignored when OPEN_ACCESS=true.
18
+ ALLOWED_TELEGRAM_USER_IDS=
19
+
20
+ # Let ANYONE on Telegram use the bot. Each user gets their own isolated ledger,
21
+ # so this makes the bot multi-tenant -- it does NOT expose your spending to
22
+ # other people. What it does expose is your wallet: every stranger's receipt
23
+ # and statement is read using YOUR HF_TOKEN. Cap that below.
24
+ # Must be set explicitly, so an unconfigured allowlist stays closed.
25
+ OPEN_ACCESS=false
26
+
27
+ # Model-backed uploads (receipt photos, statements) per user per day.
28
+ # Typed expenses are parsed locally and are never counted. 0 disables the cap.
29
+ UPLOADS_PER_USER_PER_DAY=20
30
+
31
+ # --- Inference (Hugging Face Inference Providers) --------------------------
32
+ # A HF token with "Make calls to Inference Providers" permission.
33
+ # On a Space you can leave this unset and the bot still works -- it falls back
34
+ # to the deterministic parser and skips receipt/statement understanding.
35
+ HF_TOKEN=
36
+
37
+ # OpenAI-compatible base URL. HF's router fans one token out to many providers.
38
+ LLM_BASE_URL=https://router.huggingface.co/v1
39
+
40
+ # Text model used for extraction, categorisation and advice.
41
+ LLM_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
42
+
43
+ # Vision model used for receipt photos. Must accept image_url content parts.
44
+ LLM_VISION_MODEL=Qwen/Qwen2.5-VL-72B-Instruct
45
+
46
+ # --- Durable storage ------------------------------------------------------
47
+ # Spaces disk is EPHEMERAL -- it is wiped on every restart, sleep or rebuild.
48
+ # The bot keeps SQLite locally and mirrors it to a PRIVATE dataset repo so your
49
+ # data survives. Set to a repo you own, e.g. "your-username/finbot-data".
50
+ # Leave empty to run purely local (fine for dev; DATA LOSS on a Space).
51
+ HF_DATASET_REPO=
52
+
53
+ # How often (minutes) to push the database to the dataset repo.
54
+ SYNC_EVERY_MINUTES=5
55
+
56
+ # Where the local SQLite file lives inside the container.
57
+ DATA_DIR=/home/user/data
58
+
59
+ # --- Behaviour ------------------------------------------------------------
60
+ # Default currency for bare amounts like "coffee 250". ISO 4217 code.
61
+ DEFAULT_CURRENCY=INR
62
+
63
+ # IANA timezone used to decide what "today" and "this month" mean.
64
+ TIMEZONE=Asia/Kolkata
.gitignore ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Secrets -- never commit. On Spaces these come from Settings > Secrets.
2
+ .env
3
+ *.env
4
+ !.env.example
5
+
6
+ # Local SQLite working copy and any restored snapshots
7
+ data/
8
+ *.db
9
+ *.db-journal
10
+ *.db-wal
11
+ *.db-shm
12
+
13
+ # Python
14
+ __pycache__/
15
+ *.py[cod]
16
+ .venv/
17
+ venv/
18
+ .pytest_cache/
19
+ .ruff_cache/
20
+ *.egg-info/
21
+
22
+ # Editor / OS
23
+ .DS_Store
24
+ .idea/
25
+ .vscode/
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Hugging Face Spaces runs containers as uid 1000. Create a matching user so the
4
+ # app owns its own HOME -- the SQLite working copy lives under $HOME/data.
5
+ RUN useradd -m -u 1000 user
6
+
7
+ ENV HOME=/home/user \
8
+ PATH=/home/user/.local/bin:$PATH \
9
+ PYTHONUNBUFFERED=1 \
10
+ PYTHONDONTWRITEBYTECODE=1
11
+
12
+ WORKDIR $HOME/app
13
+
14
+ COPY --chown=user:user requirements.txt .
15
+
16
+ USER user
17
+ RUN pip install --no-cache-dir --upgrade pip \
18
+ && pip install --no-cache-dir -r requirements.txt
19
+
20
+ COPY --chown=user:user . .
21
+
22
+ # Spaces routes public traffic to app_port (see README.md frontmatter).
23
+ EXPOSE 7860
24
+
25
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,15 +1,248 @@
1
  ---
2
  title: Oracle
3
- emoji: 👁
4
- colorFrom: indigo
5
- colorTo: blue
6
- sdk: gradio
7
- sdk_version: 6.24.0
8
- python_version: '3.12'
9
- app_file: app.py
10
  pinned: false
11
- license: mit
12
- short_description: Oracle for personal use
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Oracle
3
+ emoji: 💸
4
+ colorFrom: green
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
 
 
8
  pinned: false
9
+ short_description: Expense tracking and budget coaching over Telegram
 
10
  ---
11
 
12
+ # coco-finbot
13
+
14
+ > Deployed as the **Oracle** Space. `sdk: docker` above is what switches the
15
+ > Space off Gradio — do not drop it.
16
+
17
+ A personal finance bot that lives in Telegram and runs as a Hugging Face Docker
18
+ Space. Tell it what you spent, send it receipts and bank statements, and ask it
19
+ what your money is doing.
20
+
21
+ ```
22
+ you ▸ coffee 250
23
+ bot ▸ ✅ Logged ₹250.00 — coffee
24
+ Food & Dining · 2026-08-15
25
+
26
+ you ▸ [photo of a restaurant bill]
27
+ bot ▸ ✅ Logged ₹1,840.00 — Toit Brewpub
28
+ Food & Dining · 2026-08-14
29
+
30
+ you ▸ [statement.pdf]
31
+ bot ▸ Found 47 transactions in that PDF · ₹68,204.00
32
+ 3 already logged (skipped)
33
+ These were read by a model and not saved yet.
34
+ /confirm to save · /cancel to discard
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Read this before you deploy
40
+
41
+ **Space disk is ephemeral.** Hugging Face wipes it on every restart, sleep and
42
+ rebuild. If you do not set `HF_DATASET_REPO`, your entire ledger disappears the
43
+ first time the Space sleeps — silently, because nothing errors. The bot mirrors
44
+ SQLite to a **private** dataset repo on a timer and restores it on boot. `/status`
45
+ tells you which mode you are in.
46
+
47
+ **Make the Space private.** This is your spending history. A public Space exposes
48
+ the container, its logs and its `/health` endpoint to anyone.
49
+
50
+ **Decide who can use it.** `ALLOWED_TELEGRAM_USER_IDS` starts empty, which
51
+ authorises nobody — a forgotten allowlist stays closed rather than silently
52
+ opening to the world.
53
+
54
+ Set `OPEN_ACCESS=true` to let anyone message the bot. Ledgers are keyed by
55
+ Telegram user id, so open access makes the bot **multi-tenant**: each person
56
+ gets their own private ledger and nobody can see yours. What open access does
57
+ expose is your **wallet** — every stranger's receipt and statement is read using
58
+ your `HF_TOKEN`. `UPLOADS_PER_USER_PER_DAY` (default 20) caps that; typed
59
+ expenses are parsed locally and never metered. Note also that running an open
60
+ bot makes you the custodian of other people's financial data in your dataset
61
+ repo.
62
+
63
+ ---
64
+
65
+ ## Deploy
66
+
67
+ **1. Create the bot.** Message [@BotFather](https://t.me/BotFather) → `/newbot` →
68
+ keep the token.
69
+
70
+ **2. Push the Space and dataset.** `deploy.sh` creates both (private, Docker SDK)
71
+ and uploads the source. It takes the owner from your logged-in account:
72
+
73
+ ```bash
74
+ ./.venv/bin/hf auth login && ./deploy.sh
75
+ ```
76
+
77
+ ```bash
78
+ ./deploy.sh --dry-run # show the target repos, create nothing
79
+ ./deploy.sh Uniphy # deploy under an org instead of your account
80
+ ```
81
+
82
+ > Use this rather than `git push`. This directory lives inside the larger UniPhy
83
+ > repo, so a `git push` from here would send that entire tree — unrelated repos
84
+ > and any secrets they hold — to a public-by-default host. `deploy.sh` uploads
85
+ > only this folder, minus `.venv`, `.env`, caches and any local database.
86
+
87
+ **3. Set secrets** under Space Settings → Secrets (use the UI, not the CLI, so
88
+ they stay out of your shell history):
89
+
90
+ | Secret | Required | What it does |
91
+ | --- | --- | --- |
92
+ | `TELEGRAM_BOT_TOKEN` | ✅ | From BotFather |
93
+ | `TELEGRAM_WEBHOOK_SECRET` | ✅ | Any random string; rejects forged webhook calls |
94
+ | `ALLOWED_TELEGRAM_USER_IDS` | ✅ | Your numeric Telegram ID (comma-separated for more). Not needed if `OPEN_ACCESS=true` |
95
+ | `OPEN_ACCESS` | | `true` lets anyone use the bot, each with their own ledger. Default `false` |
96
+ | `UPLOADS_PER_USER_PER_DAY` | | Caps model-backed uploads per user. Default `20`, `0` disables |
97
+ | `HF_DATASET_REPO` | ✅ | `<you>/finbot-data` — **without this your data is lost on restart** |
98
+ | `HF_TOKEN` | ⚠️ | Write-scoped token with Inference Providers access. Without it, receipts/statements/advice are off; typing expenses still works |
99
+ | `DEFAULT_CURRENCY` | | ISO code for bare amounts. Default `INR` |
100
+ | `TIMEZONE` | | Decides what "today" means. Default `Asia/Kolkata` |
101
+ | `LLM_MODEL` / `LLM_VISION_MODEL` | | Override the models |
102
+
103
+ Generate a webhook secret with:
104
+
105
+ ```bash
106
+ python3 -c "import secrets; print(secrets.token_urlsafe(32))"
107
+ ```
108
+
109
+ **4. Restart the Space** so it picks up the secrets. On boot it reads
110
+ `SPACE_HOST` and registers its own Telegram webhook — nothing else to wire up.
111
+
112
+ **5. Message the bot.** Don't know your Telegram ID? Send it anything: it
113
+ replies with the ID to paste into `ALLOWED_TELEGRAM_USER_IDS`. Then send
114
+ `/status` and confirm storage reads *mirroring*, not *local only*.
115
+
116
+ ---
117
+
118
+ ## Using it
119
+
120
+ Anything with a number in it is logged. No command needed.
121
+
122
+ ```
123
+ coffee 250
124
+ ₹1,240 groceries at bigbasket
125
+ 45.50 lunch yesterday
126
+ 320 uber #transport ← #tag overrides the category guess
127
+ €40 dinner 12 aug
128
+ ```
129
+
130
+ Send a **photo** of a receipt and a vision model reads the total. Send a
131
+ **PDF/CSV/XLSX** statement and it extracts the transactions, drops any already
132
+ in your ledger, and shows you the total before saving — nothing model-extracted
133
+ is written until you `/confirm`.
134
+
135
+ | Command | |
136
+ | --- | --- |
137
+ | `/today` `/week` `/month` | Spending summaries with a category breakdown |
138
+ | `/report <period>` | `today`, `week`, `month`, `last_month`, `30d`, `year`, `all` |
139
+ | `/list [n]` | Recent transactions |
140
+ | `/recurring` | Subscriptions and repeat charges, with the annual cost |
141
+ | `/advice [question]` | Observations grounded in your own numbers |
142
+ | `/budget <category> <amount>` | Set a monthly cap |
143
+ | `/budgets` | How you're tracking |
144
+ | `/categories` | Category names for tags and budgets |
145
+ | `/undo` | Remove the last entry, or the last import wholesale |
146
+ | `/export` | Everything as CSV |
147
+ | `/status` | Config and storage health |
148
+
149
+ ---
150
+
151
+ ## How it works
152
+
153
+ ```
154
+ Telegram ──webhook──▶ FastAPI (:7860)
155
+ │
156
+ ├─ registry ──▶ features: expenses │ budgets │ coaching │ core
157
+ │ │
158
+ │ ├─ parser deterministic, free, first
159
+ │ ├─ extract CSV · XLSX · PDF
160
+ │ └─ llm ──▶ router.huggingface.co/v1
161
+ │
162
+ └─ store: SQLite ──CommitScheduler──▶ private HF dataset
163
+ ```
164
+
165
+ **Deterministic first, model second.** The regex parser handles `coffee 250`
166
+ instantly and identically every time. Inference is spent only where judgement
167
+ is genuinely needed: reading a receipt, or working out which column of an
168
+ unfamiliar bank export holds the amount. If `HF_TOKEN` is unset or a provider is
169
+ down, text logging and every summary still work.
170
+
171
+ **Money is never a float.** Amounts are integer minor units (paise, cents, fils)
172
+ plus an ISO 4217 code. Currencies are never summed together — a multi-currency
173
+ month reports one total per currency rather than a fictional combined figure.
174
+
175
+ **Statements are staged, not trusted.** A model that misreads a column would
176
+ otherwise quietly poison months of history, so imports become a pending batch
177
+ you confirm, tagged with a `batch_id` so `/undo` can reverse the whole thing.
178
+
179
+ **Inference is provider-agnostic.** `LLM_BASE_URL` is an OpenAI-compatible
180
+ endpoint. It defaults to HF's router — one token, many providers — but points
181
+ just as happily at Ollama, llama.cpp or OpenAI.
182
+
183
+ ---
184
+
185
+ ## Adding a feature
186
+
187
+ The registry is the extension point. A feature declares commands and optionally
188
+ claims plain messages or uploaded files; the first one to return a `Reply` wins.
189
+ Nothing else in the codebase changes.
190
+
191
+ ```python
192
+ from finbot.features.base import BotContext, CommandSpec, Reply, SimpleFeature
193
+
194
+ class RemindersFeature(SimpleFeature):
195
+ name = "reminders"
196
+ description = "⏰ Reminders"
197
+
198
+ def commands(self):
199
+ return (CommandSpec("remind", "Set a reminder", "<when> <what>"),)
200
+
201
+ def cmd_remind(self, ctx: BotContext) -> Reply:
202
+ return Reply(f"Noted: {ctx.args}")
203
+ ```
204
+
205
+ Add it to `default_features()` in `finbot/features/__init__.py`. `/help` and
206
+ Telegram's command menu build themselves from what is registered, and duplicate
207
+ commands raise at startup rather than shadowing silently.
208
+
209
+ ---
210
+
211
+ ## Development
212
+
213
+ ```bash
214
+ python3.11 -m venv .venv && ./.venv/bin/pip install -r requirements.txt pytest
215
+ cp .env.example .env # fill in what you need
216
+ ./.venv/bin/python -m pytest tests/ -q
217
+ ./.venv/bin/uvicorn app:app --reload --port 7860
218
+ ```
219
+
220
+ To test against real Telegram locally, expose port 7860 with a tunnel and
221
+ register the webhook:
222
+
223
+ ```bash
224
+ curl -X POST localhost:7860/admin/setup -H "X-Admin-Secret: $TELEGRAM_WEBHOOK_SECRET" -H 'Content-Type: application/json' -d '{"url":"https://<your-tunnel>/telegram/webhook"}'
225
+ ```
226
+
227
+ 268 tests cover the pure logic — money arithmetic, parsing, categorisation,
228
+ analytics, the registry contract — plus an end-to-end pass over the webhook with
229
+ a stubbed Telegram transport. None of them touch the network.
230
+
231
+ ---
232
+
233
+ ## Known limits
234
+
235
+ - **Credits are not stored.** Statement imports keep debits only; incoming money
236
+ is counted and reported but not written, because mixing it into the same table
237
+ would corrupt every "total spent" figure. Income tracking is a real feature,
238
+ not a small one.
239
+ - **Scanned PDFs** have no text layer. The bot detects this and tells you to send
240
+ it as a photo instead.
241
+ - **Legacy `.xls`** is not supported — re-save as `.xlsx` or CSV.
242
+ - **20 MB** is Telegram's own cap on bot file downloads.
243
+ - **Pending imports live in memory**, so a Space restart between the preview and
244
+ `/confirm` drops them. Re-send the file.
245
+ - **Recurring detection is lexical.** It unifies `NETFLIX.COM 447192` with
246
+ `Netflix`, but not `AMZN Mktp` with `Amazon`.
247
+ - **Advice is budgeting only.** It comments on your logged spending and is
248
+ explicitly prevented from recommending investments or giving tax advice.
app.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI entrypoint -- the Hugging Face Space serves this on port 7860.
2
+
3
+ The webhook answers Telegram immediately and does the real work in a background
4
+ task. Reading a statement can take a minute; holding the webhook open that long
5
+ makes Telegram time out and redeliver, which would import the same file twice.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ from contextlib import asynccontextmanager
13
+ from typing import Optional
14
+
15
+ from fastapi import BackgroundTasks, FastAPI, Header, Request
16
+ from fastapi.concurrency import run_in_threadpool
17
+ from fastapi.responses import HTMLResponse, JSONResponse
18
+
19
+ from finbot import __version__
20
+ from finbot.advice import Advisor
21
+ from finbot.analytics import today_in
22
+ from finbot.config import get_settings
23
+ from finbot.features import default_features
24
+ from finbot.features.base import Attachment, BotContext, Services
25
+ from finbot.ingest import Ingestor
26
+ from finbot.limits import RateLimiter
27
+ from finbot.llm import LLMClient
28
+ from finbot.registry import FeatureRegistry
29
+ from finbot.store import open_store
30
+ from finbot.telegram import (
31
+ MAX_DOWNLOAD_BYTES,
32
+ ParsedUpdate,
33
+ SeenUpdates,
34
+ TelegramClient,
35
+ parse_update,
36
+ )
37
+
38
+ logging.basicConfig(
39
+ level=os.environ.get("LOG_LEVEL", "INFO"),
40
+ format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
41
+ )
42
+ log = logging.getLogger("finbot")
43
+
44
+ WEBHOOK_PATH = "/telegram/webhook"
45
+
46
+
47
+ def build_services() -> Services:
48
+ settings = get_settings()
49
+ registry = FeatureRegistry()
50
+ llm = LLMClient(settings)
51
+
52
+ services = Services(
53
+ settings=settings,
54
+ store=open_store(settings),
55
+ llm=llm,
56
+ ingestor=Ingestor(llm, settings.default_currency),
57
+ advisor=Advisor(llm),
58
+ registry=registry,
59
+ )
60
+ registry.register_all(default_features())
61
+ return services
62
+
63
+
64
+ def space_webhook_url() -> str:
65
+ """Public URL of this Space, if we are running on one."""
66
+ if host := os.environ.get("SPACE_HOST"):
67
+ return f"https://{host.rstrip('/')}{WEBHOOK_PATH}"
68
+ return ""
69
+
70
+
71
+ @asynccontextmanager
72
+ async def lifespan(app: FastAPI):
73
+ services = build_services()
74
+ settings = services.settings
75
+ telegram = TelegramClient(settings.telegram_bot_token)
76
+
77
+ app.state.services = services
78
+ app.state.telegram = telegram
79
+ app.state.seen = SeenUpdates()
80
+ app.state.limiter = RateLimiter(settings.uploads_per_user_per_day)
81
+
82
+ for gap in settings.missing_required():
83
+ log.warning("Not configured: %s", gap)
84
+
85
+ if settings.open_access:
86
+ log.warning(
87
+ "OPEN ACCESS: anyone on Telegram may use this bot. Ledgers are "
88
+ "isolated per user, but uploads spend this Space's inference "
89
+ "credits (capped at %s/user/day).",
90
+ settings.uploads_per_user_per_day or "unlimited",
91
+ )
92
+
93
+ if telegram.enabled:
94
+ try:
95
+ me = await telegram.get_me()
96
+ log.info("Connected to Telegram as @%s", me.get("username"))
97
+ await telegram.set_my_commands(services.registry.telegram_commands())
98
+
99
+ if (url := space_webhook_url()) and settings.telegram_webhook_secret:
100
+ await telegram.set_webhook(url, settings.telegram_webhook_secret)
101
+ log.info("Webhook registered at %s", url)
102
+ elif not settings.telegram_webhook_secret:
103
+ log.warning("TELEGRAM_WEBHOOK_SECRET unset; webhook not registered.")
104
+ else:
105
+ log.info(
106
+ "No SPACE_HOST; register the webhook manually via POST /admin/setup"
107
+ )
108
+ except Exception as exc:
109
+ # A Telegram hiccup must not stop the Space from booting -- a live
110
+ # container that reports the problem beats a crash loop.
111
+ log.error("Telegram bootstrap failed: %s", exc)
112
+ else:
113
+ log.warning("TELEGRAM_BOT_TOKEN unset; running without a bot connection.")
114
+
115
+ try:
116
+ yield
117
+ finally:
118
+ log.info("Shutting down; flushing ledger to the Hub.")
119
+ await telegram.close()
120
+ services.llm.close()
121
+ await run_in_threadpool(services.store.close)
122
+
123
+
124
+ app = FastAPI(title="coco-finbot", version=__version__, lifespan=lifespan)
125
+
126
+
127
+ # --------------------------------------------------------------------- pages
128
+
129
+
130
+ @app.get("/", response_class=HTMLResponse)
131
+ async def index() -> str:
132
+ """Deliberately stateless.
133
+
134
+ This Space may be public, and transaction counts, configuration gaps and
135
+ sync state are personal operational detail. They are available to the owner
136
+ over Telegram via /status, and here only behind /admin/diagnostics.
137
+ """
138
+ return f"""<!doctype html>
139
+ <meta charset="utf-8"><title>Oracle</title>
140
+ <style>
141
+ body {{ font: 16px/1.6 system-ui, sans-serif; max-width: 40rem;
142
+ margin: 4rem auto; padding: 0 1rem; }}
143
+ a {{ color: inherit; }}
144
+ </style>
145
+ <h1>💸 Oracle</h1>
146
+ <p>A private expense-tracking and budget-coaching bot. It runs on Telegram —
147
+ there is nothing to use on this page.</p>
148
+ <p>Built with <code>coco-finbot</code> v{__version__}.</p>
149
+ """
150
+
151
+
152
+ @app.get("/health")
153
+ async def health() -> JSONResponse:
154
+ """Liveness only. No ledger or configuration detail — this may be public."""
155
+ return JSONResponse({"status": "ok", "version": __version__})
156
+
157
+
158
+ @app.get("/admin/diagnostics")
159
+ async def diagnostics(
160
+ x_admin_secret: Optional[str] = Header(default=None),
161
+ ) -> JSONResponse:
162
+ """Operational detail, gated on the webhook secret."""
163
+ services: Services = app.state.services
164
+ settings = services.settings
165
+
166
+ if not settings.telegram_webhook_secret or x_admin_secret != settings.telegram_webhook_secret:
167
+ return JSONResponse({"ok": False, "error": "forbidden"}, status_code=403)
168
+
169
+ return JSONResponse(
170
+ {
171
+ "ok": True,
172
+ "version": __version__,
173
+ "llm_enabled": settings.llm_enabled,
174
+ "sync_enabled": settings.sync_enabled,
175
+ "sync_status": services.store.sync_status,
176
+ "rows": services.store.count(),
177
+ "missing": settings.missing_required(),
178
+ }
179
+ )
180
+
181
+
182
+ # ------------------------------------------------------------------ webhook
183
+
184
+
185
+ @app.post(WEBHOOK_PATH)
186
+ async def telegram_webhook(
187
+ request: Request,
188
+ background: BackgroundTasks,
189
+ x_telegram_bot_api_secret_token: Optional[str] = Header(default=None),
190
+ ) -> JSONResponse:
191
+ services: Services = app.state.services
192
+ settings = services.settings
193
+
194
+ expected = settings.telegram_webhook_secret
195
+ if not expected or x_telegram_bot_api_secret_token != expected:
196
+ log.warning("Rejected webhook call with a bad secret token.")
197
+ return JSONResponse({"ok": False}, status_code=403)
198
+
199
+ try:
200
+ update = await request.json()
201
+ except Exception:
202
+ return JSONResponse({"ok": False, "error": "bad json"}, status_code=400)
203
+
204
+ telegram: TelegramClient = app.state.telegram
205
+ parsed = parse_update(update, telegram.username)
206
+ if parsed is None:
207
+ return JSONResponse({"ok": True, "skipped": "unsupported update"})
208
+
209
+ if not app.state.seen.add_if_new(parsed.update_id):
210
+ log.info("Ignoring redelivered update %s", parsed.update_id)
211
+ return JSONResponse({"ok": True, "skipped": "duplicate"})
212
+
213
+ # Answer now, work later: Telegram redelivers if we hold this open.
214
+ background.add_task(handle_update, parsed)
215
+ return JSONResponse({"ok": True})
216
+
217
+
218
+ async def handle_update(parsed: ParsedUpdate) -> None:
219
+ services: Services = app.state.services
220
+ telegram: TelegramClient = app.state.telegram
221
+ settings = services.settings
222
+
223
+ if not settings.is_authorised(parsed.user_id):
224
+ log.warning("Unauthorised user %s attempted access", parsed.user_id)
225
+ await telegram.send_message(
226
+ parsed.chat_id,
227
+ "This is a private bot and you're not on its allowlist.\n\n"
228
+ f"If it's yours, add <code>{parsed.user_id}</code> to "
229
+ "<code>ALLOWED_TELEGRAM_USER_IDS</code> in the Space secrets "
230
+ "and restart it.",
231
+ )
232
+ return
233
+
234
+ try:
235
+ attachment = None
236
+ if parsed.has_file:
237
+ if parsed.file_size > MAX_DOWNLOAD_BYTES:
238
+ await telegram.send_message(
239
+ parsed.chat_id,
240
+ "That file is over Telegram's 20 MB bot limit. "
241
+ "Try exporting a narrower date range.",
242
+ )
243
+ return
244
+
245
+ # Reading files costs the Space owner real inference credits, so
246
+ # cap it per user. Typing expenses stays unmetered and free.
247
+ if not app.state.limiter.check(parsed.user_id):
248
+ await telegram.send_message(
249
+ parsed.chat_id,
250
+ "You've hit today's limit for reading receipts and "
251
+ "statements. It resets in 24 hours — you can still log "
252
+ "expenses by typing them.",
253
+ )
254
+ return
255
+
256
+ await telegram.send_typing(parsed.chat_id)
257
+ data, file_path = await telegram.download(parsed.file_id)
258
+ attachment = Attachment(
259
+ data=data,
260
+ filename=parsed.file_name or file_path.rsplit("/", 1)[-1],
261
+ mime_type=parsed.mime_type,
262
+ kind=parsed.file_kind,
263
+ caption=parsed.caption,
264
+ )
265
+
266
+ ctx = BotContext(
267
+ user_id=parsed.user_id,
268
+ chat_id=parsed.chat_id,
269
+ services=services,
270
+ today=today_in(settings.timezone),
271
+ text=parsed.text or parsed.caption,
272
+ command=parsed.command,
273
+ args=parsed.args,
274
+ attachment=attachment,
275
+ )
276
+
277
+ if attachment is not None or parsed.command in {"advice", "report"}:
278
+ await telegram.send_typing(parsed.chat_id)
279
+
280
+ reply = await run_in_threadpool(services.registry.dispatch, ctx)
281
+
282
+ if reply is None:
283
+ if not parsed.command and ctx.text.strip():
284
+ await telegram.send_message(
285
+ parsed.chat_id,
286
+ "I couldn't find an amount in that. Try "
287
+ "<code>250 coffee</code>, or /help.",
288
+ )
289
+ return
290
+
291
+ if reply.document is not None:
292
+ filename, payload = reply.document
293
+ await telegram.send_document(
294
+ parsed.chat_id, filename, payload, caption=reply.text
295
+ )
296
+ return
297
+
298
+ await telegram.send_message(parsed.chat_id, reply.text, reply.parse_mode)
299
+
300
+ except Exception:
301
+ log.exception("Failed handling update %s", parsed.update_id)
302
+ try:
303
+ await telegram.send_message(
304
+ parsed.chat_id,
305
+ "Something went wrong on my side. The details are in the Space logs.",
306
+ )
307
+ except Exception:
308
+ pass
309
+
310
+
311
+ # -------------------------------------------------------------------- admin
312
+
313
+
314
+ @app.post("/admin/setup")
315
+ async def admin_setup(
316
+ request: Request,
317
+ x_admin_secret: Optional[str] = Header(default=None),
318
+ ) -> JSONResponse:
319
+ """Register the webhook manually (local dev behind a tunnel, or no SPACE_HOST).
320
+
321
+ Authorised with the same webhook secret, so no extra credential to manage.
322
+ """
323
+ services: Services = app.state.services
324
+ settings = services.settings
325
+ telegram: TelegramClient = app.state.telegram
326
+
327
+ if not settings.telegram_webhook_secret or x_admin_secret != settings.telegram_webhook_secret:
328
+ return JSONResponse({"ok": False, "error": "forbidden"}, status_code=403)
329
+
330
+ body = {}
331
+ try:
332
+ body = await request.json()
333
+ except Exception:
334
+ pass
335
+
336
+ url = body.get("url") or space_webhook_url()
337
+ if not url:
338
+ return JSONResponse(
339
+ {"ok": False, "error": "no url given and SPACE_HOST is unset"},
340
+ status_code=400,
341
+ )
342
+
343
+ await telegram.set_webhook(url, settings.telegram_webhook_secret)
344
+ await telegram.set_my_commands(services.registry.telegram_commands())
345
+ info = await telegram.get_webhook_info()
346
+ return JSONResponse({"ok": True, "webhook": info})
deploy.sh ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Push coco-finbot to a Hugging Face Space.
4
+ #
5
+ # Uses `hf upload` rather than git, deliberately: this directory sits inside the
6
+ # larger UniPhy repo, so `git push` here would try to push that entire tree --
7
+ # unrelated repos and any secrets they hold -- to a public-by-default host.
8
+ #
9
+ # Run `hf auth login` first. Secrets are NOT set here; put them in the Space
10
+ # Settings > Secrets UI so they never land in your shell history.
11
+ #
12
+ # Usage:
13
+ # ./deploy.sh # deploy under your own account
14
+ # ./deploy.sh Uniphy # deploy under an org you belong to
15
+ # ./deploy.sh "" my-bot my-data # custom space / dataset names
16
+ # ./deploy.sh --dry-run # show what would happen, create nothing
17
+
18
+ set -euo pipefail
19
+
20
+ DRY_RUN=0
21
+ if [[ "${1:-}" == "--dry-run" ]]; then
22
+ DRY_RUN=1
23
+ shift
24
+ fi
25
+
26
+ OWNER="${1:-}"
27
+ SPACE_NAME="${2:-coco-finbot}"
28
+ DATA_NAME="${3:-finbot-data}"
29
+
30
+ HF="./.venv/bin/hf"
31
+ PY="./.venv/bin/python"
32
+ [[ -x "$HF" ]] || HF="hf"
33
+ [[ -x "$PY" ]] || PY="python3"
34
+
35
+ echo "▸ Checking authentication"
36
+ # `hf auth whoami` prints ANSI-coloured labelled lines ("user: name") and exits
37
+ # 0 even when logged out, so ask the API for a clean value instead of scraping.
38
+ ACCOUNT="$(
39
+ "$PY" - <<'PY' 2>/dev/null || true
40
+ try:
41
+ from huggingface_hub import HfApi
42
+ print(HfApi().whoami()["name"])
43
+ except Exception:
44
+ pass
45
+ PY
46
+ )"
47
+
48
+ if [[ -z "$ACCOUNT" ]]; then
49
+ echo " Not logged in. Run: $HF auth login" >&2
50
+ exit 1
51
+ fi
52
+ echo " logged in as: $ACCOUNT"
53
+
54
+ OWNER="${OWNER:-$ACCOUNT}"
55
+
56
+ SPACE_ID="${OWNER}/${SPACE_NAME}"
57
+ DATA_ID="${OWNER}/${DATA_NAME}"
58
+
59
+ # Fail early with a readable message rather than a validation traceback.
60
+ if [[ ! "$OWNER" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
61
+ echo " Invalid owner name: '$OWNER'" >&2
62
+ exit 1
63
+ fi
64
+
65
+ echo "▸ Target Space: $SPACE_ID (docker, private)"
66
+ echo "▸ Target dataset: $DATA_ID (private, durable ledger)"
67
+
68
+ if [[ "$DRY_RUN" == "1" ]]; then
69
+ echo
70
+ echo "(dry run — nothing created or uploaded)"
71
+ exit 0
72
+ fi
73
+
74
+ echo "▸ Creating Space"
75
+ "$HF" repo create "$SPACE_ID" --repo-type space --space_sdk docker --private --exist-ok
76
+
77
+ echo "▸ Creating dataset"
78
+ "$HF" repo create "$DATA_ID" --repo-type dataset --private --exist-ok
79
+
80
+ echo "▸ Uploading source"
81
+ # Everything the container does not need, or that must never leave this machine.
82
+ "$HF" upload "$SPACE_ID" . . \
83
+ --repo-type space \
84
+ --commit-message "Deploy coco-finbot" \
85
+ --exclude \
86
+ ".venv/*" \
87
+ "**/__pycache__/*" \
88
+ ".pytest_cache/*" \
89
+ ".ruff_cache/*" \
90
+ "data/*" \
91
+ "*.db" \
92
+ ".env" \
93
+ ".git/*"
94
+
95
+ SECRET="$("$PY" -c "import secrets; print(secrets.token_urlsafe(32))" 2>/dev/null || echo "<generate a random string>")"
96
+
97
+ cat <<EOF
98
+
99
+ ✅ Uploaded to https://huggingface.co/spaces/${SPACE_ID}
100
+
101
+ Still to do (only you can do these):
102
+
103
+ 1. Space Settings > Secrets, add:
104
+ TELEGRAM_BOT_TOKEN from @BotFather
105
+ TELEGRAM_WEBHOOK_SECRET ${SECRET}
106
+ ALLOWED_TELEGRAM_USER_IDS your numeric Telegram id
107
+ HF_DATASET_REPO ${DATA_ID}
108
+ HF_TOKEN a write token with Inference Providers access
109
+
110
+ 2. Confirm the Space is Private (Settings > Change visibility).
111
+
112
+ 3. Restart the Space. It registers its own Telegram webhook on boot.
113
+
114
+ 4. Message your bot. If you don't know your Telegram id, send it anything --
115
+ it replies with the id to paste into ALLOWED_TELEGRAM_USER_IDS, then
116
+ restart the Space once more.
117
+
118
+ 5. Send /status and confirm storage says "mirroring", not "local only".
119
+ EOF
finbot/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """coco-finbot -- personal expense tracking and budget coaching over Telegram.
2
+
3
+ Designed to run as a Hugging Face Docker Space. Two constraints from that
4
+ environment shape the whole design:
5
+
6
+ * Space disk is **ephemeral** -- wiped on every restart, sleep and rebuild. All
7
+ durable state therefore lives in a private Hub dataset (see
8
+ :mod:`finbot.store`), with SQLite as a local working copy.
9
+ * Free Spaces **sleep** after inactivity, so the bot is webhook-driven rather
10
+ than long-polling.
11
+ """
12
+
13
+ __version__ = "0.1.0"
finbot/advice.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Budget coaching grounded in the user's own numbers.
2
+
3
+ Two deliberate constraints:
4
+
5
+ * **Grounded.** The model is handed a computed brief -- real totals, real
6
+ categories, real recurring charges -- and told to comment only on that. It
7
+ never sees a free-form question it can answer from general knowledge, so it
8
+ cannot invent a number that looks authoritative and is wrong.
9
+ * **Scoped to budgeting.** This is a spending coach, not a financial adviser.
10
+ The prompt forbids investment, tax and legal recommendations, and the
11
+ fallback path below is pure arithmetic over the ledger.
12
+
13
+ If inference is unavailable the deterministic observations still fire, so
14
+ ``/advice`` always returns something useful.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from datetime import date
21
+ from typing import Optional
22
+
23
+ from .analytics import (
24
+ BudgetStatus,
25
+ Recurring,
26
+ by_category,
27
+ dominant_currency,
28
+ month_bounds,
29
+ project_month_end,
30
+ top_merchants,
31
+ total_minor,
32
+ )
33
+ from .categories import label as category_label
34
+ from .llm import LLMClient, LLMUnavailable
35
+ from .models import Expense
36
+ from .money import format_money
37
+
38
+ log = logging.getLogger(__name__)
39
+
40
+ DISCLAIMER = (
41
+ "_General budgeting observations based only on what you've logged — "
42
+ "not financial advice._"
43
+ )
44
+
45
+ _SYSTEM = """You are a personal budgeting coach embedded in a Telegram bot.
46
+
47
+ You will be given a factual brief computed from one person's own logged
48
+ spending. Comment ONLY on that brief.
49
+
50
+ Hard rules:
51
+ - Never recommend specific investments, securities, funds, stocks, crypto,
52
+ insurance or financial products, and never suggest how to allocate savings.
53
+ You are not a licensed financial adviser. If the user seems to want that,
54
+ say plainly that it is outside what you do.
55
+ - Never give tax or legal advice.
56
+ - Never invent a number. Every figure you cite must appear in the brief.
57
+ - If the brief is thin, say the data is thin rather than padding it out.
58
+ - Do not moralise about the spending. The user asked for observations, not
59
+ judgement about whether their choices were wise.
60
+
61
+ Style: 120 words or fewer, plain sentences, at most three short bullets. Lead
62
+ with the single most useful observation. Be concrete and specific."""
63
+
64
+
65
+ def _fmt_pct(value: float) -> str:
66
+ return f"{value:+.0f}%"
67
+
68
+
69
+ def build_brief(
70
+ expenses_this_month: list[Expense],
71
+ expenses_last_month: list[Expense],
72
+ recurring: list[Recurring],
73
+ budgets: list[BudgetStatus],
74
+ currency: str,
75
+ today: date,
76
+ ) -> str:
77
+ """Compose the factual brief the model is allowed to talk about."""
78
+ start, _ = month_bounds(today)
79
+ this_total = total_minor(expenses_this_month)
80
+ last_total = total_minor(expenses_last_month)
81
+
82
+ lines: list[str] = [
83
+ f"Date: {today.isoformat()}",
84
+ f"Currency: {currency}",
85
+ f"Month to date ({start.isoformat()} to {today.isoformat()}): "
86
+ f"{format_money(this_total, currency)} across {len(expenses_this_month)} transactions",
87
+ f"Same period last month: {format_money(last_total, currency)}",
88
+ ]
89
+
90
+ if last_total:
91
+ change = (this_total - last_total) / last_total * 100
92
+ lines.append(f"Change vs last month: {_fmt_pct(change)}")
93
+
94
+ projected = project_month_end(expenses_this_month, today)
95
+ lines.append(f"Projected month-end at current pace: {format_money(projected, currency)}")
96
+
97
+ if categories := by_category(expenses_this_month):
98
+ lines.append("Top categories this month:")
99
+ for name, amount in categories[:6]:
100
+ share = amount / this_total * 100 if this_total else 0
101
+ lines.append(
102
+ f" - {category_label(name)}: {format_money(amount, currency)} ({share:.0f}%)"
103
+ )
104
+
105
+ if merchants := top_merchants(expenses_this_month, limit=5):
106
+ lines.append("Top merchants this month:")
107
+ for name, amount in merchants:
108
+ lines.append(f" - {name}: {format_money(amount, currency)}")
109
+
110
+ if recurring:
111
+ lines.append("Recurring charges detected:")
112
+ for item in recurring[:8]:
113
+ lines.append(
114
+ f" - {item.label}: {format_money(item.typical_minor, item.currency)}/month "
115
+ f"(seen in {item.months} months, last {item.last_seen.isoformat()})"
116
+ )
117
+
118
+ if budgets:
119
+ lines.append("Budgets this month:")
120
+ for status in budgets:
121
+ state = "OVER" if status.over else "within"
122
+ lines.append(
123
+ f" - {category_label(status.category)}: "
124
+ f"{format_money(status.spent_minor, status.currency)} of "
125
+ f"{format_money(status.limit_minor, status.currency)} ({status.pct:.0f}%, {state})"
126
+ )
127
+
128
+ return "\n".join(lines)
129
+
130
+
131
+ def deterministic_observations(
132
+ expenses_this_month: list[Expense],
133
+ expenses_last_month: list[Expense],
134
+ recurring: list[Recurring],
135
+ budgets: list[BudgetStatus],
136
+ currency: str,
137
+ today: date,
138
+ ) -> list[str]:
139
+ """Rule-based findings. Always available, no inference required."""
140
+ notes: list[str] = []
141
+ this_total = total_minor(expenses_this_month)
142
+ last_total = total_minor(expenses_last_month)
143
+
144
+ if not expenses_this_month:
145
+ return ["Nothing logged this month yet — send me an expense to get started."]
146
+
147
+ if last_total:
148
+ change = (this_total - last_total) / last_total * 100
149
+ if abs(change) >= 10:
150
+ direction = "up" if change > 0 else "down"
151
+ notes.append(
152
+ f"Spending is {direction} {abs(change):.0f}% on last month "
153
+ f"({format_money(this_total, currency)} vs {format_money(last_total, currency)})."
154
+ )
155
+
156
+ projected = project_month_end(expenses_this_month, today)
157
+ if projected > this_total:
158
+ notes.append(
159
+ f"At this pace you'll finish the month around {format_money(projected, currency)}."
160
+ )
161
+
162
+ categories = by_category(expenses_this_month)
163
+ if categories and this_total:
164
+ name, amount = categories[0]
165
+ share = amount / this_total * 100
166
+ if share >= 30:
167
+ notes.append(
168
+ f"{category_label(name)} is {share:.0f}% of this month's spend "
169
+ f"({format_money(amount, currency)})."
170
+ )
171
+
172
+ for status in budgets:
173
+ if status.over:
174
+ notes.append(
175
+ f"{category_label(status.category)} is over budget by "
176
+ f"{format_money(-status.remaining_minor, status.currency)}."
177
+ )
178
+ elif status.pct >= 80:
179
+ notes.append(
180
+ f"{category_label(status.category)} is at {status.pct:.0f}% of budget."
181
+ )
182
+
183
+ if recurring:
184
+ annual = sum(r.annualised_minor for r in recurring if r.currency == currency)
185
+ if annual:
186
+ notes.append(
187
+ f"{len(recurring)} recurring charges detected, "
188
+ f"about {format_money(annual, currency)} a year if all continue."
189
+ )
190
+
191
+ return notes or ["Nothing unusual stands out this month."]
192
+
193
+
194
+ class Advisor:
195
+ def __init__(self, llm: LLMClient):
196
+ self.llm = llm
197
+
198
+ def advise(
199
+ self,
200
+ expenses_this_month: list[Expense],
201
+ expenses_last_month: list[Expense],
202
+ recurring: list[Recurring],
203
+ budgets: list[BudgetStatus],
204
+ default_currency: str,
205
+ today: date,
206
+ question: Optional[str] = None,
207
+ ) -> str:
208
+ currency = dominant_currency(expenses_this_month, default_currency)
209
+ facts = deterministic_observations(
210
+ expenses_this_month, expenses_last_month, recurring, budgets, currency, today
211
+ )
212
+
213
+ if not self.llm.enabled or not expenses_this_month:
214
+ return "\n".join(f"• {note}" for note in facts)
215
+
216
+ brief = build_brief(
217
+ expenses_this_month, expenses_last_month, recurring, budgets, currency, today
218
+ )
219
+ user_block = f"Brief:\n{brief}"
220
+ if question:
221
+ user_block += (
222
+ f"\n\nThe user asked: {question}\n"
223
+ "Answer only if the brief supports it; otherwise say what you'd "
224
+ "need them to log."
225
+ )
226
+
227
+ try:
228
+ response = self.llm.chat(
229
+ [
230
+ {"role": "system", "content": _SYSTEM},
231
+ {"role": "user", "content": user_block},
232
+ ],
233
+ temperature=0.3,
234
+ max_tokens=500,
235
+ )
236
+ except LLMUnavailable as exc:
237
+ log.info("Advice fell back to deterministic notes: %s", exc)
238
+ return "\n".join(f"• {note}" for note in facts)
239
+
240
+ response = (response or "").strip()
241
+ if not response:
242
+ return "\n".join(f"• {note}" for note in facts)
243
+ return response
finbot/analytics.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Aggregation over the ledger.
2
+
3
+ All pure functions over lists of expenses -- no I/O, no model calls -- so the
4
+ numbers are testable and always reproducible. Everything is computed
5
+ per-currency: summing INR and USD into one figure would be meaningless, so a
6
+ multi-currency ledger yields one summary per currency rather than a fictional
7
+ total.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from calendar import monthrange
14
+ from collections import defaultdict
15
+ from dataclasses import dataclass
16
+ from datetime import date, datetime, timedelta
17
+ from typing import Iterable, Optional
18
+ from zoneinfo import ZoneInfo
19
+
20
+ from .models import Expense, Summary
21
+
22
+ PERIODS = ("today", "yesterday", "week", "month", "last_month", "year", "30d", "all")
23
+
24
+
25
+ def today_in(timezone: str) -> date:
26
+ """Current date in the user's timezone.
27
+
28
+ The container runs in UTC; for a user in Asia/Kolkata a spend logged at
29
+ 2am local would otherwise land on the previous day.
30
+ """
31
+ try:
32
+ return datetime.now(ZoneInfo(timezone)).date()
33
+ except Exception:
34
+ return date.today()
35
+
36
+
37
+ def month_bounds(day: date) -> tuple[date, date]:
38
+ return day.replace(day=1), day.replace(day=monthrange(day.year, day.month)[1])
39
+
40
+
41
+ def previous_month_bounds(day: date) -> tuple[date, date]:
42
+ first = day.replace(day=1)
43
+ last_prev = first - timedelta(days=1)
44
+ return month_bounds(last_prev)
45
+
46
+
47
+ def period_bounds(name: str, today: date) -> tuple[date, date, str]:
48
+ """Resolve a period keyword to (start, end, human label)."""
49
+ name = (name or "month").lower().strip()
50
+
51
+ if name == "today":
52
+ return today, today, "Today"
53
+ if name == "yesterday":
54
+ day = today - timedelta(days=1)
55
+ return day, day, "Yesterday"
56
+ if name == "week":
57
+ start = today - timedelta(days=today.weekday())
58
+ return start, today, "This week"
59
+ if name == "last_month":
60
+ start, end = previous_month_bounds(today)
61
+ return start, end, start.strftime("%B %Y")
62
+ if name == "year":
63
+ return today.replace(month=1, day=1), today, str(today.year)
64
+ if name == "30d":
65
+ return today - timedelta(days=29), today, "Last 30 days"
66
+ if name == "all":
67
+ return date(1970, 1, 1), today, "All time"
68
+
69
+ start, _ = month_bounds(today)
70
+ return start, today, today.strftime("%B %Y")
71
+
72
+
73
+ def split_by_currency(expenses: Iterable[Expense]) -> dict[str, list[Expense]]:
74
+ buckets: dict[str, list[Expense]] = defaultdict(list)
75
+ for expense in expenses:
76
+ buckets[expense.currency.upper()].append(expense)
77
+ return dict(buckets)
78
+
79
+
80
+ def dominant_currency(expenses: Iterable[Expense], fallback: str) -> str:
81
+ buckets = split_by_currency(expenses)
82
+ if not buckets:
83
+ return fallback.upper()
84
+ return max(buckets.items(), key=lambda kv: (len(kv[1]), sum(e.amount_minor for e in kv[1])))[0]
85
+
86
+
87
+ def total_minor(expenses: Iterable[Expense]) -> int:
88
+ return sum(e.amount_minor for e in expenses)
89
+
90
+
91
+ def by_category(expenses: Iterable[Expense]) -> list[tuple[str, int]]:
92
+ totals: dict[str, int] = defaultdict(int)
93
+ for expense in expenses:
94
+ totals[expense.category] += expense.amount_minor
95
+ return sorted(totals.items(), key=lambda kv: kv[1], reverse=True)
96
+
97
+
98
+ def summarise(
99
+ expenses: list[Expense],
100
+ currency: str,
101
+ label: str,
102
+ start: date,
103
+ end: date,
104
+ previous: Optional[list[Expense]] = None,
105
+ ) -> Summary:
106
+ scoped = [e for e in expenses if e.currency.upper() == currency.upper()]
107
+ previous_total = None
108
+ if previous is not None:
109
+ previous_total = total_minor(
110
+ [e for e in previous if e.currency.upper() == currency.upper()]
111
+ )
112
+
113
+ return Summary(
114
+ label=label,
115
+ start=start,
116
+ end=end,
117
+ currency=currency.upper(),
118
+ total_minor=total_minor(scoped),
119
+ count=len(scoped),
120
+ by_category=by_category(scoped),
121
+ previous_total_minor=previous_total,
122
+ )
123
+
124
+
125
+ # ----------------------------------------------------------------- recurring
126
+
127
+
128
+ @dataclass
129
+ class Recurring:
130
+ label: str
131
+ category: str
132
+ currency: str
133
+ typical_minor: int
134
+ occurrences: int
135
+ months: int
136
+ last_seen: date
137
+
138
+ @property
139
+ def annualised_minor(self) -> int:
140
+ return self.typical_minor * 12
141
+
142
+
143
+ _NOISE_RE = re.compile(r"\b(?:\d{4,}|ref|txn|upi|no|id)\b[-\s]*\w*", re.I)
144
+ _TLD_RE = re.compile(r"\.(?:com|in|co|net|org|io|app|me|shop|store)\b", re.I)
145
+
146
+ #: Structural filler that appears in card narrations regardless of merchant.
147
+ #: Deliberately not locale-specific -- no city or country names, which would
148
+ #: only work for whichever market we happened to hardcode.
149
+ _NOISE_TOKENS = frozenset(
150
+ {
151
+ "com", "www", "net", "org", "ltd", "pvt", "inc", "llc", "limited",
152
+ "the", "pos", "pay", "payment", "purchase", "online", "debit",
153
+ "card", "transaction", "auto", "recurring",
154
+ }
155
+ )
156
+
157
+
158
+ def _merchant_key(expense: Expense) -> str:
159
+ """Normalise a merchant so 'NETFLIX.COM 447192' and 'Netflix' collapse.
160
+
161
+ Purely lexical, so it will not unify names that share no words
162
+ ("AMZN Mktp" vs "Amazon"). Keeping only the first few significant tokens
163
+ absorbs the trailing reference numbers and city suffixes banks append.
164
+ """
165
+ raw = (expense.merchant or expense.description or "").lower()
166
+ raw = _TLD_RE.sub(" ", raw)
167
+ raw = _NOISE_RE.sub(" ", raw)
168
+ raw = re.sub(r"[^a-z ]+", " ", raw)
169
+ tokens = [
170
+ t for t in raw.split() if len(t) > 2 and t not in _NOISE_TOKENS
171
+ ]
172
+ return " ".join(tokens[:3])
173
+
174
+
175
+ def detect_recurring(
176
+ expenses: Iterable[Expense],
177
+ min_months: int = 3,
178
+ tolerance: float = 0.20,
179
+ ) -> list[Recurring]:
180
+ """Find charges that repeat monthly at a similar amount.
181
+
182
+ Requires hits in at least ``min_months`` distinct calendar months so that
183
+ three coffees in one week don't register as a subscription.
184
+ """
185
+ groups: dict[tuple[str, str], list[Expense]] = defaultdict(list)
186
+ for expense in expenses:
187
+ key = _merchant_key(expense)
188
+ if not key:
189
+ continue
190
+ groups[(key, expense.currency.upper())].append(expense)
191
+
192
+ found: list[Recurring] = []
193
+ for (key, currency), items in groups.items():
194
+ months = {(e.occurred_on.year, e.occurred_on.month) for e in items}
195
+ if len(months) < min_months:
196
+ continue
197
+
198
+ amounts = sorted(e.amount_minor for e in items)
199
+ median = amounts[len(amounts) // 2]
200
+ if median <= 0:
201
+ continue
202
+
203
+ # Consistent amount is what separates a subscription from ordinary
204
+ # repeat spending at the same shop.
205
+ consistent = [a for a in amounts if abs(a - median) <= median * tolerance]
206
+ if len(consistent) < len(amounts) * 0.6:
207
+ continue
208
+
209
+ best = max(items, key=lambda e: e.occurred_on)
210
+ found.append(
211
+ Recurring(
212
+ label=(best.merchant or best.description or key).strip()[:60],
213
+ category=best.category,
214
+ currency=currency,
215
+ typical_minor=median,
216
+ occurrences=len(items),
217
+ months=len(months),
218
+ last_seen=best.occurred_on,
219
+ )
220
+ )
221
+
222
+ return sorted(found, key=lambda r: r.typical_minor, reverse=True)
223
+
224
+
225
+ # ------------------------------------------------------------------- budgets
226
+
227
+
228
+ @dataclass
229
+ class BudgetStatus:
230
+ category: str
231
+ currency: str
232
+ limit_minor: int
233
+ spent_minor: int
234
+
235
+ @property
236
+ def pct(self) -> float:
237
+ if self.limit_minor <= 0:
238
+ return 0.0
239
+ return self.spent_minor / self.limit_minor * 100.0
240
+
241
+ @property
242
+ def remaining_minor(self) -> int:
243
+ return self.limit_minor - self.spent_minor
244
+
245
+ @property
246
+ def over(self) -> bool:
247
+ return self.spent_minor > self.limit_minor
248
+
249
+
250
+ def budget_status(
251
+ budgets: Iterable, month_expenses: Iterable[Expense]
252
+ ) -> list[BudgetStatus]:
253
+ spent: dict[tuple[str, str], int] = defaultdict(int)
254
+ for expense in month_expenses:
255
+ spent[(expense.category, expense.currency.upper())] += expense.amount_minor
256
+
257
+ statuses = [
258
+ BudgetStatus(
259
+ category=budget.category,
260
+ currency=budget.currency.upper(),
261
+ limit_minor=budget.amount_minor,
262
+ spent_minor=spent.get((budget.category, budget.currency.upper()), 0),
263
+ )
264
+ for budget in budgets
265
+ ]
266
+ return sorted(statuses, key=lambda s: s.pct, reverse=True)
267
+
268
+
269
+ def daily_burn(expenses: list[Expense], start: date, end: date) -> float:
270
+ """Average spend per elapsed day, in minor units."""
271
+ days = max(1, (end - start).days + 1)
272
+ return total_minor(expenses) / days
273
+
274
+
275
+ def project_month_end(expenses: list[Expense], today: date) -> int:
276
+ """Straight-line projection of this month's total from spend so far."""
277
+ start, end = month_bounds(today)
278
+ elapsed = max(1, (today - start).days + 1)
279
+ days_in_month = (end - start).days + 1
280
+ return int(total_minor(expenses) / elapsed * days_in_month)
281
+
282
+
283
+ def top_merchants(expenses: Iterable[Expense], limit: int = 5) -> list[tuple[str, int]]:
284
+ totals: dict[str, int] = defaultdict(int)
285
+ for expense in expenses:
286
+ label = (expense.merchant or expense.description or "unlabelled").strip()[:40]
287
+ totals[label] += expense.amount_minor
288
+ return sorted(totals.items(), key=lambda kv: kv[1], reverse=True)[:limit]
finbot/categories.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Category taxonomy and deterministic keyword matching.
2
+
3
+ Rules run before any model call. Most everyday spend ("uber", "netflix",
4
+ "petrol") is unambiguous, and a lookup is free, instant and reproducible where
5
+ a model call is none of those. The LLM only sees what the rules could not
6
+ place.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ #: Canonical categories. Keep this list short -- a taxonomy nobody can hold in
14
+ #: their head produces inconsistent categorisation, which makes trends lie.
15
+ CATEGORIES: tuple[str, ...] = (
16
+ "groceries",
17
+ "food_dining",
18
+ "transport",
19
+ "fuel",
20
+ "shopping",
21
+ "utilities",
22
+ "rent",
23
+ "health",
24
+ "entertainment",
25
+ "subscriptions",
26
+ "education",
27
+ "travel",
28
+ "fees_charges",
29
+ "transfers",
30
+ "cash",
31
+ "income",
32
+ "other",
33
+ )
34
+
35
+ #: Human labels for display.
36
+ LABELS: dict[str, str] = {
37
+ "groceries": "Groceries",
38
+ "food_dining": "Food & Dining",
39
+ "transport": "Transport",
40
+ "fuel": "Fuel",
41
+ "shopping": "Shopping",
42
+ "utilities": "Utilities",
43
+ "rent": "Rent & Housing",
44
+ "health": "Health",
45
+ "entertainment": "Entertainment",
46
+ "subscriptions": "Subscriptions",
47
+ "education": "Education",
48
+ "travel": "Travel",
49
+ "fees_charges": "Fees & Charges",
50
+ "transfers": "Transfers",
51
+ "cash": "Cash",
52
+ "income": "Income",
53
+ "other": "Other",
54
+ }
55
+
56
+ # Ordered most-specific first: "fuel" must win over "transport" for a petrol
57
+ # pump, and "subscriptions" over "entertainment" for Netflix.
58
+ _RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
59
+ (
60
+ "subscriptions",
61
+ (
62
+ "netflix", "spotify", "prime video", "hotstar", "disney+", "youtube premium",
63
+ "icloud", "google one", "dropbox", "notion", "figma", "adobe", "github",
64
+ "openai", "chatgpt", "claude", "anthropic", "midjourney", "subscription",
65
+ "patreon", "substack", "audible", "kindle unlimited",
66
+ ),
67
+ ),
68
+ (
69
+ "fuel",
70
+ (
71
+ "petrol", "diesel", "fuel", "gas station", "shell", "bharat petroleum",
72
+ "hp petrol", "indian oil", "iocl", "bpcl", "hpcl", "chevron", "exxon",
73
+ ),
74
+ ),
75
+ (
76
+ "transport",
77
+ (
78
+ "uber", "ola", "lyft", "rapido", "taxi", "cab", "metro", "bus", "train",
79
+ "irctc", "railway", "toll", "fastag", "parking", "auto rickshaw", "grab",
80
+ "bolt", "blablacar",
81
+ ),
82
+ ),
83
+ (
84
+ "groceries",
85
+ (
86
+ "grocery", "groceries", "supermarket", "bigbasket", "blinkit", "zepto",
87
+ "dmart", "reliance fresh", "more retail", "spencer", "tesco", "aldi",
88
+ "lidl", "walmart", "costco", "sainsbury", "kroger", "instacart",
89
+ "vegetables", "fruits", "milk", "kirana",
90
+ ),
91
+ ),
92
+ (
93
+ "food_dining",
94
+ (
95
+ "swiggy", "zomato", "doordash", "ubereats", "deliveroo", "just eat",
96
+ "restaurant", "cafe", "coffee", "starbucks", "mcdonald", "kfc", "domino",
97
+ "pizza", "burger", "dinner", "lunch", "breakfast", "snack", "chai",
98
+ "bakery", "biryani", "food", "dining", "brunch", "takeaway",
99
+ ),
100
+ ),
101
+ (
102
+ "utilities",
103
+ (
104
+ "electricity", "water bill", "gas bill", "broadband", "internet", "wifi",
105
+ "airtel", "jio", "vodafone", "vi postpaid", "bsnl", "act fibernet",
106
+ "recharge", "mobile bill", "phone bill", "dth", "tata power", "adani",
107
+ "utility", "council tax",
108
+ ),
109
+ ),
110
+ (
111
+ "rent",
112
+ ("rent", "landlord", "lease", "maintenance charge", "society charge", "mortgage"),
113
+ ),
114
+ (
115
+ "health",
116
+ (
117
+ "pharmacy", "chemist", "apollo", "medplus", "1mg", "pharmeasy", "hospital",
118
+ "clinic", "doctor", "dentist", "medicine", "medical", "diagnostic", "lab test",
119
+ "insurance premium", "gym", "fitness", "cult.fit", "physio", "optician",
120
+ ),
121
+ ),
122
+ (
123
+ "entertainment",
124
+ (
125
+ "movie", "cinema", "pvr", "inox", "bookmyshow", "concert", "theatre",
126
+ "game", "steam", "playstation", "xbox", "nintendo", "bar", "pub", "club",
127
+ ),
128
+ ),
129
+ (
130
+ "shopping",
131
+ (
132
+ "amazon", "flipkart", "myntra", "ajio", "meesho", "nykaa", "ikea", "zara",
133
+ "h&m", "uniqlo", "decathlon", "croma", "reliance digital", "apple store",
134
+ "ebay", "etsy", "aliexpress", "shein", "clothes", "shoes", "electronics",
135
+ ),
136
+ ),
137
+ (
138
+ "travel",
139
+ (
140
+ "flight", "airline", "indigo", "vistara", "air india", "spicejet", "emirates",
141
+ "makemytrip", "goibibo", "cleartrip", "booking.com", "airbnb", "oyo", "hotel",
142
+ "hostel", "visa fee", "passport", "travel",
143
+ ),
144
+ ),
145
+ (
146
+ "education",
147
+ (
148
+ "course", "udemy", "coursera", "tuition", "school fee", "college", "university",
149
+ "textbook", "exam fee", "byju", "unacademy", "edx", "workshop",
150
+ ),
151
+ ),
152
+ (
153
+ "fees_charges",
154
+ (
155
+ "bank charge", "service charge", "convenience fee", "processing fee",
156
+ "late fee", "penalty", "interest charge", "gst", "atm charge", "annual fee",
157
+ "overdraft", "forex markup",
158
+ ),
159
+ ),
160
+ (
161
+ "transfers",
162
+ (
163
+ "transfer", "neft", "imps", "rtgs", "upi to", "sent to", "paytm wallet",
164
+ "self transfer", "credit card payment", "loan emi", "emi",
165
+ ),
166
+ ),
167
+ ("cash", ("atm", "cash withdrawal", "withdrawal", "cash")),
168
+ (
169
+ "income",
170
+ ("salary", "payroll", "credited", "refund", "cashback", "interest earned", "dividend"),
171
+ ),
172
+ )
173
+
174
+
175
+ def _normalise(text: str) -> str:
176
+ return re.sub(r"\s+", " ", text.lower()).strip()
177
+
178
+
179
+ def categorise(text: str, merchant: str = "") -> tuple[str, float]:
180
+ """Map free text to a category deterministically.
181
+
182
+ Returns (category, confidence). Confidence is 1.0 for a keyword hit and 0.0
183
+ for the ``other`` fallback, which is the signal the caller uses to decide
184
+ whether to spend a model call on it.
185
+ """
186
+ haystack = _normalise(f"{merchant} {text}")
187
+ if not haystack:
188
+ return "other", 0.0
189
+
190
+ for category, keywords in _RULES:
191
+ for keyword in keywords:
192
+ # Word-boundary match so "gas" doesn't fire on "gasket" and
193
+ # "cash" doesn't fire on "cashew".
194
+ if re.search(rf"(?<![a-z0-9]){re.escape(keyword)}(?![a-z0-9])", haystack):
195
+ return category, 1.0
196
+ return "other", 0.0
197
+
198
+
199
+ def is_valid(category: str) -> bool:
200
+ return category in CATEGORIES
201
+
202
+
203
+ def coerce(category: str) -> str:
204
+ """Force an arbitrary string onto the taxonomy.
205
+
206
+ Models are asked for a category from the list but do not always comply;
207
+ this keeps a stray label out of the ledger where it would fragment totals.
208
+ """
209
+ if not category:
210
+ return "other"
211
+ candidate = _normalise(category).replace(" ", "_").replace("&", "").strip("_")
212
+ if candidate in CATEGORIES:
213
+ return candidate
214
+ aliases = {
215
+ "food": "food_dining",
216
+ "dining": "food_dining",
217
+ "restaurants": "food_dining",
218
+ "eating_out": "food_dining",
219
+ "grocery": "groceries",
220
+ "commute": "transport",
221
+ "travel_transport": "transport",
222
+ "petrol": "fuel",
223
+ "bills": "utilities",
224
+ "housing": "rent",
225
+ "medical": "health",
226
+ "healthcare": "health",
227
+ "fitness": "health",
228
+ "leisure": "entertainment",
229
+ "subscription": "subscriptions",
230
+ "fees": "fees_charges",
231
+ "charges": "fees_charges",
232
+ "transfer": "transfers",
233
+ "salary": "income",
234
+ "misc": "other",
235
+ "miscellaneous": "other",
236
+ "uncategorized": "other",
237
+ "uncategorised": "other",
238
+ }
239
+ return aliases.get(candidate, "other")
240
+
241
+
242
+ def label(category: str) -> str:
243
+ return LABELS.get(category, category.replace("_", " ").title())
finbot/config.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime configuration, read once from the environment.
2
+
3
+ On Hugging Face these come from Space Settings > Secrets; locally from a .env
4
+ file. Everything has a default that keeps the app bootable, so a missing
5
+ optional secret degrades a feature rather than crashing the container -- a
6
+ Space that won't boot is much harder to debug than one that boots and says
7
+ what it is missing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from functools import lru_cache
13
+
14
+ from pydantic_settings import BaseSettings, SettingsConfigDict
15
+
16
+
17
+ class Settings(BaseSettings):
18
+ model_config = SettingsConfigDict(
19
+ env_file=".env", env_file_encoding="utf-8", extra="ignore"
20
+ )
21
+
22
+ # --- Telegram ---
23
+ telegram_bot_token: str = ""
24
+ telegram_webhook_secret: str = ""
25
+ allowed_telegram_user_ids: str = ""
26
+ #: Let anyone message the bot. Each Telegram user still gets their own
27
+ #: isolated ledger -- open access makes this multi-tenant, it does not
28
+ #: expose one user's spending to another. It does mean strangers spend the
29
+ #: Space owner's inference credits, which is what uploads_per_user_per_day
30
+ #: is for. Must be set explicitly, so a forgotten allowlist stays closed
31
+ #: rather than silently opening to the world.
32
+ open_access: bool = False
33
+ #: Model-backed uploads (receipts, statements) allowed per user per day.
34
+ #: 0 or less disables the cap.
35
+ uploads_per_user_per_day: int = 20
36
+
37
+ # --- Inference ---
38
+ hf_token: str = ""
39
+ llm_base_url: str = "https://router.huggingface.co/v1"
40
+ llm_model: str = "Qwen/Qwen3-235B-A22B-Instruct-2507"
41
+ llm_vision_model: str = "Qwen/Qwen2.5-VL-72B-Instruct"
42
+
43
+ # --- Storage ---
44
+ hf_dataset_repo: str = ""
45
+ sync_every_minutes: int = 5
46
+ data_dir: str = "/home/user/data"
47
+
48
+ # --- Behaviour ---
49
+ default_currency: str = "INR"
50
+ timezone: str = "Asia/Kolkata"
51
+
52
+ @property
53
+ def allowed_user_ids(self) -> frozenset[int]:
54
+ """Telegram user IDs permitted to talk to the bot.
55
+
56
+ Empty means nobody. This is deliberate: an open finance bot would let
57
+ any stranger who finds the username write to your ledger and read your
58
+ spending back out.
59
+ """
60
+ ids: set[int] = set()
61
+ for chunk in self.allowed_telegram_user_ids.split(","):
62
+ chunk = chunk.strip()
63
+ if not chunk:
64
+ continue
65
+ try:
66
+ ids.add(int(chunk))
67
+ except ValueError:
68
+ continue
69
+ return frozenset(ids)
70
+
71
+ def is_authorised(self, user_id: int) -> bool:
72
+ return self.open_access or user_id in self.allowed_user_ids
73
+
74
+ @property
75
+ def llm_enabled(self) -> bool:
76
+ return bool(self.hf_token)
77
+
78
+ @property
79
+ def sync_enabled(self) -> bool:
80
+ return bool(self.hf_dataset_repo and self.hf_token)
81
+
82
+ def missing_required(self) -> list[str]:
83
+ """Config gaps that stop the bot doing its job, worst first."""
84
+ gaps: list[str] = []
85
+ if not self.telegram_bot_token:
86
+ gaps.append("TELEGRAM_BOT_TOKEN (bot cannot send or receive)")
87
+ if not self.telegram_webhook_secret:
88
+ gaps.append("TELEGRAM_WEBHOOK_SECRET (webhook cannot be verified)")
89
+ if not self.open_access and not self.allowed_user_ids:
90
+ gaps.append("ALLOWED_TELEGRAM_USER_IDS (nobody is authorised yet)")
91
+ if not self.hf_dataset_repo:
92
+ gaps.append("HF_DATASET_REPO (data will be lost when the Space restarts)")
93
+ if not self.hf_token:
94
+ gaps.append("HF_TOKEN (receipts, statements and advice are disabled)")
95
+ return gaps
96
+
97
+
98
+ @lru_cache(maxsize=1)
99
+ def get_settings() -> Settings:
100
+ return Settings()
finbot/extract.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn an uploaded file into text and rows.
2
+
3
+ Deliberately format-agnostic and bank-agnostic: this module only gets the
4
+ content *out* of the file. Deciding which column is a date and which is an
5
+ amount is :mod:`finbot.ingest`'s job, and is done by the model so that any
6
+ bank's export works without a hand-written layout per institution.
7
+
8
+ Nothing here calls the network, so all of it is unit-testable offline.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import csv
14
+ import io
15
+ import logging
16
+ from dataclasses import dataclass, field
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ #: Guard rails. A personal statement is a few hundred rows; anything far past
21
+ #: that is either a mistake or would blow the model's context and the user's
22
+ #: token budget, so it is truncated with a visible note rather than silently.
23
+ MAX_ROWS = 1500
24
+ MAX_TEXT_CHARS = 120_000
25
+ MAX_PDF_PAGES = 40
26
+
27
+
28
+ @dataclass
29
+ class ExtractedDocument:
30
+ kind: str
31
+ text: str = ""
32
+ rows: list[list[str]] = field(default_factory=list)
33
+ pages: int = 0
34
+ truncated: bool = False
35
+ note: str = ""
36
+
37
+ @property
38
+ def is_empty(self) -> bool:
39
+ return not self.text.strip() and not self.rows
40
+
41
+
42
+ def _decode(data: bytes) -> str:
43
+ for encoding in ("utf-8-sig", "utf-8", "cp1252", "latin-1"):
44
+ try:
45
+ return data.decode(encoding)
46
+ except UnicodeDecodeError:
47
+ continue
48
+ return data.decode("utf-8", errors="replace")
49
+
50
+
51
+ def _clean_row(row: list) -> list[str]:
52
+ return [("" if c is None else str(c)).strip() for c in row]
53
+
54
+
55
+ def _is_blank(row: list[str]) -> bool:
56
+ return not any(cell for cell in row)
57
+
58
+
59
+ def extract_csv(data: bytes) -> ExtractedDocument:
60
+ text = _decode(data)
61
+ sample = text[:8192]
62
+
63
+ try:
64
+ dialect = csv.Sniffer().sniff(sample, delimiters=",;\t|")
65
+ except csv.Error:
66
+ # Sniffing fails on single-column or irregular exports; comma is the
67
+ # safe default and the model tolerates a wrong split better than a crash.
68
+ dialect = csv.excel
69
+
70
+ rows: list[list[str]] = []
71
+ truncated = False
72
+ for row in csv.reader(io.StringIO(text), dialect):
73
+ cleaned = _clean_row(row)
74
+ if _is_blank(cleaned):
75
+ continue
76
+ rows.append(cleaned)
77
+ if len(rows) >= MAX_ROWS:
78
+ truncated = True
79
+ break
80
+
81
+ return ExtractedDocument(kind="csv", rows=rows, truncated=truncated)
82
+
83
+
84
+ def extract_xlsx(data: bytes) -> ExtractedDocument:
85
+ from openpyxl import load_workbook
86
+
87
+ workbook = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
88
+ rows: list[list[str]] = []
89
+ truncated = False
90
+
91
+ try:
92
+ for sheet in workbook.worksheets:
93
+ if truncated:
94
+ break
95
+ if len(workbook.worksheets) > 1:
96
+ rows.append([f"# sheet: {sheet.title}"])
97
+ for row in sheet.iter_rows(values_only=True):
98
+ cleaned = _clean_row(list(row))
99
+ if _is_blank(cleaned):
100
+ continue
101
+ rows.append(cleaned)
102
+ if len(rows) >= MAX_ROWS:
103
+ truncated = True
104
+ break
105
+ finally:
106
+ workbook.close()
107
+
108
+ return ExtractedDocument(kind="xlsx", rows=rows, truncated=truncated)
109
+
110
+
111
+ def extract_pdf(data: bytes) -> ExtractedDocument:
112
+ """Pull tables (preferred) and text out of a PDF statement.
113
+
114
+ Scanned statements contain no text layer. That is detected and reported
115
+ rather than silently returning nothing, because "0 transactions found" and
116
+ "this file is a photograph of a statement" need very different user advice.
117
+ """
118
+ import pdfplumber
119
+
120
+ rows: list[list[str]] = []
121
+ text_parts: list[str] = []
122
+ truncated = False
123
+ page_count = 0
124
+
125
+ with pdfplumber.open(io.BytesIO(data)) as pdf:
126
+ page_count = len(pdf.pages)
127
+ for index, page in enumerate(pdf.pages):
128
+ if index >= MAX_PDF_PAGES:
129
+ truncated = True
130
+ break
131
+
132
+ try:
133
+ for table in page.extract_tables() or []:
134
+ for row in table:
135
+ cleaned = _clean_row(list(row))
136
+ if _is_blank(cleaned):
137
+ continue
138
+ rows.append(cleaned)
139
+ if len(rows) >= MAX_ROWS:
140
+ truncated = True
141
+ break
142
+ if truncated:
143
+ break
144
+ except Exception as exc:
145
+ log.warning("Table extraction failed on page %d: %s", index + 1, exc)
146
+
147
+ try:
148
+ if page_text := page.extract_text():
149
+ text_parts.append(page_text)
150
+ except Exception as exc:
151
+ log.warning("Text extraction failed on page %d: %s", index + 1, exc)
152
+
153
+ if truncated:
154
+ break
155
+
156
+ text = "\n".join(text_parts)[:MAX_TEXT_CHARS]
157
+ doc = ExtractedDocument(
158
+ kind="pdf", text=text, rows=rows, pages=page_count, truncated=truncated
159
+ )
160
+
161
+ if doc.is_empty:
162
+ doc.note = (
163
+ "This PDF has no selectable text, so it is almost certainly a scan "
164
+ "or photo of a statement. Send it as a photo instead and I'll read "
165
+ "it with the vision model, or export a CSV/XLSX from your bank."
166
+ )
167
+ return doc
168
+
169
+
170
+ def extract(data: bytes, filename: str = "", mime_type: str = "") -> ExtractedDocument:
171
+ """Dispatch on filename extension, falling back to MIME type."""
172
+ name = (filename or "").lower()
173
+ mime = (mime_type or "").lower()
174
+
175
+ if name.endswith(".csv") or name.endswith(".tsv") or "csv" in mime:
176
+ return extract_csv(data)
177
+ if name.endswith((".xlsx", ".xlsm", ".xltx")) or "spreadsheet" in mime or "excel" in mime:
178
+ return extract_xlsx(data)
179
+ if name.endswith(".pdf") or "pdf" in mime:
180
+ return extract_pdf(data)
181
+ if name.endswith((".txt", ".md")) or mime.startswith("text/"):
182
+ return ExtractedDocument(kind="text", text=_decode(data)[:MAX_TEXT_CHARS])
183
+
184
+ # .xls (old binary format) is not readable by openpyxl -- say so precisely.
185
+ if name.endswith(".xls"):
186
+ return ExtractedDocument(
187
+ kind="unsupported",
188
+ note=(
189
+ "Old .xls files aren't supported. Open it and re-save as .xlsx "
190
+ "or export CSV, then send it again."
191
+ ),
192
+ )
193
+
194
+ return ExtractedDocument(
195
+ kind="unsupported",
196
+ note=f"I can't read {filename or 'that file'}. Send a CSV, XLSX, PDF or a photo.",
197
+ )
198
+
199
+
200
+ def rows_to_text(rows: list[list[str]], limit: int = MAX_ROWS) -> str:
201
+ """Render rows as pipe-delimited lines for a model prompt."""
202
+ lines = [" | ".join(cell for cell in row) for row in rows[:limit]]
203
+ return "\n".join(lines)[:MAX_TEXT_CHARS]
finbot/features/__init__.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bundled features.
2
+
3
+ To add your own: subclass :class:`~finbot.features.base.Feature`, then include
4
+ it in the list passed to the registry (see ``finbot.app.build_services``).
5
+ Nothing else in the codebase needs to change.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .base import (
11
+ Attachment,
12
+ BotContext,
13
+ CommandSpec,
14
+ Feature,
15
+ Reply,
16
+ Services,
17
+ SimpleFeature,
18
+ )
19
+ from .budgets import BudgetsFeature
20
+ from .coaching import CoachingFeature
21
+ from .core import CoreFeature
22
+ from .expenses import ExpensesFeature
23
+
24
+ __all__ = [
25
+ "Attachment",
26
+ "BotContext",
27
+ "BudgetsFeature",
28
+ "CoachingFeature",
29
+ "CommandSpec",
30
+ "CoreFeature",
31
+ "ExpensesFeature",
32
+ "Feature",
33
+ "Reply",
34
+ "Services",
35
+ "SimpleFeature",
36
+ "default_features",
37
+ ]
38
+
39
+
40
+ def default_features() -> list[Feature]:
41
+ """The stock feature set, in registration order."""
42
+ return [
43
+ ExpensesFeature(),
44
+ BudgetsFeature(),
45
+ CoachingFeature(),
46
+ CoreFeature(),
47
+ ]
finbot/features/base.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The extension point.
2
+
3
+ A *feature* is a self-contained bundle of bot behaviour: some commands, and
4
+ optionally an interest in plain messages or uploaded files. Adding one means
5
+ subclassing :class:`Feature` and registering it -- no edits to the router, the
6
+ webhook, or any other feature.
7
+
8
+ Expense tracking is simply the first feature registered. Anything else a bot
9
+ needs to do (reminders, notes, standups, ops alerts) drops in beside it on
10
+ equal terms.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from abc import ABC, abstractmethod
16
+ from dataclasses import dataclass, field
17
+ from datetime import date
18
+ from typing import TYPE_CHECKING, Optional, Sequence
19
+
20
+ if TYPE_CHECKING: # pragma: no cover - typing only
21
+ from ..advice import Advisor
22
+ from ..config import Settings
23
+ from ..ingest import Ingestor
24
+ from ..llm import LLMClient
25
+ from ..models import PendingImport
26
+ from ..store import Store
27
+ from ..registry import FeatureRegistry
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CommandSpec:
32
+ """One slash command, and enough metadata to build /help and setMyCommands."""
33
+
34
+ name: str
35
+ help: str
36
+ args: str = ""
37
+ aliases: tuple[str, ...] = ()
38
+ #: Hidden commands still work but stay out of the menu (e.g. /confirm).
39
+ hidden: bool = False
40
+
41
+ @property
42
+ def usage(self) -> str:
43
+ return f"/{self.name} {self.args}".strip()
44
+
45
+
46
+ @dataclass
47
+ class Attachment:
48
+ """A file the user sent, already downloaded."""
49
+
50
+ data: bytes
51
+ filename: str = ""
52
+ mime_type: str = ""
53
+ kind: str = "document" # "photo" | "document"
54
+ caption: str = ""
55
+
56
+ @property
57
+ def is_image(self) -> bool:
58
+ return self.kind == "photo" or self.mime_type.startswith("image/")
59
+
60
+
61
+ @dataclass
62
+ class Services:
63
+ """Shared dependencies handed to every feature."""
64
+
65
+ settings: "Settings"
66
+ store: "Store"
67
+ llm: "LLMClient"
68
+ ingestor: "Ingestor"
69
+ advisor: "Advisor"
70
+ registry: "FeatureRegistry"
71
+ #: Imports awaiting /confirm, keyed by user id. Held in memory only -- a
72
+ #: Space restart drops them, which is acceptable because the user simply
73
+ #: re-sends the file, and it keeps unconfirmed model output out of storage.
74
+ pending: dict[int, "PendingImport"] = field(default_factory=dict)
75
+
76
+
77
+ @dataclass
78
+ class BotContext:
79
+ """Everything a feature needs to answer one update."""
80
+
81
+ user_id: int
82
+ chat_id: int
83
+ services: Services
84
+ today: date
85
+ text: str = ""
86
+ command: str = ""
87
+ args: str = ""
88
+ attachment: Optional[Attachment] = None
89
+
90
+ @property
91
+ def settings(self) -> "Settings":
92
+ return self.services.settings
93
+
94
+ @property
95
+ def store(self) -> "Store":
96
+ return self.services.store
97
+
98
+
99
+ @dataclass
100
+ class Reply:
101
+ """What to send back. HTML parse mode -- see finbot.render for why."""
102
+
103
+ text: str
104
+ parse_mode: Optional[str] = "HTML"
105
+ disable_preview: bool = True
106
+ #: Optional file to send alongside the text, as (filename, bytes).
107
+ document: Optional[tuple[str, bytes]] = None
108
+
109
+
110
+ class Feature(ABC):
111
+ #: Stable identifier, used in logs and /help grouping.
112
+ name: str = "feature"
113
+ #: One line shown in /help.
114
+ description: str = ""
115
+
116
+ def commands(self) -> Sequence[CommandSpec]:
117
+ """Slash commands this feature owns."""
118
+ return ()
119
+
120
+ def handle_command(self, ctx: BotContext) -> Optional[Reply]:
121
+ """Handle one of this feature's commands. Return None to decline."""
122
+ return None
123
+
124
+ def handle_message(self, ctx: BotContext) -> Optional[Reply]:
125
+ """Handle plain (non-command) text. Return None to let others try."""
126
+ return None
127
+
128
+ def handle_attachment(self, ctx: BotContext) -> Optional[Reply]:
129
+ """Handle an uploaded file. Return None to let others try."""
130
+ return None
131
+
132
+ #: Lower runs first when several features want the same plain message.
133
+ priority: int = 100
134
+
135
+
136
+ class SimpleFeature(Feature):
137
+ """Convenience base that routes commands to ``cmd_<name>`` methods."""
138
+
139
+ @abstractmethod
140
+ def commands(self) -> Sequence[CommandSpec]: # pragma: no cover - abstract
141
+ ...
142
+
143
+ def handle_command(self, ctx: BotContext) -> Optional[Reply]:
144
+ for spec in self.commands():
145
+ if ctx.command == spec.name or ctx.command in spec.aliases:
146
+ handler = getattr(self, f"cmd_{spec.name}", None)
147
+ if handler is None:
148
+ return None
149
+ return handler(ctx)
150
+ return None
finbot/features/budgets.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Monthly budgets per category."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Sequence
6
+
7
+ from .. import render
8
+ from ..analytics import budget_status, month_bounds
9
+ from ..categories import CATEGORIES, coerce
10
+ from ..categories import label as category_label
11
+ from ..models import Budget
12
+ from ..money import parse_amount
13
+ from .base import BotContext, CommandSpec, Reply, SimpleFeature
14
+
15
+
16
+ class BudgetsFeature(SimpleFeature):
17
+ name = "budgets"
18
+ description = "🎯 Budgets"
19
+ priority = 20
20
+
21
+ def commands(self) -> Sequence[CommandSpec]:
22
+ return (
23
+ CommandSpec("budget", "Set a monthly budget", "<category> <amount>"),
24
+ CommandSpec("budgets", "How you're tracking against budgets"),
25
+ CommandSpec("unbudget", "Remove a budget", "<category>"),
26
+ CommandSpec("categories", "List the category names"),
27
+ )
28
+
29
+ def cmd_budget(self, ctx: BotContext) -> Reply:
30
+ args = ctx.args.strip()
31
+ if not args:
32
+ return Reply(
33
+ "Set a monthly cap, e.g. <code>/budget food_dining 8000</code>.\n"
34
+ "/categories lists the names."
35
+ )
36
+
37
+ parts = args.split()
38
+ category = coerce(parts[0])
39
+ if category == "other" and parts[0].lower() not in ("other",):
40
+ return Reply(
41
+ f"I don't recognise the category <code>{render.escape(parts[0])}</code>.\n"
42
+ "/categories lists what I understand."
43
+ )
44
+
45
+ amount = parse_amount(" ".join(parts[1:]), ctx.settings.default_currency)
46
+ if amount is None or amount.minor <= 0:
47
+ return Reply("I need an amount, e.g. <code>/budget groceries 12000</code>.")
48
+
49
+ budget = Budget(
50
+ user_id=ctx.user_id,
51
+ category=category,
52
+ amount_minor=amount.minor,
53
+ currency=amount.currency or ctx.settings.default_currency,
54
+ )
55
+ ctx.store.set_budget(budget)
56
+ return Reply(
57
+ f"🎯 Budget set: {render.escape(category_label(category))} "
58
+ f"{render.money(budget.amount_minor, budget.currency)}/month."
59
+ )
60
+
61
+ def cmd_budgets(self, ctx: BotContext) -> Reply:
62
+ budgets = ctx.store.get_budgets(ctx.user_id)
63
+ start, _ = month_bounds(ctx.today)
64
+ expenses = ctx.store.list_expenses(ctx.user_id, start, ctx.today)
65
+ return Reply(render.budget_list(budget_status(budgets, expenses)))
66
+
67
+ def cmd_unbudget(self, ctx: BotContext) -> Reply:
68
+ category = coerce(ctx.args.strip())
69
+ if ctx.store.delete_budget(ctx.user_id, category):
70
+ return Reply(f"Removed the {render.escape(category_label(category))} budget.")
71
+ return Reply("No budget set for that category.")
72
+
73
+ def cmd_categories(self, ctx: BotContext) -> Reply:
74
+ lines = ["<b>Categories</b>"]
75
+ lines.extend(
76
+ f"<code>{name}</code> — {render.escape(category_label(name))}"
77
+ for name in CATEGORIES
78
+ )
79
+ lines.append("")
80
+ lines.append("Tag an entry to override: <code>250 lunch #transport</code>")
81
+ return Reply("\n".join(lines))
finbot/features/coaching.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Budget coaching over the user's own logged spending."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Sequence
6
+
7
+ from .. import render
8
+ from ..advice import DISCLAIMER
9
+ from ..analytics import (
10
+ budget_status,
11
+ detect_recurring,
12
+ month_bounds,
13
+ previous_month_bounds,
14
+ )
15
+ from .base import BotContext, CommandSpec, Reply, SimpleFeature
16
+
17
+
18
+ class CoachingFeature(SimpleFeature):
19
+ name = "coaching"
20
+ description = "🧭 Coaching"
21
+ priority = 30
22
+
23
+ def commands(self) -> Sequence[CommandSpec]:
24
+ return (
25
+ CommandSpec("advice", "Observations on your spending", "[question]"),
26
+ )
27
+
28
+ def cmd_advice(self, ctx: BotContext) -> Reply:
29
+ start, _ = month_bounds(ctx.today)
30
+ this_month = ctx.store.list_expenses(ctx.user_id, start, ctx.today)
31
+
32
+ prev_start, prev_end = previous_month_bounds(ctx.today)
33
+ last_month = ctx.store.list_expenses(ctx.user_id, prev_start, prev_end)
34
+
35
+ all_expenses = ctx.store.list_expenses(ctx.user_id)
36
+ recurring = detect_recurring(all_expenses)
37
+ budgets = budget_status(ctx.store.get_budgets(ctx.user_id), this_month)
38
+
39
+ text = ctx.services.advisor.advise(
40
+ expenses_this_month=this_month,
41
+ expenses_last_month=last_month,
42
+ recurring=recurring,
43
+ budgets=budgets,
44
+ default_currency=ctx.settings.default_currency,
45
+ today=ctx.today,
46
+ question=ctx.args.strip() or None,
47
+ )
48
+
49
+ body = render.escape(text)
50
+ footer = render.escape(DISCLAIMER.replace("_", ""))
51
+ return Reply(f"{body}\n\n<i>{footer}</i>")
finbot/features/core.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Onboarding, help and diagnostics. Registered last so /help sees everything."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Sequence
6
+
7
+ from .. import render
8
+ from ..analytics import month_bounds
9
+ from .base import BotContext, CommandSpec, Reply, SimpleFeature
10
+
11
+ _WELCOME = """<b>Hello 👋</b>
12
+
13
+ Just tell me what you spent and I'll keep track.
14
+
15
+ <b>Log it</b>
16
+ <code>250 coffee</code>
17
+ <code>₹1,240 groceries at bigbasket</code>
18
+ <code>45.50 lunch yesterday</code>
19
+ <code>320 uber #transport</code>
20
+
21
+ <b>Send me things</b>
22
+ 📷 a receipt photo — I'll read the total
23
+ 📄 a bank statement (PDF, CSV, XLSX) — I'll pull out the transactions and ask before saving
24
+
25
+ <b>See it back</b>
26
+ /today · /week · /month · /recurring · /advice
27
+
28
+ /help for everything."""
29
+
30
+
31
+ class CoreFeature(SimpleFeature):
32
+ name = "core"
33
+ description = "⚙️ General"
34
+ priority = 90
35
+
36
+ def commands(self) -> Sequence[CommandSpec]:
37
+ return (
38
+ CommandSpec("start", "What this bot does"),
39
+ CommandSpec("help", "All commands"),
40
+ CommandSpec("status", "Configuration and storage health"),
41
+ CommandSpec("whoami", "Your Telegram user ID", hidden=True),
42
+ )
43
+
44
+ def cmd_start(self, ctx: BotContext) -> Reply:
45
+ return Reply(_WELCOME)
46
+
47
+ def cmd_help(self, ctx: BotContext) -> Reply:
48
+ body = ctx.services.registry.help_text()
49
+ tail = (
50
+ "\n\n<i>Plain text with a number is logged as an expense. "
51
+ "Add #category to override the guess.</i>"
52
+ )
53
+ return Reply(body + tail)
54
+
55
+ def cmd_whoami(self, ctx: BotContext) -> Reply:
56
+ return Reply(f"Your Telegram user ID is <code>{ctx.user_id}</code>.")
57
+
58
+ def cmd_status(self, ctx: BotContext) -> Reply:
59
+ settings = ctx.settings
60
+ store = ctx.store
61
+
62
+ start, _ = month_bounds(ctx.today)
63
+ month_count = len(ctx.store.list_expenses(ctx.user_id, start, ctx.today))
64
+
65
+ lines = [
66
+ "<b>Status</b>",
67
+ f"Transactions logged: {store.count(ctx.user_id)} ({month_count} this month)",
68
+ f"Today (in {render.escape(settings.timezone)}): {ctx.today.isoformat()}",
69
+ "",
70
+ "<b>Inference</b>",
71
+ ]
72
+
73
+ if settings.llm_enabled:
74
+ lines.append(f"✅ {render.escape(settings.llm_model)}")
75
+ lines.append(f" vision: {render.escape(settings.llm_vision_model)}")
76
+ lines.append(f" via {render.escape(settings.llm_base_url)}")
77
+ else:
78
+ lines.append("⚠️ No HF_TOKEN — receipts, statements and advice are off.")
79
+ lines.append(" Plain-text logging still works.")
80
+
81
+ lines.append("")
82
+ lines.append("<b>Access</b>")
83
+ if settings.open_access:
84
+ cap = settings.uploads_per_user_per_day
85
+ lines.append("🌍 Open — anyone on Telegram can use this bot.")
86
+ lines.append(" Each person's ledger is separate; nobody sees yours.")
87
+ lines.append(
88
+ f" Uploads capped at {cap}/user/day."
89
+ if cap > 0
90
+ else " ⚠️ Uploads uncapped — strangers can spend your inference credits."
91
+ )
92
+ else:
93
+ lines.append(f"🔒 Allowlist — {len(settings.allowed_user_ids)} user(s).")
94
+
95
+ lines.append("")
96
+ lines.append("<b>Storage</b>")
97
+ if settings.sync_enabled:
98
+ lines.append(f"✅ {render.escape(store.sync_status)}")
99
+ else:
100
+ lines.append(
101
+ "🔴 <b>Local only — Space disk is ephemeral.</b>\n"
102
+ " Your data will be lost on the next restart or sleep.\n"
103
+ " Set HF_DATASET_REPO to fix this."
104
+ )
105
+
106
+ if gaps := settings.missing_required():
107
+ lines.append("")
108
+ lines.append("<b>Not configured</b>")
109
+ lines.extend(f"• {render.escape(gap)}" for gap in gaps)
110
+
111
+ return Reply("\n".join(lines))
finbot/features/expenses.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Expense logging, summaries and imports -- the first registered feature."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Optional, Sequence
7
+
8
+ from .. import render
9
+ from ..analytics import (
10
+ detect_recurring,
11
+ dominant_currency,
12
+ period_bounds,
13
+ previous_month_bounds,
14
+ summarise,
15
+ )
16
+ from ..extract import extract
17
+ from ..models import Expense
18
+ from ..money import format_money
19
+ from .base import BotContext, CommandSpec, Reply, SimpleFeature
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ #: Above this many rows, remind the user that /undo drops the whole batch.
24
+ LARGE_IMPORT = 50
25
+
26
+
27
+ class ExpensesFeature(SimpleFeature):
28
+ name = "expenses"
29
+ description = "💸 Spending"
30
+ priority = 10
31
+
32
+ def commands(self) -> Sequence[CommandSpec]:
33
+ return (
34
+ CommandSpec("spend", "Log an expense", "<amount> <what>"),
35
+ CommandSpec("today", "What you spent today"),
36
+ CommandSpec("week", "This week's spending"),
37
+ CommandSpec("month", "This month's spending"),
38
+ CommandSpec(
39
+ "report",
40
+ "Summary for a period",
41
+ "[today|week|month|last_month|30d|year|all]",
42
+ ),
43
+ CommandSpec("list", "Recent transactions", "[count]"),
44
+ CommandSpec("recurring", "Subscriptions and repeat charges"),
45
+ CommandSpec("undo", "Remove the last entry or import"),
46
+ CommandSpec("export", "Download everything as CSV"),
47
+ CommandSpec("confirm", "Save a pending import", hidden=True),
48
+ CommandSpec("cancel", "Discard a pending import", hidden=True),
49
+ )
50
+
51
+ # ------------------------------------------------------------- logging
52
+
53
+ def _log_draft(self, ctx: BotContext, draft, source: str) -> Expense:
54
+ expense = draft.to_expense(ctx.user_id, source=source, batch_id="")
55
+ ctx.store.add_expense(expense)
56
+ return expense
57
+
58
+ def cmd_spend(self, ctx: BotContext) -> Reply:
59
+ if not ctx.args.strip():
60
+ return Reply(
61
+ "Tell me what to log, e.g. <code>/spend 250 coffee</code>.\n"
62
+ "You can also just send <code>250 coffee</code> with no command."
63
+ )
64
+ return self._log_text(ctx, ctx.args)
65
+
66
+ def handle_message(self, ctx: BotContext) -> Optional[Reply]:
67
+ """Plain text is treated as an expense if it contains an amount."""
68
+ if not ctx.text.strip():
69
+ return None
70
+ return self._log_text(ctx, ctx.text)
71
+
72
+ def _log_text(self, ctx: BotContext, text: str) -> Optional[Reply]:
73
+ draft = ctx.services.ingestor.from_text(text, ctx.today)
74
+ if draft is None:
75
+ return None # no amount found -- let another feature try
76
+
77
+ expense = self._log_draft(ctx, draft, source="text")
78
+ note = ""
79
+ if expense.category == "other":
80
+ note = "Couldn't categorise it — add #groceries etc. next time to pin it."
81
+ return Reply(render.logged(expense, note))
82
+
83
+ # --------------------------------------------------------- attachments
84
+
85
+ def handle_attachment(self, ctx: BotContext) -> Optional[Reply]:
86
+ attachment = ctx.attachment
87
+ if attachment is None:
88
+ return None
89
+
90
+ if attachment.is_image:
91
+ return self._handle_receipt(ctx, attachment)
92
+ return self._handle_statement(ctx, attachment)
93
+
94
+ def _handle_receipt(self, ctx: BotContext, attachment) -> Reply:
95
+ draft, error = ctx.services.ingestor.from_receipt(
96
+ attachment.data, attachment.mime_type or "image/jpeg", ctx.today
97
+ )
98
+ if draft is None:
99
+ return Reply(render.escape(error))
100
+
101
+ if attachment.caption:
102
+ draft.description = attachment.caption[:200]
103
+
104
+ expense = self._log_draft(ctx, draft, source="photo")
105
+ confidence = ""
106
+ if expense.confidence < 0.7:
107
+ confidence = "I wasn't fully confident reading that — check the amount."
108
+ return Reply(render.logged(expense, confidence))
109
+
110
+ def _handle_statement(self, ctx: BotContext, attachment) -> Reply:
111
+ doc = extract(attachment.data, attachment.filename, attachment.mime_type)
112
+ pending, error = ctx.services.ingestor.from_document(
113
+ doc, ctx.user_id, ctx.today, label=attachment.filename
114
+ )
115
+ if pending is None:
116
+ return Reply(render.escape(error))
117
+
118
+ # Drop rows already in the ledger so re-sending a statement is safe.
119
+ # Paired as a list, not a dict: ExpenseDraft is an unhashable dataclass.
120
+ pairs = [
121
+ (
122
+ draft,
123
+ draft.to_expense(
124
+ ctx.user_id, pending.source, pending.batch_id
125
+ ).dedupe_key,
126
+ )
127
+ for draft in pending.drafts
128
+ ]
129
+ existing = ctx.store.existing_dedupe_keys(ctx.user_id, [k for _, k in pairs])
130
+
131
+ kept = [draft for draft, key in pairs if key not in existing]
132
+ pending.duplicates = len(pending.drafts) - len(kept)
133
+ pending.drafts = kept
134
+
135
+ if not pending.drafts:
136
+ return Reply(
137
+ f"Every transaction in that file is already logged "
138
+ f"({pending.duplicates} duplicates). Nothing to add."
139
+ )
140
+
141
+ ctx.services.pending[ctx.user_id] = pending
142
+ return Reply(render.pending_import(pending))
143
+
144
+ def cmd_confirm(self, ctx: BotContext) -> Reply:
145
+ pending = ctx.services.pending.pop(ctx.user_id, None)
146
+ if pending is None:
147
+ return Reply("Nothing waiting to be confirmed.")
148
+
149
+ expenses = [
150
+ draft.to_expense(ctx.user_id, pending.source, pending.batch_id)
151
+ for draft in pending.drafts
152
+ ]
153
+ ctx.store.add_expenses(expenses)
154
+ ctx.store.flush() # push straight to the Hub; imports are expensive to redo
155
+
156
+ totals = pending.total_by_currency()
157
+ total_str = " + ".join(
158
+ format_money(v, k) for k, v in sorted(totals.items())
159
+ )
160
+ note = ""
161
+ if len(expenses) >= LARGE_IMPORT:
162
+ note = "\n<i>/undo removes this whole import.</i>"
163
+ return Reply(
164
+ f"✅ Saved {len(expenses)} transactions · {render.escape(total_str)}{note}"
165
+ )
166
+
167
+ def cmd_cancel(self, ctx: BotContext) -> Reply:
168
+ pending = ctx.services.pending.pop(ctx.user_id, None)
169
+ if pending is None:
170
+ return Reply("Nothing pending.")
171
+ return Reply(f"Discarded {len(pending.drafts)} unsaved transactions.")
172
+
173
+ # ---------------------------------------------------------- summaries
174
+
175
+ def _summary_reply(self, ctx: BotContext, period: str) -> Reply:
176
+ start, end, label = period_bounds(period, ctx.today)
177
+ expenses = ctx.store.list_expenses(ctx.user_id, start, end)
178
+ currency = dominant_currency(expenses, ctx.settings.default_currency)
179
+
180
+ previous = None
181
+ if period == "month":
182
+ prev_start, prev_end = previous_month_bounds(ctx.today)
183
+ # Compare like with like: the same elapsed slice of last month, not
184
+ # a full month against a partial one.
185
+ clipped_end = min(prev_end, prev_start + (ctx.today - start))
186
+ previous = ctx.store.list_expenses(ctx.user_id, prev_start, clipped_end)
187
+
188
+ summ = render.summary(
189
+ summarise(expenses, currency, label, start, end, previous)
190
+ )
191
+
192
+ others = [c for c in {e.currency.upper() for e in expenses} if c != currency]
193
+ if others:
194
+ extra = []
195
+ for code in sorted(others):
196
+ subtotal = sum(
197
+ e.amount_minor for e in expenses if e.currency.upper() == code
198
+ )
199
+ extra.append(format_money(subtotal, code))
200
+ summ += f"\n<i>Also: {render.escape(' · '.join(extra))}</i>"
201
+
202
+ return Reply(summ)
203
+
204
+ def cmd_today(self, ctx: BotContext) -> Reply:
205
+ return self._summary_reply(ctx, "today")
206
+
207
+ def cmd_week(self, ctx: BotContext) -> Reply:
208
+ return self._summary_reply(ctx, "week")
209
+
210
+ def cmd_month(self, ctx: BotContext) -> Reply:
211
+ return self._summary_reply(ctx, "month")
212
+
213
+ def cmd_report(self, ctx: BotContext) -> Reply:
214
+ period = (ctx.args.strip().lower() or "month").replace(" ", "_")
215
+ if period in {"lastmonth", "last-month"}:
216
+ period = "last_month"
217
+ return self._summary_reply(ctx, period)
218
+
219
+ def cmd_list(self, ctx: BotContext) -> Reply:
220
+ try:
221
+ count = max(1, min(50, int(ctx.args.strip()))) if ctx.args.strip() else 15
222
+ except ValueError:
223
+ count = 15
224
+ expenses = ctx.store.list_expenses(ctx.user_id, limit=count)
225
+ return Reply(render.expense_list(expenses, f"Last {len(expenses)} transactions"))
226
+
227
+ def cmd_recurring(self, ctx: BotContext) -> Reply:
228
+ expenses = ctx.store.list_expenses(ctx.user_id)
229
+ currency = dominant_currency(expenses, ctx.settings.default_currency)
230
+ return Reply(render.recurring_list(detect_recurring(expenses), currency))
231
+
232
+ # -------------------------------------------------------------- undo
233
+
234
+ def cmd_undo(self, ctx: BotContext) -> Reply:
235
+ last = ctx.store.last_expense(ctx.user_id)
236
+ if last is None:
237
+ return Reply("Nothing to undo.")
238
+
239
+ if last.batch_id:
240
+ removed = ctx.store.delete_batch(ctx.user_id, last.batch_id)
241
+ return Reply(f"Removed the last import — {removed} transactions.")
242
+
243
+ ctx.store.delete_expense(ctx.user_id, last.id)
244
+ return Reply(f"Removed: {render.expense_line(last)}")
245
+
246
+ # ------------------------------------------------------------ export
247
+
248
+ def cmd_export(self, ctx: BotContext) -> Reply:
249
+ expenses = ctx.store.list_expenses(ctx.user_id)
250
+ if not expenses:
251
+ return Reply("Nothing to export yet.")
252
+ payload = render.csv_export(expenses).encode("utf-8")
253
+ return Reply(
254
+ f"📄 {len(expenses)} transactions.",
255
+ document=(f"finbot-export-{ctx.today.isoformat()}.csv", payload),
256
+ )
finbot/ingest.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn user input into expense drafts.
2
+
3
+ Order of preference throughout: deterministic parsing first, model second. The
4
+ regex parser handles "coffee 250" instantly, for free, and identically every
5
+ time. The model is spent only on what actually needs judgement -- reading a
6
+ receipt photo, or working out which column of an unfamiliar bank export is the
7
+ amount.
8
+
9
+ Statement imports never write straight to the ledger. They produce a
10
+ :class:`~finbot.models.PendingImport` that the user confirms, because a model
11
+ that misreads a column would otherwise quietly poison months of history.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from datetime import date, datetime
18
+ from typing import Any, Optional
19
+
20
+ from .categories import CATEGORIES, categorise, coerce
21
+ from .extract import ExtractedDocument, rows_to_text
22
+ from .llm import LLMClient, LLMUnavailable
23
+ from .models import ExpenseDraft, PendingImport
24
+ from .money import to_minor
25
+ from .parser import parse_expense
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+ _CATEGORY_LIST = ", ".join(CATEGORIES)
30
+
31
+ _STATEMENT_SYSTEM = f"""You extract transactions from bank and card statements.
32
+
33
+ The statement may come from any bank in any country, in any currency, with any
34
+ column order or language. Work out the structure yourself from the content.
35
+
36
+ Return a JSON object with one key, "transactions", whose value is an array.
37
+ Each element must have exactly these fields:
38
+ "date": ISO 8601 date, YYYY-MM-DD
39
+ "description": the merchant or narration, cleaned of reference numbers
40
+ "amount": a positive number in major units (e.g. 1234.56), never a string
41
+ "currency": ISO 4217 code, e.g. INR, USD, EUR
42
+ "direction": "debit" if money left the account, "credit" if it came in
43
+ "category": one of: {_CATEGORY_LIST}
44
+
45
+ Rules:
46
+ - Infer the currency from symbols or codes in the document. If genuinely absent,
47
+ use "{{default_currency}}".
48
+ - Amounts are always positive; use "direction" to express the sign.
49
+ - Ambiguous dates are day-first unless the document proves otherwise.
50
+ - Skip opening/closing balances, subtotals, carried-forward lines and headers.
51
+ - If you cannot read a row confidently, omit it rather than guessing.
52
+ - Return only the JSON object."""
53
+
54
+ _RECEIPT_SYSTEM = f"""You read receipts and bills from photographs.
55
+
56
+ Return a JSON object with these fields:
57
+ "merchant": the shop or business name, "" if unreadable
58
+ "date": ISO 8601 date YYYY-MM-DD, or "" if not visible
59
+ "amount": the FINAL TOTAL paid, positive, in major units, as a number
60
+ "currency": ISO 4217 code inferred from the receipt
61
+ "category": one of: {_CATEGORY_LIST}
62
+ "confidence": 0.0-1.0, how sure you are of the total
63
+
64
+ Take the grand total actually paid -- not a subtotal, not a single line item,
65
+ not the tax. If the image is not a receipt, set amount to 0."""
66
+
67
+ _FREETEXT_SYSTEM = f"""You turn a person's shorthand note into one expense record.
68
+
69
+ Return a JSON object with:
70
+ "amount": positive number in major units
71
+ "currency": ISO 4217 code
72
+ "description": short, human-readable
73
+ "date": ISO 8601 YYYY-MM-DD
74
+ "category": one of: {_CATEGORY_LIST}
75
+
76
+ If the note contains no spend at all, return {{"amount": 0}}."""
77
+
78
+
79
+ def _parse_any_date(value: Any, fallback: date) -> date:
80
+ if isinstance(value, date) and not isinstance(value, datetime):
81
+ return value
82
+ if isinstance(value, datetime):
83
+ return value.date()
84
+ text = str(value or "").strip()
85
+ if not text:
86
+ return fallback
87
+ try:
88
+ return date.fromisoformat(text[:10])
89
+ except ValueError:
90
+ pass
91
+ try:
92
+ from dateutil import parser as dateparser
93
+
94
+ return dateparser.parse(text, dayfirst=True, fuzzy=True).date()
95
+ except Exception:
96
+ return fallback
97
+
98
+
99
+ def _coerce_amount(value: Any) -> Optional[float]:
100
+ if isinstance(value, (int, float)):
101
+ return abs(float(value))
102
+ text = str(value or "").strip()
103
+ if not text:
104
+ return None
105
+ # Strip everything that isn't numeric, then reuse the shared separator logic.
106
+ from .money import _normalise_number # local import: private helper
107
+
108
+ cleaned = "".join(ch for ch in text if ch.isdigit() or ch in ".,-")
109
+ cleaned = cleaned.lstrip("-")
110
+ normalised = _normalise_number(cleaned)
111
+ if normalised is None:
112
+ return None
113
+ try:
114
+ return abs(float(normalised))
115
+ except ValueError:
116
+ return None
117
+
118
+
119
+ def _draft_from_obj(
120
+ obj: dict, default_currency: str, today: date
121
+ ) -> Optional[ExpenseDraft]:
122
+ """Validate one model-emitted record into a draft, or drop it."""
123
+ if not isinstance(obj, dict):
124
+ return None
125
+
126
+ amount = _coerce_amount(obj.get("amount"))
127
+ if not amount or amount <= 0:
128
+ return None
129
+
130
+ currency = str(obj.get("currency") or default_currency).upper()[:3]
131
+ if not currency.isalpha():
132
+ currency = default_currency.upper()
133
+
134
+ description = str(obj.get("description") or obj.get("merchant") or "").strip()
135
+ description = " ".join(description.split())[:200] or "unlabelled"
136
+
137
+ category = coerce(str(obj.get("category") or ""))
138
+ if category == "other":
139
+ # The model's label was unusable; keyword rules often still know this one.
140
+ guessed, hit = categorise(description)
141
+ if hit:
142
+ category = guessed
143
+
144
+ return ExpenseDraft(
145
+ amount_minor=to_minor(amount, currency),
146
+ currency=currency,
147
+ description=description,
148
+ occurred_on=_parse_any_date(obj.get("date"), today),
149
+ category=category,
150
+ merchant=str(obj.get("merchant") or "").strip()[:120],
151
+ confidence=float(obj.get("confidence") or 0.7),
152
+ )
153
+
154
+
155
+ class Ingestor:
156
+ def __init__(self, llm: LLMClient, default_currency: str = "INR"):
157
+ self.llm = llm
158
+ self.default_currency = default_currency
159
+
160
+ # ------------------------------------------------------------ free text
161
+
162
+ def from_text(self, text: str, today: Optional[date] = None) -> Optional[ExpenseDraft]:
163
+ """Deterministic parse, with a model fallback only if that fails."""
164
+ today = today or date.today()
165
+
166
+ draft = parse_expense(text, self.default_currency, today)
167
+ if draft is not None:
168
+ return draft
169
+
170
+ if not self.llm.enabled:
171
+ return None
172
+
173
+ try:
174
+ obj = self.llm.chat_json(
175
+ _FREETEXT_SYSTEM,
176
+ f"Today is {today.isoformat()}.\n\nNote: {text}",
177
+ max_tokens=400,
178
+ )
179
+ except (LLMUnavailable, ValueError) as exc:
180
+ log.info("Free-text model fallback unavailable: %s", exc)
181
+ return None
182
+
183
+ return _draft_from_obj(obj, self.default_currency, today)
184
+
185
+ # -------------------------------------------------------------- receipts
186
+
187
+ def from_receipt(
188
+ self, image_bytes: bytes, mime_type: str, today: Optional[date] = None
189
+ ) -> tuple[Optional[ExpenseDraft], str]:
190
+ """Read a receipt photo. Returns (draft, error_message)."""
191
+ today = today or date.today()
192
+
193
+ if not self.llm.enabled:
194
+ return None, (
195
+ "Reading receipt photos needs an inference token. "
196
+ "Set HF_TOKEN in the Space secrets, or type the amount instead."
197
+ )
198
+
199
+ try:
200
+ obj = self.llm.vision_json(
201
+ _RECEIPT_SYSTEM,
202
+ f"Today is {today.isoformat()}. Read this receipt.",
203
+ image_bytes,
204
+ mime_type,
205
+ )
206
+ except (LLMUnavailable, ValueError) as exc:
207
+ log.warning("Receipt read failed: %s", exc)
208
+ return None, f"I couldn't read that receipt ({exc}). Try a clearer photo?"
209
+
210
+ draft = _draft_from_obj(obj, self.default_currency, today)
211
+ if draft is None:
212
+ return None, (
213
+ "I couldn't find a total on that image. If it is a receipt, "
214
+ "try a straighter, better-lit photo, or just type the amount."
215
+ )
216
+ return draft, ""
217
+
218
+ # ------------------------------------------------------------ statements
219
+
220
+ def from_document(
221
+ self,
222
+ doc: ExtractedDocument,
223
+ user_id: int,
224
+ today: Optional[date] = None,
225
+ label: str = "",
226
+ ) -> tuple[Optional[PendingImport], str]:
227
+ """Map an extracted statement into drafts. Returns (pending, error)."""
228
+ today = today or date.today()
229
+
230
+ if doc.kind == "unsupported" or doc.is_empty:
231
+ return None, doc.note or "There was nothing readable in that file."
232
+
233
+ if not self.llm.enabled:
234
+ return None, (
235
+ "Reading statements needs an inference token. Set HF_TOKEN in "
236
+ "the Space secrets and send the file again."
237
+ )
238
+
239
+ payload = rows_to_text(doc.rows) if doc.rows else doc.text
240
+ if not payload.strip():
241
+ return None, "There was nothing readable in that file."
242
+
243
+ system = _STATEMENT_SYSTEM.replace("{default_currency}", self.default_currency)
244
+ try:
245
+ result = self.llm.chat_json(
246
+ system,
247
+ f"Today is {today.isoformat()}. Extract every transaction.\n\n{payload}",
248
+ max_tokens=8000,
249
+ )
250
+ except (LLMUnavailable, ValueError) as exc:
251
+ log.warning("Statement extraction failed: %s", exc)
252
+ return None, f"I couldn't read that statement ({exc})."
253
+
254
+ records = result.get("transactions") if isinstance(result, dict) else result
255
+ if not isinstance(records, list):
256
+ return None, "The extractor returned an unexpected shape. Try again?"
257
+
258
+ drafts: list[ExpenseDraft] = []
259
+ skipped = 0
260
+ credits = 0
261
+ for record in records:
262
+ if not isinstance(record, dict):
263
+ skipped += 1
264
+ continue
265
+ # Credits are money coming in. Storing them alongside spend would
266
+ # corrupt every "total spent" figure, so they are counted and
267
+ # reported rather than written.
268
+ if str(record.get("direction", "debit")).lower().startswith("cred"):
269
+ credits += 1
270
+ continue
271
+ draft = _draft_from_obj(record, self.default_currency, today)
272
+ if draft is None:
273
+ skipped += 1
274
+ continue
275
+ drafts.append(draft)
276
+
277
+ if not drafts:
278
+ detail = f" ({credits} credits, {skipped} unreadable rows)" if (credits or skipped) else ""
279
+ return None, f"I found no spending transactions in that file{detail}."
280
+
281
+ pending = PendingImport(
282
+ user_id=user_id,
283
+ drafts=drafts,
284
+ source=doc.kind,
285
+ skipped=skipped,
286
+ label=label,
287
+ )
288
+ if credits:
289
+ pending.label = (pending.label + f" · {credits} credits skipped").strip(" ·")
290
+ if doc.truncated:
291
+ pending.label = (pending.label + " · file truncated").strip(" ·")
292
+ return pending, ""
finbot/limits.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-user throttling for model-backed work.
2
+
3
+ Only relevant in open-access mode. Reading a receipt or a statement spends the
4
+ Space owner's inference credits, so without a cap a single stranger with a
5
+ folder of PDFs can run up an unbounded bill on someone else's token.
6
+
7
+ Deliberately in-memory: it resets when the Space restarts or sleeps, which is
8
+ fine for a budget guard and avoids putting other people's usage records in the
9
+ owner's dataset. Plain text costs nothing (the deterministic parser handles it)
10
+ and is never throttled.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+ import time
17
+ from collections import defaultdict
18
+
19
+ WINDOW_SECONDS = 24 * 60 * 60
20
+
21
+
22
+ class RateLimiter:
23
+ def __init__(self, per_day: int):
24
+ #: Zero or negative disables the limiter entirely.
25
+ self.per_day = per_day
26
+ self._hits: dict[int, list[float]] = defaultdict(list)
27
+ self._lock = threading.Lock()
28
+
29
+ @property
30
+ def enabled(self) -> bool:
31
+ return self.per_day > 0
32
+
33
+ def _prune(self, user_id: int, now: float) -> list[float]:
34
+ recent = [t for t in self._hits[user_id] if now - t < WINDOW_SECONDS]
35
+ self._hits[user_id] = recent
36
+ return recent
37
+
38
+ def check(self, user_id: int, now: float | None = None) -> bool:
39
+ """Record an attempt. False means the user is over their daily cap."""
40
+ if not self.enabled:
41
+ return True
42
+ now = now if now is not None else time.time()
43
+ with self._lock:
44
+ recent = self._prune(user_id, now)
45
+ if len(recent) >= self.per_day:
46
+ return False
47
+ recent.append(now)
48
+ return True
49
+
50
+ def remaining(self, user_id: int, now: float | None = None) -> int:
51
+ if not self.enabled:
52
+ return -1 # unlimited
53
+ now = now if now is not None else time.time()
54
+ with self._lock:
55
+ return max(0, self.per_day - len(self._prune(user_id, now)))
finbot/llm.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inference against Hugging Face Inference Providers.
2
+
3
+ The router at ``https://router.huggingface.co/v1`` is OpenAI-compatible, so one
4
+ HF token fans out across Cerebras, Together, Fireworks and friends without any
5
+ provider-specific code. Point ``LLM_BASE_URL`` somewhere else -- Ollama, a
6
+ llama.cpp server, OpenAI proper -- and everything here still works.
7
+
8
+ Every entry point degrades rather than raising into the request path: if no
9
+ token is configured, or the provider is down, the caller gets
10
+ :class:`LLMUnavailable` and falls back to deterministic parsing. Losing receipt
11
+ understanding is an inconvenience; a bot that stops accepting "coffee 250"
12
+ because an inference provider is having a bad day is a broken bot.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import json
19
+ import logging
20
+ import re
21
+ import time
22
+ from typing import Any, Optional
23
+
24
+ import httpx
25
+
26
+ from .config import Settings
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ _RETRY_STATUS = {408, 409, 425, 429, 500, 502, 503, 504}
31
+ _MAX_ATTEMPTS = 3
32
+ _FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.S)
33
+
34
+
35
+ class LLMUnavailable(RuntimeError):
36
+ """Inference could not be performed. Callers must degrade gracefully."""
37
+
38
+
39
+ def _extract_json_blob(text: str) -> Any:
40
+ """Parse JSON out of a model response that may be wrapped in prose or fences."""
41
+ text = (text or "").strip()
42
+ if not text:
43
+ raise ValueError("empty response")
44
+
45
+ if match := _FENCE_RE.search(text):
46
+ text = match.group(1).strip()
47
+
48
+ try:
49
+ return json.loads(text)
50
+ except json.JSONDecodeError:
51
+ pass
52
+
53
+ # Fall back to the first balanced {...} or [...] in the response.
54
+ for opener, closer in (("{", "}"), ("[", "]")):
55
+ start = text.find(opener)
56
+ if start == -1:
57
+ continue
58
+ depth = 0
59
+ in_string = False
60
+ escape = False
61
+ for i in range(start, len(text)):
62
+ ch = text[i]
63
+ if escape:
64
+ escape = False
65
+ continue
66
+ if ch == "\\":
67
+ escape = True
68
+ continue
69
+ if ch == '"':
70
+ in_string = not in_string
71
+ continue
72
+ if in_string:
73
+ continue
74
+ if ch == opener:
75
+ depth += 1
76
+ elif ch == closer:
77
+ depth -= 1
78
+ if depth == 0:
79
+ return json.loads(text[start : i + 1])
80
+ raise ValueError(f"no JSON found in response: {text[:200]}")
81
+
82
+
83
+ class LLMClient:
84
+ def __init__(self, settings: Settings):
85
+ self.settings = settings
86
+ self._client: Optional[httpx.Client] = None
87
+
88
+ @property
89
+ def enabled(self) -> bool:
90
+ return self.settings.llm_enabled
91
+
92
+ def _http(self) -> httpx.Client:
93
+ if self._client is None:
94
+ self._client = httpx.Client(
95
+ base_url=self.settings.llm_base_url.rstrip("/"),
96
+ headers={
97
+ "Authorization": f"Bearer {self.settings.hf_token}",
98
+ "Content-Type": "application/json",
99
+ },
100
+ timeout=httpx.Timeout(90.0, connect=15.0),
101
+ )
102
+ return self._client
103
+
104
+ def close(self) -> None:
105
+ if self._client is not None:
106
+ self._client.close()
107
+ self._client = None
108
+
109
+ def _post_chat(self, payload: dict) -> str:
110
+ if not self.enabled:
111
+ raise LLMUnavailable("HF_TOKEN is not set")
112
+
113
+ last_error: Optional[str] = None
114
+ for attempt in range(1, _MAX_ATTEMPTS + 1):
115
+ try:
116
+ response = self._http().post("/chat/completions", json=payload)
117
+ except httpx.RequestError as exc:
118
+ last_error = f"network error: {exc}"
119
+ log.warning("Inference attempt %d failed: %s", attempt, last_error)
120
+ time.sleep(min(2 ** attempt, 8))
121
+ continue
122
+
123
+ if response.status_code in _RETRY_STATUS and attempt < _MAX_ATTEMPTS:
124
+ retry_after = response.headers.get("retry-after")
125
+ delay = float(retry_after) if retry_after and retry_after.isdigit() else min(2 ** attempt, 8)
126
+ last_error = f"HTTP {response.status_code}"
127
+ log.warning("Inference attempt %d got %s; retrying", attempt, last_error)
128
+ time.sleep(delay)
129
+ continue
130
+
131
+ if response.status_code >= 400:
132
+ raise LLMUnavailable(
133
+ f"HTTP {response.status_code}: {response.text[:300]}"
134
+ )
135
+
136
+ data = response.json()
137
+ try:
138
+ return data["choices"][0]["message"]["content"] or ""
139
+ except (KeyError, IndexError, TypeError) as exc:
140
+ raise LLMUnavailable(f"unexpected response shape: {exc}") from exc
141
+
142
+ raise LLMUnavailable(last_error or "exhausted retries")
143
+
144
+ def chat(
145
+ self,
146
+ messages: list[dict],
147
+ *,
148
+ model: Optional[str] = None,
149
+ temperature: float = 0.2,
150
+ max_tokens: int = 1200,
151
+ ) -> str:
152
+ payload = {
153
+ "model": model or self.settings.llm_model,
154
+ "messages": messages,
155
+ "temperature": temperature,
156
+ "max_tokens": max_tokens,
157
+ }
158
+ return self._post_chat(payload)
159
+
160
+ def chat_json(
161
+ self,
162
+ system: str,
163
+ user: str,
164
+ *,
165
+ model: Optional[str] = None,
166
+ temperature: float = 0.0,
167
+ max_tokens: int = 4000,
168
+ ) -> Any:
169
+ """Chat constrained to JSON, with a repair attempt on malformed output."""
170
+ messages = [
171
+ {"role": "system", "content": system},
172
+ {"role": "user", "content": user},
173
+ ]
174
+ payload: dict[str, Any] = {
175
+ "model": model or self.settings.llm_model,
176
+ "messages": messages,
177
+ "temperature": temperature,
178
+ "max_tokens": max_tokens,
179
+ "response_format": {"type": "json_object"},
180
+ }
181
+
182
+ try:
183
+ raw = self._post_chat(payload)
184
+ except LLMUnavailable as exc:
185
+ # Not every provider on the router honours response_format. Retry
186
+ # once without it before giving up.
187
+ if "response_format" not in str(exc):
188
+ payload.pop("response_format", None)
189
+ raw = self._post_chat(payload)
190
+ else:
191
+ raise
192
+
193
+ try:
194
+ return _extract_json_blob(raw)
195
+ except ValueError as exc:
196
+ log.warning("Malformed JSON from model, attempting repair: %s", exc)
197
+ repair = [
198
+ {"role": "system", "content": "Return only valid JSON. No prose, no code fences."},
199
+ {"role": "user", "content": f"Fix this into valid JSON:\n\n{raw[:6000]}"},
200
+ ]
201
+ repaired = self._post_chat(
202
+ {
203
+ "model": model or self.settings.llm_model,
204
+ "messages": repair,
205
+ "temperature": 0.0,
206
+ "max_tokens": max_tokens,
207
+ }
208
+ )
209
+ return _extract_json_blob(repaired)
210
+
211
+ def vision_json(
212
+ self,
213
+ system: str,
214
+ user: str,
215
+ image_bytes: bytes,
216
+ mime_type: str = "image/jpeg",
217
+ *,
218
+ max_tokens: int = 1500,
219
+ ) -> Any:
220
+ """Send an image plus instructions and parse a JSON reply."""
221
+ if not self.enabled:
222
+ raise LLMUnavailable("HF_TOKEN is not set")
223
+
224
+ encoded = base64.b64encode(image_bytes).decode("ascii")
225
+ messages = [
226
+ {"role": "system", "content": system},
227
+ {
228
+ "role": "user",
229
+ "content": [
230
+ {"type": "text", "text": user},
231
+ {
232
+ "type": "image_url",
233
+ "image_url": {"url": f"data:{mime_type};base64,{encoded}"},
234
+ },
235
+ ],
236
+ },
237
+ ]
238
+ raw = self._post_chat(
239
+ {
240
+ "model": self.settings.llm_vision_model,
241
+ "messages": messages,
242
+ "temperature": 0.0,
243
+ "max_tokens": max_tokens,
244
+ }
245
+ )
246
+ return _extract_json_blob(raw)
finbot/models.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Domain records. Plain dataclasses -- the storage layer maps them to SQLite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ import uuid
8
+ from dataclasses import dataclass, field
9
+ from datetime import date, datetime, timezone
10
+ from typing import Optional
11
+
12
+ from .money import format_money
13
+
14
+ #: Where a record came from. Kept on every expense so a bad import can be
15
+ #: found and reversed as a unit.
16
+ SOURCES = ("text", "photo", "pdf", "csv", "xlsx", "manual")
17
+
18
+
19
+ def _now() -> datetime:
20
+ return datetime.now(timezone.utc)
21
+
22
+
23
+ def _slug(text: str) -> str:
24
+ """Loose normalisation for dedupe: case, punctuation and spacing-insensitive."""
25
+ return re.sub(r"[^a-z0-9]+", "", text.lower())
26
+
27
+
28
+ @dataclass
29
+ class Expense:
30
+ user_id: int
31
+ amount_minor: int
32
+ currency: str
33
+ description: str
34
+ category: str
35
+ occurred_on: date
36
+ id: str = field(default_factory=lambda: uuid.uuid4().hex)
37
+ merchant: str = ""
38
+ source: str = "text"
39
+ #: Groups rows that arrived together, so /undo can drop a whole import.
40
+ batch_id: str = ""
41
+ note: str = ""
42
+ #: 1.0 for deterministic parses, lower for model-extracted rows.
43
+ confidence: float = 1.0
44
+ created_at: datetime = field(default_factory=_now)
45
+
46
+ @property
47
+ def dedupe_key(self) -> str:
48
+ """Stable identity for 'the same transaction'.
49
+
50
+ Deliberately excludes id, source and confidence so that the same row
51
+ re-imported from a re-downloaded statement collapses onto the original.
52
+ """
53
+ basis = "|".join(
54
+ [
55
+ str(self.user_id),
56
+ str(self.amount_minor),
57
+ self.currency.upper(),
58
+ self.occurred_on.isoformat(),
59
+ _slug(self.merchant or self.description),
60
+ ]
61
+ )
62
+ return hashlib.sha256(basis.encode("utf-8")).hexdigest()[:32]
63
+
64
+ def display(self) -> str:
65
+ amount = format_money(self.amount_minor, self.currency)
66
+ label = self.merchant or self.description or self.category
67
+ return f"{amount} · {label} · {self.category} · {self.occurred_on.isoformat()}"
68
+
69
+
70
+ @dataclass
71
+ class Budget:
72
+ user_id: int
73
+ category: str
74
+ amount_minor: int
75
+ currency: str
76
+ period: str = "monthly"
77
+ created_at: datetime = field(default_factory=_now)
78
+
79
+
80
+ @dataclass
81
+ class ExpenseDraft:
82
+ """A candidate row awaiting confirmation.
83
+
84
+ Statement and receipt imports land here first. Nothing model-extracted is
85
+ written to the ledger without the user seeing the total and saying yes.
86
+ """
87
+
88
+ amount_minor: int
89
+ currency: str
90
+ description: str
91
+ occurred_on: date
92
+ category: str = "other"
93
+ merchant: str = ""
94
+ confidence: float = 0.5
95
+ raw: str = ""
96
+
97
+ def to_expense(
98
+ self, user_id: int, source: str, batch_id: str, note: str = ""
99
+ ) -> Expense:
100
+ return Expense(
101
+ user_id=user_id,
102
+ amount_minor=self.amount_minor,
103
+ currency=self.currency,
104
+ description=self.description,
105
+ category=self.category,
106
+ occurred_on=self.occurred_on,
107
+ merchant=self.merchant,
108
+ source=source,
109
+ batch_id=batch_id,
110
+ note=note,
111
+ confidence=self.confidence,
112
+ )
113
+
114
+
115
+ @dataclass
116
+ class PendingImport:
117
+ """Drafts held between 'here is your statement' and '/confirm'."""
118
+
119
+ user_id: int
120
+ drafts: list[ExpenseDraft]
121
+ source: str
122
+ batch_id: str = field(default_factory=lambda: uuid.uuid4().hex)
123
+ created_at: datetime = field(default_factory=_now)
124
+ #: Rows dropped because they already exist in the ledger.
125
+ duplicates: int = 0
126
+ #: Rows the extractor emitted but could not be made sense of.
127
+ skipped: int = 0
128
+ label: str = ""
129
+
130
+ def total_by_currency(self) -> dict[str, int]:
131
+ totals: dict[str, int] = {}
132
+ for d in self.drafts:
133
+ totals[d.currency] = totals.get(d.currency, 0) + d.amount_minor
134
+ return totals
135
+
136
+
137
+ @dataclass
138
+ class Summary:
139
+ """Aggregated spend for a period, ready to render."""
140
+
141
+ label: str
142
+ start: date
143
+ end: date
144
+ currency: str
145
+ total_minor: int
146
+ count: int
147
+ by_category: list[tuple[str, int]]
148
+ previous_total_minor: Optional[int] = None
149
+
150
+ @property
151
+ def delta_pct(self) -> Optional[float]:
152
+ if self.previous_total_minor is None or self.previous_total_minor == 0:
153
+ return None
154
+ return (
155
+ (self.total_minor - self.previous_total_minor)
156
+ / self.previous_total_minor
157
+ * 100.0
158
+ )
finbot/money.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-currency amounts as integer minor units.
2
+
3
+ Money is never a float here. 0.1 + 0.2 != 0.3 in binary floating point, and a
4
+ ledger that drifts by a paisa per transaction is worse than useless -- so every
5
+ amount is an int of the currency's smallest unit (paise, cents, fils) plus an
6
+ ISO 4217 code.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import NamedTuple, Optional
13
+
14
+ # Currencies whose minor unit is not 1/100. Anything absent is assumed to have
15
+ # two decimal places, which is true for the large majority of ISO 4217.
16
+ _EXPONENTS: dict[str, int] = {
17
+ "JPY": 0, "KRW": 0, "VND": 0, "CLP": 0, "ISK": 0, "UGX": 0,
18
+ "PYG": 0, "RWF": 0, "XAF": 0, "XOF": 0, "XPF": 0, "KMF": 0,
19
+ "DJF": 0, "GNF": 0, "BIF": 0, "VUV": 0,
20
+ "BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3,
21
+ }
22
+
23
+ # Symbols that map to exactly one currency. Deliberately excludes bare "$",
24
+ # which is ambiguous across USD/CAD/AUD/SGD/HKD and many more -- see
25
+ # _AMBIGUOUS_SYMBOLS.
26
+ _SYMBOLS: dict[str, str] = {
27
+ "₹": "INR", "€": "EUR", "£": "GBP", "¥": "JPY", "₽": "RUB",
28
+ "₩": "KRW", "₪": "ILS", "₫": "VND", "₺": "TRY", "₦": "NGN",
29
+ "₴": "UAH", "₸": "KZT", "฿": "THB", "₡": "CRC", "₱": "PHP",
30
+ "﷼": "SAR", "Rs": "INR", "R$": "BRL", "CHF": "CHF",
31
+ }
32
+
33
+ # Symbols that need the configured default to disambiguate.
34
+ _AMBIGUOUS_SYMBOLS = {"$"}
35
+
36
+ _ISO_RE = re.compile(r"\b([A-Z]{3})\b")
37
+
38
+ # A maximal run of digits and separators; _normalise_number then decides what
39
+ # each separator meant. Handles 1234 / 1,234.56 / 1.234,56 / 1,23,456.78
40
+ # (Indian lakh grouping) / 1\xa0234,56.
41
+ #
42
+ # A plain ASCII space is deliberately NOT a group separator: French-style
43
+ # "1 234" is indistinguishable from two separate numbers, and reading
44
+ # "250 300" as 250300 is a far worse failure than missing that one format.
45
+ _NUMBER_RE = re.compile(r"\d[\d,.\xa0\u202f]*\d|\d")
46
+
47
+
48
+ class ParsedAmount(NamedTuple):
49
+ minor: int
50
+ currency: Optional[str]
51
+ #: The exact substring that produced this amount, so callers can strip it
52
+ #: out of a description without re-guessing.
53
+ span: tuple[int, int]
54
+
55
+
56
+ def exponent(currency: str) -> int:
57
+ """Number of decimal places for an ISO 4217 code."""
58
+ return _EXPONENTS.get(currency.upper(), 2)
59
+
60
+
61
+ def to_minor(value: float | str, currency: str) -> int:
62
+ """Convert a major-unit value to integer minor units, half-up.
63
+
64
+ Uses string/Decimal arithmetic rather than float rounding so that values
65
+ like 8.345 land where a human would expect.
66
+ """
67
+ from decimal import Decimal, ROUND_HALF_UP
68
+
69
+ exp = exponent(currency)
70
+ quantum = Decimal(1).scaleb(-exp)
71
+ dec = Decimal(str(value)).quantize(quantum, rounding=ROUND_HALF_UP)
72
+ return int(dec.scaleb(exp))
73
+
74
+
75
+ def from_minor(minor: int, currency: str) -> float:
76
+ """Minor units back to a major-unit float. Display only -- never for maths."""
77
+ return minor / (10 ** exponent(currency))
78
+
79
+
80
+ def format_money(minor: int, currency: str) -> str:
81
+ """Human-readable amount, e.g. 123456 INR -> '₹1,234.56'."""
82
+ exp = exponent(currency)
83
+ sign = "-" if minor < 0 else ""
84
+ whole, frac = divmod(abs(minor), 10 ** exp)
85
+ symbol = _symbol_for(currency)
86
+ body = f"{whole:,}" if exp == 0 else f"{whole:,}.{frac:0{exp}d}"
87
+ if symbol:
88
+ return f"{sign}{symbol}{body}"
89
+ return f"{sign}{body} {currency.upper()}"
90
+
91
+
92
+ def _symbol_for(currency: str) -> str:
93
+ for sym, code in _SYMBOLS.items():
94
+ # Only single-glyph symbols read well as prefixes.
95
+ if code == currency.upper() and len(sym) == 1:
96
+ return sym
97
+ if currency.upper() == "USD":
98
+ return "$"
99
+ return ""
100
+
101
+
102
+ def _normalise_number(raw: str) -> Optional[str]:
103
+ """Turn a grouped numeric string into a plain decimal string.
104
+
105
+ The hard case is telling a decimal separator from a thousands separator.
106
+ Rules, in order:
107
+ 1. If both ',' and '.' appear, the rightmost one is the decimal point.
108
+ 2. If one appears once and is followed by exactly 2 digits, it is decimal.
109
+ 3. Otherwise it is grouping and gets stripped.
110
+ """
111
+ text = re.sub(r"[\s\xa0\u202f]", "", raw)
112
+ if not text:
113
+ return None
114
+
115
+ has_comma, has_dot = "," in text, "." in text
116
+
117
+ if has_comma and has_dot:
118
+ dec_sep = "," if text.rfind(",") > text.rfind(".") else "."
119
+ grp_sep = "." if dec_sep == "," else ","
120
+ text = text.replace(grp_sep, "").replace(dec_sep, ".")
121
+ elif has_comma or has_dot:
122
+ sep = "," if has_comma else "."
123
+ head, _, tail = text.rpartition(sep)
124
+ if text.count(sep) == 1 and len(tail) in (1, 2):
125
+ text = f"{head}.{tail}"
126
+ else:
127
+ text = text.replace(sep, "")
128
+
129
+ if not re.fullmatch(r"\d+(?:\.\d+)?", text):
130
+ return None
131
+ return text
132
+
133
+
134
+ def detect_currency(text: str, default: str) -> str:
135
+ """Best-effort currency for a free-text fragment."""
136
+ upper = text.upper()
137
+ for sym, code in _SYMBOLS.items():
138
+ symbol = sym.upper()
139
+ if symbol.isalpha():
140
+ # Letter symbols ("Rs", "CHF") need boundaries, or "cars" reads as
141
+ # rupees and "chfoo" as francs.
142
+ if re.search(rf"(?<![A-Z]){re.escape(symbol)}(?![A-Z])", upper):
143
+ return code
144
+ elif symbol in upper:
145
+ return code
146
+ for match in _ISO_RE.finditer(upper):
147
+ code = match.group(1)
148
+ if code in _EXPONENTS or code in set(_SYMBOLS.values()) or code in _COMMON_ISO:
149
+ return code
150
+ if any(sym in text for sym in _AMBIGUOUS_SYMBOLS):
151
+ return default.upper()
152
+ return default.upper()
153
+
154
+
155
+ _COMMON_ISO = {
156
+ "USD", "EUR", "GBP", "INR", "JPY", "CNY", "AUD", "CAD", "SGD", "CHF",
157
+ "HKD", "NZD", "SEK", "NOK", "DKK", "ZAR", "AED", "SAR", "MYR", "THB",
158
+ "IDR", "PHP", "VND", "KRW", "BRL", "MXN", "RUB", "TRY", "PLN", "CZK",
159
+ "ILS", "NGN", "KES", "EGP", "PKR", "BDT", "LKR", "NPR",
160
+ }
161
+
162
+
163
+ def parse_amount(text: str, default_currency: str = "INR") -> Optional[ParsedAmount]:
164
+ """Pull the most plausible monetary amount out of free text.
165
+
166
+ Returns None when nothing numeric is present. Currency is resolved from an
167
+ explicit symbol or ISO code when one is given, else the supplied default.
168
+ """
169
+ best: Optional[ParsedAmount] = None
170
+ for match in _NUMBER_RE.finditer(text):
171
+ normalised = _normalise_number(match.group(0))
172
+ if normalised is None:
173
+ continue
174
+ currency = detect_currency(text, default_currency)
175
+ minor = to_minor(normalised, currency)
176
+ candidate = ParsedAmount(minor=minor, currency=currency, span=match.span())
177
+ # Prefer the largest magnitude: in "2 coffees 250" the spend is 250.
178
+ if best is None or candidate.minor > best.minor:
179
+ best = candidate
180
+ return best
finbot/parser.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic parsing of free-text expense entry.
2
+
3
+ This is the fast path: "coffee 250", "₹1,200 groceries at bigbasket",
4
+ "spent 45.50 on lunch yesterday". It runs before any model call, costs
5
+ nothing, and is reproducible. Anything it cannot make sense of is handed to
6
+ the LLM by :mod:`finbot.ingest`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from datetime import date, timedelta
13
+ from typing import Optional
14
+
15
+ from .categories import categorise, coerce
16
+ from .models import ExpenseDraft
17
+ from .money import parse_amount
18
+
19
+ _WEEKDAYS = {
20
+ "monday": 0, "mon": 0,
21
+ "tuesday": 1, "tue": 1, "tues": 1,
22
+ "wednesday": 2, "wed": 2,
23
+ "thursday": 3, "thu": 3, "thurs": 3,
24
+ "friday": 4, "fri": 4,
25
+ "saturday": 5, "sat": 5,
26
+ "sunday": 6, "sun": 6,
27
+ }
28
+
29
+ _MONTHS = {
30
+ "jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3,
31
+ "apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7,
32
+ "aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9,
33
+ "oct": 10, "october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12,
34
+ }
35
+
36
+ # Words that carry no meaning in a description once the amount is removed.
37
+ _FILLER = {
38
+ "spent", "spend", "paid", "pay", "bought", "buy", "for", "on", "at", "in",
39
+ "of", "the", "a", "an", "to", "rs", "rs.", "inr", "cost", "costs", "was",
40
+ "were", "i", "my", "me", "some", "and", "with", "got",
41
+ }
42
+
43
+ _ISO_DATE_RE = re.compile(r"\b(\d{4})-(\d{1,2})-(\d{1,2})\b")
44
+ _DMY_RE = re.compile(r"\b(\d{1,2})[/-](\d{1,2})(?:[/-](\d{2,4}))?\b")
45
+ _DAY_MONTH_RE = re.compile(
46
+ r"\b(\d{1,2})(?:st|nd|rd|th)?\s+(" + "|".join(_MONTHS) + r")\b", re.I
47
+ )
48
+ _MONTH_DAY_RE = re.compile(
49
+ r"\b(" + "|".join(_MONTHS) + r")\s+(\d{1,2})(?:st|nd|rd|th)?\b", re.I
50
+ )
51
+ _DAYS_AGO_RE = re.compile(r"\b(\d{1,3})\s+days?\s+ago\b", re.I)
52
+ _LAST_WEEKDAY_RE = re.compile(
53
+ r"\b(?:last\s+)?(" + "|".join(_WEEKDAYS) + r")\b", re.I
54
+ )
55
+ _TAG_RE = re.compile(r"#([a-z_][a-z0-9_]*)", re.I)
56
+
57
+
58
+ def _clamp_year(year: int) -> int:
59
+ if year < 100:
60
+ return 2000 + year
61
+ return year
62
+
63
+
64
+ def parse_date_hint(
65
+ text: str, today: Optional[date] = None
66
+ ) -> tuple[date, Optional[tuple[int, int]]]:
67
+ """Resolve a date reference in ``text``.
68
+
69
+ Returns (resolved_date, span_of_the_phrase). Span is None when no hint was
70
+ found, in which case the date defaults to today. Dates that would land in
71
+ the future are pulled back a year -- "12 dec" typed in August means last
72
+ December, not a spend that has not happened yet.
73
+ """
74
+ today = today or date.today()
75
+ lowered = text.lower()
76
+
77
+ if m := re.search(r"\bday before yesterday\b", lowered):
78
+ return today - timedelta(days=2), m.span()
79
+ if m := re.search(r"\byesterday\b", lowered):
80
+ return today - timedelta(days=1), m.span()
81
+ if m := re.search(r"\btoday\b", lowered):
82
+ return today, m.span()
83
+
84
+ if m := _DAYS_AGO_RE.search(lowered):
85
+ return today - timedelta(days=int(m.group(1))), m.span()
86
+
87
+ if m := _ISO_DATE_RE.search(lowered):
88
+ try:
89
+ return date(int(m.group(1)), int(m.group(2)), int(m.group(3))), m.span()
90
+ except ValueError:
91
+ pass
92
+
93
+ if m := _DAY_MONTH_RE.search(lowered):
94
+ day, month = int(m.group(1)), _MONTHS[m.group(2).lower()]
95
+ try:
96
+ candidate = date(today.year, month, day)
97
+ if candidate > today:
98
+ candidate = date(today.year - 1, month, day)
99
+ return candidate, m.span()
100
+ except ValueError:
101
+ pass
102
+
103
+ if m := _MONTH_DAY_RE.search(lowered):
104
+ month, day = _MONTHS[m.group(1).lower()], int(m.group(2))
105
+ try:
106
+ candidate = date(today.year, month, day)
107
+ if candidate > today:
108
+ candidate = date(today.year - 1, month, day)
109
+ return candidate, m.span()
110
+ except ValueError:
111
+ pass
112
+
113
+ if m := _DMY_RE.search(lowered):
114
+ first, second, year_s = int(m.group(1)), int(m.group(2)), m.group(3)
115
+ year = _clamp_year(int(year_s)) if year_s else today.year
116
+ # Ambiguous d/m vs m/d. Prefer day-first, which covers most of the
117
+ # world, and fall back to month-first when day-first is impossible.
118
+ for day, month in ((first, second), (second, first)):
119
+ try:
120
+ candidate = date(year, month, day)
121
+ except ValueError:
122
+ continue
123
+ if not year_s and candidate > today:
124
+ candidate = date(year - 1, month, day)
125
+ return candidate, m.span()
126
+
127
+ if m := _LAST_WEEKDAY_RE.search(lowered):
128
+ target = _WEEKDAYS[m.group(1).lower()]
129
+ delta = (today.weekday() - target) % 7
130
+ delta = delta or 7 # bare weekday name means the most recent past one
131
+ return today - timedelta(days=delta), m.span()
132
+
133
+ return today, None
134
+
135
+
136
+ def _clean_description(text: str, cuts: list[tuple[int, int]]) -> str:
137
+ """Remove consumed spans, currency noise and filler words."""
138
+ chars = list(text)
139
+ for start, end in cuts:
140
+ for i in range(start, min(end, len(chars))):
141
+ chars[i] = " "
142
+ remaining = "".join(chars)
143
+
144
+ remaining = re.sub(r"[₹$€£¥₽₩₪₫₺₦₴₸฿₡₱﷼]", " ", remaining)
145
+ remaining = re.sub(r"\b[A-Z]{3}\b", " ", remaining)
146
+ remaining = _TAG_RE.sub(" ", remaining)
147
+ remaining = re.sub(r"[^\w\s&'-]", " ", remaining)
148
+
149
+ words = [w for w in remaining.split() if w.lower() not in _FILLER]
150
+ return re.sub(r"\s+", " ", " ".join(words)).strip()
151
+
152
+
153
+ def parse_expense(
154
+ text: str, default_currency: str = "INR", today: Optional[date] = None
155
+ ) -> Optional[ExpenseDraft]:
156
+ """Parse one free-text line into a draft, or None if no amount is present."""
157
+ if not text or not text.strip():
158
+ return None
159
+
160
+ amount = parse_amount(text, default_currency)
161
+ if amount is None:
162
+ return None
163
+
164
+ occurred_on, date_span = parse_date_hint(text, today)
165
+
166
+ cuts = [amount.span]
167
+ if date_span:
168
+ cuts.append(date_span)
169
+
170
+ description = _clean_description(text, cuts)
171
+
172
+ # An explicit #tag overrides keyword matching.
173
+ tag_match = _TAG_RE.search(text)
174
+ if tag_match:
175
+ category = coerce(tag_match.group(1))
176
+ confidence = 1.0
177
+ else:
178
+ category, confidence = categorise(description)
179
+
180
+ return ExpenseDraft(
181
+ amount_minor=amount.minor,
182
+ currency=amount.currency or default_currency.upper(),
183
+ description=description or "unlabelled",
184
+ occurred_on=occurred_on,
185
+ category=category,
186
+ confidence=1.0 if confidence > 0 else 0.6,
187
+ raw=text.strip(),
188
+ )
finbot/registry.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Feature registration and update routing.
2
+
3
+ Dispatch order for one incoming update:
4
+
5
+ 1. If it carries a file -> ``handle_attachment`` on each feature by priority.
6
+ 2. If it starts with a slash -> the feature that owns that command.
7
+ 3. Otherwise -> ``handle_message`` on each feature by priority.
8
+
9
+ First non-``None`` reply wins. A feature that returns ``None`` is declining,
10
+ which lets several features coexist over the same input without knowing about
11
+ each other.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import Iterable, Optional
18
+
19
+ from .features.base import BotContext, CommandSpec, Feature, Reply
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+
24
+ class DuplicateCommand(ValueError):
25
+ pass
26
+
27
+
28
+ class FeatureRegistry:
29
+ def __init__(self) -> None:
30
+ self._features: list[Feature] = []
31
+ self._commands: dict[str, tuple[Feature, CommandSpec]] = {}
32
+
33
+ def register(self, feature: Feature) -> Feature:
34
+ for spec in feature.commands():
35
+ for name in (spec.name, *spec.aliases):
36
+ if name in self._commands:
37
+ owner = self._commands[name][0].name
38
+ raise DuplicateCommand(
39
+ f"/{name} is already provided by feature '{owner}'"
40
+ )
41
+ self._commands[name] = (feature, spec)
42
+ self._features.append(feature)
43
+ self._features.sort(key=lambda f: f.priority)
44
+ return feature
45
+
46
+ def register_all(self, features: Iterable[Feature]) -> None:
47
+ for feature in features:
48
+ self.register(feature)
49
+
50
+ @property
51
+ def features(self) -> list[Feature]:
52
+ return list(self._features)
53
+
54
+ def known_command(self, name: str) -> bool:
55
+ return name in self._commands
56
+
57
+ def dispatch(self, ctx: BotContext) -> Optional[Reply]:
58
+ if ctx.attachment is not None:
59
+ for feature in self._features:
60
+ try:
61
+ if reply := feature.handle_attachment(ctx):
62
+ return reply
63
+ except Exception:
64
+ log.exception("Feature %s failed on attachment", feature.name)
65
+ return Reply(
66
+ "Something went wrong reading that file. "
67
+ "The error is in the Space logs."
68
+ )
69
+ return None
70
+
71
+ if ctx.command:
72
+ entry = self._commands.get(ctx.command)
73
+ if entry is None:
74
+ return Reply(
75
+ f"I don't know /{_escape(ctx.command)}. Try /help for what I can do."
76
+ )
77
+ feature, _spec = entry
78
+ try:
79
+ return feature.handle_command(ctx)
80
+ except Exception:
81
+ log.exception("Feature %s failed on /%s", feature.name, ctx.command)
82
+ return Reply(
83
+ "Something went wrong running that command. "
84
+ "The error is in the Space logs."
85
+ )
86
+
87
+ for feature in self._features:
88
+ try:
89
+ if reply := feature.handle_message(ctx):
90
+ return reply
91
+ except Exception:
92
+ log.exception("Feature %s failed on message", feature.name)
93
+ return Reply(
94
+ "Something went wrong handling that. The error is in the Space logs."
95
+ )
96
+ return None
97
+
98
+ def help_text(self) -> str:
99
+ """Grouped /help built from whatever is registered."""
100
+ blocks: list[str] = []
101
+ for feature in self._features:
102
+ visible = [s for s in feature.commands() if not s.hidden]
103
+ if not visible:
104
+ continue
105
+ lines = [f"<b>{_escape(feature.description or feature.name)}</b>"]
106
+ for spec in visible:
107
+ lines.append(f"<code>{_escape(spec.usage)}</code> — {_escape(spec.help)}")
108
+ blocks.append("\n".join(lines))
109
+ return "\n\n".join(blocks)
110
+
111
+ def telegram_commands(self) -> list[dict[str, str]]:
112
+ """Payload for Telegram's setMyCommands."""
113
+ seen: set[str] = set()
114
+ out: list[dict[str, str]] = []
115
+ for feature in self._features:
116
+ for spec in feature.commands():
117
+ if spec.hidden or spec.name in seen:
118
+ continue
119
+ seen.add(spec.name)
120
+ # Telegram caps descriptions at 256 chars.
121
+ out.append({"command": spec.name, "description": spec.help[:256]})
122
+ return out
123
+
124
+
125
+ def _escape(text: str) -> str:
126
+ from .render import escape
127
+
128
+ return escape(text)
finbot/render.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Telegram message rendering.
2
+
3
+ Uses Telegram's **HTML** parse mode rather than MarkdownV2. MarkdownV2 requires
4
+ escaping ``. - ( ) ! +`` and more, which appear in virtually every line of
5
+ financial output (``₹1,234.56``, ``-12%``, ``(30%)``); one missed escape and
6
+ Telegram rejects the whole message. HTML needs only ``< > &`` escaped, so the
7
+ failure surface is far smaller.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import html
13
+ from datetime import date
14
+ from typing import Iterable
15
+
16
+ from .analytics import BudgetStatus, Recurring
17
+ from .categories import label as category_label
18
+ from .models import Expense, PendingImport, Summary
19
+ from .money import format_money
20
+
21
+ BAR_WIDTH = 10
22
+
23
+
24
+ def escape(text: str) -> str:
25
+ return html.escape(str(text or ""), quote=False)
26
+
27
+
28
+ def bar(fraction: float, width: int = BAR_WIDTH) -> str:
29
+ filled = max(0, min(width, round(fraction * width)))
30
+ return "█" * filled + "░" * (width - filled)
31
+
32
+
33
+ def money(minor: int, currency: str) -> str:
34
+ return escape(format_money(minor, currency))
35
+
36
+
37
+ def expense_line(expense: Expense) -> str:
38
+ label = expense.merchant or expense.description or "unlabelled"
39
+ return (
40
+ f"{money(expense.amount_minor, expense.currency)} · {escape(label)} "
41
+ f"<i>{escape(category_label(expense.category))}</i> · "
42
+ f"{expense.occurred_on.isoformat()}"
43
+ )
44
+
45
+
46
+ def logged(expense: Expense, note: str = "") -> str:
47
+ parts = [
48
+ f"✅ Logged {money(expense.amount_minor, expense.currency)} — "
49
+ f"{escape(expense.description or expense.merchant or 'unlabelled')}",
50
+ f"<i>{escape(category_label(expense.category))}</i> · {expense.occurred_on.isoformat()}",
51
+ ]
52
+ if note:
53
+ parts.append(escape(note))
54
+ parts.append("<i>/undo to remove</i>")
55
+ return "\n".join(parts)
56
+
57
+
58
+ def summary(summ: Summary) -> str:
59
+ if summ.count == 0:
60
+ return (
61
+ f"<b>{escape(summ.label)}</b>\nNothing logged "
62
+ f"({summ.start.isoformat()} → {summ.end.isoformat()})."
63
+ )
64
+
65
+ lines = [
66
+ f"<b>{escape(summ.label)}</b> · {money(summ.total_minor, summ.currency)} "
67
+ f"<i>({summ.count} txns)</i>"
68
+ ]
69
+
70
+ if (delta := summ.delta_pct) is not None:
71
+ arrow = "▲" if delta > 0 else "▼"
72
+ lines.append(
73
+ f"{arrow} {abs(delta):.0f}% vs previous "
74
+ f"({money(summ.previous_total_minor or 0, summ.currency)})"
75
+ )
76
+
77
+ lines.append("")
78
+ top = summ.by_category[:8]
79
+ for name, amount in top:
80
+ fraction = amount / summ.total_minor if summ.total_minor else 0
81
+ lines.append(
82
+ f"<code>{bar(fraction)}</code> {escape(category_label(name))} "
83
+ f"{money(amount, summ.currency)} <i>({fraction * 100:.0f}%)</i>"
84
+ )
85
+
86
+ if len(summ.by_category) > len(top):
87
+ rest = sum(amount for _, amount in summ.by_category[len(top):])
88
+ lines.append(f"<i>+{len(summ.by_category) - len(top)} more · {money(rest, summ.currency)}</i>")
89
+
90
+ return "\n".join(lines)
91
+
92
+
93
+ def expense_list(expenses: Iterable[Expense], title: str, limit: int = 20) -> str:
94
+ items = list(expenses)[:limit]
95
+ if not items:
96
+ return f"<b>{escape(title)}</b>\nNothing to show."
97
+ lines = [f"<b>{escape(title)}</b>"]
98
+ lines.extend(f"• {expense_line(e)}" for e in items)
99
+ return "\n".join(lines)
100
+
101
+
102
+ def pending_import(pending: PendingImport) -> str:
103
+ totals = pending.total_by_currency()
104
+ total_str = " + ".join(money(v, k) for k, v in sorted(totals.items()))
105
+
106
+ lines = [
107
+ f"<b>Found {len(pending.drafts)} transactions</b> in that "
108
+ f"{escape(pending.source.upper())} · {total_str}",
109
+ ]
110
+
111
+ detail = []
112
+ if pending.duplicates:
113
+ detail.append(f"{pending.duplicates} already logged (skipped)")
114
+ if pending.skipped:
115
+ detail.append(f"{pending.skipped} unreadable")
116
+ if pending.label:
117
+ detail.append(pending.label)
118
+ if detail:
119
+ lines.append(f"<i>{escape(' · '.join(detail))}</i>")
120
+
121
+ lines.append("")
122
+ preview = pending.drafts[:8]
123
+ for draft in preview:
124
+ lines.append(
125
+ f"• {money(draft.amount_minor, draft.currency)} · "
126
+ f"{escape(draft.description[:40])} · "
127
+ f"<i>{escape(category_label(draft.category))}</i> · "
128
+ f"{draft.occurred_on.isoformat()}"
129
+ )
130
+ if len(pending.drafts) > len(preview):
131
+ lines.append(f"<i>…and {len(pending.drafts) - len(preview)} more</i>")
132
+
133
+ lines.append("")
134
+ lines.append("These were read by a model and <b>not saved yet</b>.")
135
+ lines.append("/confirm to save · /cancel to discard")
136
+ return "\n".join(lines)
137
+
138
+
139
+ def recurring_list(items: list[Recurring], currency: str) -> str:
140
+ if not items:
141
+ return (
142
+ "<b>Recurring charges</b>\nNone detected yet — this needs a few "
143
+ "months of history to spot a pattern."
144
+ )
145
+
146
+ lines = ["<b>Recurring charges</b>"]
147
+ for item in items[:15]:
148
+ lines.append(
149
+ f"• {money(item.typical_minor, item.currency)}/mo · {escape(item.label)} "
150
+ f"<i>({item.months} months, last {item.last_seen.isoformat()})</i>"
151
+ )
152
+
153
+ annual = sum(r.annualised_minor for r in items if r.currency == currency)
154
+ if annual:
155
+ lines.append("")
156
+ lines.append(f"<b>≈ {money(annual, currency)} a year</b> if all continue.")
157
+ return "\n".join(lines)
158
+
159
+
160
+ def budget_list(statuses: list[BudgetStatus]) -> str:
161
+ if not statuses:
162
+ return (
163
+ "<b>Budgets</b>\nNone set.\n"
164
+ "<code>/budget food_dining 8000</code> to set one."
165
+ )
166
+
167
+ lines = ["<b>Budgets this month</b>"]
168
+ for status in statuses:
169
+ mark = "⚠️" if status.over else ("🟡" if status.pct >= 80 else "🟢")
170
+ lines.append(
171
+ f"{mark} <code>{bar(status.pct / 100)}</code> "
172
+ f"{escape(category_label(status.category))} "
173
+ f"{money(status.spent_minor, status.currency)}/"
174
+ f"{money(status.limit_minor, status.currency)} "
175
+ f"<i>({status.pct:.0f}%)</i>"
176
+ )
177
+ return "\n".join(lines)
178
+
179
+
180
+ def csv_export(expenses: list[Expense]) -> str:
181
+ import csv
182
+ import io
183
+
184
+ buffer = io.StringIO()
185
+ writer = csv.writer(buffer)
186
+ writer.writerow(
187
+ ["date", "amount", "currency", "category", "merchant", "description", "source"]
188
+ )
189
+ for expense in expenses:
190
+ from .money import from_minor
191
+
192
+ writer.writerow(
193
+ [
194
+ expense.occurred_on.isoformat(),
195
+ f"{from_minor(expense.amount_minor, expense.currency):.2f}",
196
+ expense.currency,
197
+ expense.category,
198
+ expense.merchant,
199
+ expense.description,
200
+ expense.source,
201
+ ]
202
+ )
203
+ return buffer.getvalue()
204
+
205
+
206
+ def period_footer(start: date, end: date) -> str:
207
+ return f"<i>{start.isoformat()} → {end.isoformat()}</i>"
finbot/store.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Durable storage: SQLite locally, mirrored to a private Hub dataset.
2
+
3
+ Hugging Face Space disk is ephemeral -- it is wiped on every restart, sleep and
4
+ rebuild. A finance bot that stored its ledger only on that disk would silently
5
+ lose everything the first time the Space went to sleep, which is the worst
6
+ possible failure for this application because it is invisible until you go
7
+ looking for old data.
8
+
9
+ So: SQLite is the working copy, and a ``CommitScheduler`` mirrors the database
10
+ file into a **private** dataset repo on a timer. On boot the newest copy is
11
+ pulled back down. Set ``HF_DATASET_REPO`` and this is handled; leave it unset
12
+ and the bot runs local-only, which is fine for development and lossy on a
13
+ Space -- ``/status`` says so out loud.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import shutil
20
+ import sqlite3
21
+ import threading
22
+ from datetime import date, datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Iterable, Optional, Sequence
25
+
26
+ from .config import Settings
27
+ from .models import Budget, Expense
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+ DB_FILENAME = "finbot.db"
32
+ SCHEMA_VERSION = 1
33
+
34
+ _SCHEMA = """
35
+ CREATE TABLE IF NOT EXISTS expenses (
36
+ id TEXT PRIMARY KEY,
37
+ user_id INTEGER NOT NULL,
38
+ amount_minor INTEGER NOT NULL,
39
+ currency TEXT NOT NULL,
40
+ description TEXT NOT NULL DEFAULT '',
41
+ category TEXT NOT NULL DEFAULT 'other',
42
+ merchant TEXT NOT NULL DEFAULT '',
43
+ occurred_on TEXT NOT NULL,
44
+ source TEXT NOT NULL DEFAULT 'text',
45
+ batch_id TEXT NOT NULL DEFAULT '',
46
+ note TEXT NOT NULL DEFAULT '',
47
+ confidence REAL NOT NULL DEFAULT 1.0,
48
+ dedupe_key TEXT NOT NULL DEFAULT '',
49
+ created_at TEXT NOT NULL
50
+ );
51
+
52
+ CREATE INDEX IF NOT EXISTS idx_expenses_user_date
53
+ ON expenses(user_id, occurred_on);
54
+ CREATE INDEX IF NOT EXISTS idx_expenses_batch
55
+ ON expenses(batch_id);
56
+ -- Non-unique on purpose: two identical coffees on one day are both real. Import
57
+ -- dedupe is done explicitly against this index, so re-importing a statement
58
+ -- collapses while manual entry stays free to repeat.
59
+ CREATE INDEX IF NOT EXISTS idx_expenses_dedupe
60
+ ON expenses(user_id, dedupe_key);
61
+
62
+ CREATE TABLE IF NOT EXISTS budgets (
63
+ user_id INTEGER NOT NULL,
64
+ category TEXT NOT NULL,
65
+ amount_minor INTEGER NOT NULL,
66
+ currency TEXT NOT NULL,
67
+ period TEXT NOT NULL DEFAULT 'monthly',
68
+ created_at TEXT NOT NULL,
69
+ PRIMARY KEY (user_id, category, period)
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS meta (
73
+ key TEXT PRIMARY KEY,
74
+ value TEXT NOT NULL
75
+ );
76
+ """
77
+
78
+
79
+ def _row_to_expense(row: sqlite3.Row) -> Expense:
80
+ return Expense(
81
+ id=row["id"],
82
+ user_id=row["user_id"],
83
+ amount_minor=row["amount_minor"],
84
+ currency=row["currency"],
85
+ description=row["description"],
86
+ category=row["category"],
87
+ merchant=row["merchant"],
88
+ occurred_on=date.fromisoformat(row["occurred_on"]),
89
+ source=row["source"],
90
+ batch_id=row["batch_id"],
91
+ note=row["note"],
92
+ confidence=row["confidence"],
93
+ created_at=datetime.fromisoformat(row["created_at"]),
94
+ )
95
+
96
+
97
+ class Store:
98
+ """SQLite-backed ledger with optional Hub mirroring."""
99
+
100
+ def __init__(self, settings: Settings):
101
+ self.settings = settings
102
+ self.data_dir = Path(settings.data_dir)
103
+ self.db_path = self.data_dir / DB_FILENAME
104
+ self._lock = threading.RLock()
105
+ self._scheduler = None
106
+ self.sync_status = "disabled"
107
+
108
+ self.data_dir.mkdir(parents=True, exist_ok=True)
109
+ if settings.sync_enabled:
110
+ self._restore_from_hub()
111
+
112
+ self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
113
+ self._conn.row_factory = sqlite3.Row
114
+ self._conn.execute("PRAGMA foreign_keys = ON")
115
+ self._migrate()
116
+
117
+ if settings.sync_enabled:
118
+ self._start_sync()
119
+
120
+ # ---------------------------------------------------------------- schema
121
+
122
+ def _migrate(self) -> None:
123
+ with self._lock:
124
+ self._conn.executescript(_SCHEMA)
125
+ self._conn.execute(
126
+ "INSERT INTO meta(key, value) VALUES('schema_version', ?) "
127
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
128
+ (str(SCHEMA_VERSION),),
129
+ )
130
+ self._conn.commit()
131
+
132
+ # ------------------------------------------------------------------ sync
133
+
134
+ def _restore_from_hub(self) -> None:
135
+ """Pull the last mirrored database down before opening it."""
136
+ from huggingface_hub import hf_hub_download
137
+ from huggingface_hub.utils import (
138
+ EntryNotFoundError,
139
+ RepositoryNotFoundError,
140
+ )
141
+
142
+ try:
143
+ cached = hf_hub_download(
144
+ repo_id=self.settings.hf_dataset_repo,
145
+ filename=DB_FILENAME,
146
+ repo_type="dataset",
147
+ token=self.settings.hf_token,
148
+ )
149
+ except (EntryNotFoundError, RepositoryNotFoundError):
150
+ log.info("No existing ledger in the dataset repo; starting fresh.")
151
+ return
152
+ except Exception as exc: # network, auth, anything transient
153
+ # Do NOT clobber a local db we may already have.
154
+ log.warning("Could not restore ledger from Hub: %s", exc)
155
+ self.sync_status = f"restore failed: {exc}"
156
+ return
157
+
158
+ shutil.copyfile(cached, self.db_path)
159
+ log.info("Restored ledger from %s", self.settings.hf_dataset_repo)
160
+
161
+ def _start_sync(self) -> None:
162
+ from huggingface_hub import CommitScheduler, create_repo
163
+
164
+ lock = self._lock
165
+
166
+ class _LockedScheduler(CommitScheduler):
167
+ """Snapshot the DB only while no write is in flight.
168
+
169
+ Without this the uploader can read the file mid-transaction and
170
+ mirror a torn database.
171
+ """
172
+
173
+ def push_to_hub(self, *args, **kwargs):
174
+ with lock:
175
+ return super().push_to_hub(*args, **kwargs)
176
+
177
+ try:
178
+ create_repo(
179
+ repo_id=self.settings.hf_dataset_repo,
180
+ repo_type="dataset",
181
+ private=True,
182
+ exist_ok=True,
183
+ token=self.settings.hf_token,
184
+ )
185
+ self._scheduler = _LockedScheduler(
186
+ repo_id=self.settings.hf_dataset_repo,
187
+ repo_type="dataset",
188
+ folder_path=self.data_dir,
189
+ path_in_repo="",
190
+ every=max(1, self.settings.sync_every_minutes),
191
+ allow_patterns=[DB_FILENAME],
192
+ private=True,
193
+ token=self.settings.hf_token,
194
+ squash_history=True,
195
+ )
196
+ self.sync_status = (
197
+ f"mirroring to {self.settings.hf_dataset_repo} "
198
+ f"every {self.settings.sync_every_minutes}m"
199
+ )
200
+ log.info("Ledger sync active: %s", self.sync_status)
201
+ except Exception as exc:
202
+ self.sync_status = f"failed: {exc}"
203
+ log.error("Could not start ledger sync: %s", exc)
204
+
205
+ def flush(self) -> None:
206
+ """Force an immediate mirror. Called on shutdown and after imports."""
207
+ if self._scheduler is None:
208
+ return
209
+ try:
210
+ self._scheduler.push_to_hub()
211
+ except Exception as exc:
212
+ log.error("Ledger flush failed: %s", exc)
213
+
214
+ def close(self) -> None:
215
+ if self._scheduler is not None:
216
+ try:
217
+ self._scheduler.stop()
218
+ except Exception:
219
+ pass
220
+ self.flush()
221
+ with self._lock:
222
+ self._conn.close()
223
+
224
+ # -------------------------------------------------------------- expenses
225
+
226
+ def add_expense(self, expense: Expense) -> Expense:
227
+ with self._lock:
228
+ self._conn.execute(
229
+ """INSERT INTO expenses
230
+ (id, user_id, amount_minor, currency, description, category,
231
+ merchant, occurred_on, source, batch_id, note, confidence,
232
+ dedupe_key, created_at)
233
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
234
+ (
235
+ expense.id,
236
+ expense.user_id,
237
+ expense.amount_minor,
238
+ expense.currency.upper(),
239
+ expense.description,
240
+ expense.category,
241
+ expense.merchant,
242
+ expense.occurred_on.isoformat(),
243
+ expense.source,
244
+ expense.batch_id,
245
+ expense.note,
246
+ expense.confidence,
247
+ expense.dedupe_key,
248
+ expense.created_at.isoformat(),
249
+ ),
250
+ )
251
+ self._conn.commit()
252
+ return expense
253
+
254
+ def add_expenses(self, expenses: Sequence[Expense]) -> int:
255
+ for expense in expenses:
256
+ self.add_expense(expense)
257
+ return len(expenses)
258
+
259
+ def existing_dedupe_keys(self, user_id: int, keys: Iterable[str]) -> set[str]:
260
+ keys = [k for k in keys if k]
261
+ if not keys:
262
+ return set()
263
+ found: set[str] = set()
264
+ with self._lock:
265
+ # Chunked to stay under SQLite's variable limit on big imports.
266
+ for i in range(0, len(keys), 500):
267
+ chunk = keys[i : i + 500]
268
+ placeholders = ",".join("?" * len(chunk))
269
+ rows = self._conn.execute(
270
+ f"SELECT dedupe_key FROM expenses "
271
+ f"WHERE user_id = ? AND dedupe_key IN ({placeholders})",
272
+ (user_id, *chunk),
273
+ ).fetchall()
274
+ found.update(r["dedupe_key"] for r in rows)
275
+ return found
276
+
277
+ def list_expenses(
278
+ self,
279
+ user_id: int,
280
+ start: Optional[date] = None,
281
+ end: Optional[date] = None,
282
+ category: Optional[str] = None,
283
+ limit: Optional[int] = None,
284
+ ) -> list[Expense]:
285
+ clauses = ["user_id = ?"]
286
+ params: list[object] = [user_id]
287
+ if start:
288
+ clauses.append("occurred_on >= ?")
289
+ params.append(start.isoformat())
290
+ if end:
291
+ clauses.append("occurred_on <= ?")
292
+ params.append(end.isoformat())
293
+ if category:
294
+ clauses.append("category = ?")
295
+ params.append(category)
296
+
297
+ sql = (
298
+ f"SELECT * FROM expenses WHERE {' AND '.join(clauses)} "
299
+ f"ORDER BY occurred_on DESC, created_at DESC"
300
+ )
301
+ if limit:
302
+ sql += f" LIMIT {int(limit)}"
303
+
304
+ with self._lock:
305
+ rows = self._conn.execute(sql, params).fetchall()
306
+ return [_row_to_expense(r) for r in rows]
307
+
308
+ def last_expense(self, user_id: int) -> Optional[Expense]:
309
+ with self._lock:
310
+ row = self._conn.execute(
311
+ "SELECT * FROM expenses WHERE user_id = ? "
312
+ "ORDER BY created_at DESC LIMIT 1",
313
+ (user_id,),
314
+ ).fetchone()
315
+ return _row_to_expense(row) if row else None
316
+
317
+ def delete_expense(self, user_id: int, expense_id: str) -> bool:
318
+ with self._lock:
319
+ cur = self._conn.execute(
320
+ "DELETE FROM expenses WHERE user_id = ? AND id = ?",
321
+ (user_id, expense_id),
322
+ )
323
+ self._conn.commit()
324
+ return cur.rowcount > 0
325
+
326
+ def delete_batch(self, user_id: int, batch_id: str) -> int:
327
+ if not batch_id:
328
+ return 0
329
+ with self._lock:
330
+ cur = self._conn.execute(
331
+ "DELETE FROM expenses WHERE user_id = ? AND batch_id = ?",
332
+ (user_id, batch_id),
333
+ )
334
+ self._conn.commit()
335
+ return cur.rowcount
336
+
337
+ def currencies_used(self, user_id: int) -> list[str]:
338
+ with self._lock:
339
+ rows = self._conn.execute(
340
+ "SELECT currency, COUNT(*) AS n FROM expenses WHERE user_id = ? "
341
+ "GROUP BY currency ORDER BY n DESC",
342
+ (user_id,),
343
+ ).fetchall()
344
+ return [r["currency"] for r in rows]
345
+
346
+ def count(self, user_id: Optional[int] = None) -> int:
347
+ with self._lock:
348
+ if user_id is None:
349
+ row = self._conn.execute("SELECT COUNT(*) AS n FROM expenses").fetchone()
350
+ else:
351
+ row = self._conn.execute(
352
+ "SELECT COUNT(*) AS n FROM expenses WHERE user_id = ?", (user_id,)
353
+ ).fetchone()
354
+ return int(row["n"])
355
+
356
+ # --------------------------------------------------------------- budgets
357
+
358
+ def set_budget(self, budget: Budget) -> Budget:
359
+ with self._lock:
360
+ self._conn.execute(
361
+ """INSERT INTO budgets
362
+ (user_id, category, amount_minor, currency, period, created_at)
363
+ VALUES (?,?,?,?,?,?)
364
+ ON CONFLICT(user_id, category, period) DO UPDATE SET
365
+ amount_minor = excluded.amount_minor,
366
+ currency = excluded.currency""",
367
+ (
368
+ budget.user_id,
369
+ budget.category,
370
+ budget.amount_minor,
371
+ budget.currency.upper(),
372
+ budget.period,
373
+ budget.created_at.isoformat(),
374
+ ),
375
+ )
376
+ self._conn.commit()
377
+ return budget
378
+
379
+ def get_budgets(self, user_id: int) -> list[Budget]:
380
+ with self._lock:
381
+ rows = self._conn.execute(
382
+ "SELECT * FROM budgets WHERE user_id = ? ORDER BY category",
383
+ (user_id,),
384
+ ).fetchall()
385
+ return [
386
+ Budget(
387
+ user_id=r["user_id"],
388
+ category=r["category"],
389
+ amount_minor=r["amount_minor"],
390
+ currency=r["currency"],
391
+ period=r["period"],
392
+ created_at=datetime.fromisoformat(r["created_at"]),
393
+ )
394
+ for r in rows
395
+ ]
396
+
397
+ def delete_budget(self, user_id: int, category: str, period: str = "monthly") -> bool:
398
+ with self._lock:
399
+ cur = self._conn.execute(
400
+ "DELETE FROM budgets WHERE user_id = ? AND category = ? AND period = ?",
401
+ (user_id, category, period),
402
+ )
403
+ self._conn.commit()
404
+ return cur.rowcount > 0
405
+
406
+
407
+ def open_store(settings: Settings) -> Store:
408
+ return Store(settings)
409
+
410
+
411
+ def utc_now() -> datetime:
412
+ return datetime.now(timezone.utc)
finbot/telegram.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Telegram Bot API client and update parsing.
2
+
3
+ Webhook rather than long polling, because free Spaces sleep after inactivity
4
+ and a sleeping container cannot poll. Telegram's POST wakes it instead.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections import OrderedDict
11
+ from dataclasses import dataclass
12
+ from typing import Any, Optional
13
+
14
+ import httpx
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+ API_ROOT = "https://api.telegram.org"
19
+
20
+ #: getFile / downloads are capped at 20 MB by the Bot API itself.
21
+ MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024
22
+
23
+ #: Telegram rejects messages over 4096 characters.
24
+ MAX_MESSAGE_CHARS = 4096
25
+
26
+
27
+ @dataclass
28
+ class ParsedUpdate:
29
+ update_id: int
30
+ user_id: int
31
+ chat_id: int
32
+ text: str = ""
33
+ command: str = ""
34
+ args: str = ""
35
+ caption: str = ""
36
+ file_id: str = ""
37
+ file_name: str = ""
38
+ mime_type: str = ""
39
+ file_kind: str = ""
40
+ file_size: int = 0
41
+
42
+ @property
43
+ def has_file(self) -> bool:
44
+ return bool(self.file_id)
45
+
46
+
47
+ def parse_update(update: dict[str, Any], bot_username: str = "") -> Optional[ParsedUpdate]:
48
+ """Normalise a Telegram update. Returns None for anything we don't handle."""
49
+ message = update.get("message") or update.get("edited_message")
50
+ if not isinstance(message, dict):
51
+ return None
52
+
53
+ sender = message.get("from") or {}
54
+ chat = message.get("chat") or {}
55
+ if not sender.get("id") or not chat.get("id"):
56
+ return None
57
+
58
+ parsed = ParsedUpdate(
59
+ update_id=int(update.get("update_id", 0)),
60
+ user_id=int(sender["id"]),
61
+ chat_id=int(chat["id"]),
62
+ caption=(message.get("caption") or "").strip(),
63
+ )
64
+
65
+ text = (message.get("text") or "").strip()
66
+ parsed.text = text
67
+
68
+ if text.startswith("/"):
69
+ head, _, rest = text.partition(" ")
70
+ command = head[1:]
71
+ # Group chats deliver "/month@MyBot"; strip the mention.
72
+ if "@" in command:
73
+ command, _, mentioned = command.partition("@")
74
+ if bot_username and mentioned.lower() != bot_username.lower():
75
+ return None
76
+ parsed.command = command.lower()
77
+ parsed.args = rest.strip()
78
+
79
+ if photos := message.get("photo"):
80
+ # PhotoSize list runs smallest to largest; the largest reads best.
81
+ largest = max(photos, key=lambda p: p.get("file_size") or 0)
82
+ parsed.file_id = largest.get("file_id", "")
83
+ parsed.file_kind = "photo"
84
+ parsed.mime_type = "image/jpeg"
85
+ parsed.file_size = int(largest.get("file_size") or 0)
86
+ elif document := message.get("document"):
87
+ parsed.file_id = document.get("file_id", "")
88
+ parsed.file_kind = "document"
89
+ parsed.file_name = document.get("file_name", "")
90
+ parsed.mime_type = document.get("mime_type", "")
91
+ parsed.file_size = int(document.get("file_size") or 0)
92
+
93
+ return parsed
94
+
95
+
96
+ def chunk_message(text: str, limit: int = MAX_MESSAGE_CHARS) -> list[str]:
97
+ """Split a long reply on line boundaries so HTML tags stay balanced."""
98
+ if len(text) <= limit:
99
+ return [text]
100
+
101
+ chunks: list[str] = []
102
+ current: list[str] = []
103
+ length = 0
104
+ for line in text.split("\n"):
105
+ # +1 for the newline we will rejoin with.
106
+ if length + len(line) + 1 > limit and current:
107
+ chunks.append("\n".join(current))
108
+ current, length = [], 0
109
+ current.append(line)
110
+ length += len(line) + 1
111
+ if current:
112
+ chunks.append("\n".join(current))
113
+ return chunks
114
+
115
+
116
+ class SeenUpdates:
117
+ """Bounded set of processed update ids.
118
+
119
+ We answer Telegram's webhook immediately and process in the background, so
120
+ a slow reply can cause Telegram to redeliver. Without this, one statement
121
+ could be imported twice.
122
+ """
123
+
124
+ def __init__(self, capacity: int = 512):
125
+ self.capacity = capacity
126
+ self._seen: OrderedDict[int, None] = OrderedDict()
127
+
128
+ def add_if_new(self, update_id: int) -> bool:
129
+ if update_id in self._seen:
130
+ return False
131
+ self._seen[update_id] = None
132
+ while len(self._seen) > self.capacity:
133
+ self._seen.popitem(last=False)
134
+ return True
135
+
136
+
137
+ class TelegramClient:
138
+ def __init__(self, token: str):
139
+ self.token = token
140
+ self._client: Optional[httpx.AsyncClient] = None
141
+ self.username = ""
142
+
143
+ @property
144
+ def enabled(self) -> bool:
145
+ return bool(self.token)
146
+
147
+ def _http(self) -> httpx.AsyncClient:
148
+ if self._client is None:
149
+ self._client = httpx.AsyncClient(
150
+ base_url=f"{API_ROOT}/bot{self.token}",
151
+ timeout=httpx.Timeout(60.0, connect=10.0),
152
+ )
153
+ return self._client
154
+
155
+ async def close(self) -> None:
156
+ if self._client is not None:
157
+ await self._client.aclose()
158
+ self._client = None
159
+
160
+ async def _call(self, method: str, **payload: Any) -> dict:
161
+ response = await self._http().post(f"/{method}", json=payload)
162
+ data = response.json()
163
+ if not data.get("ok"):
164
+ raise RuntimeError(f"{method} failed: {data.get('description', data)}")
165
+ return data.get("result") or {}
166
+
167
+ async def get_me(self) -> dict:
168
+ result = await self._call("getMe")
169
+ self.username = result.get("username", "")
170
+ return result
171
+
172
+ async def send_message(
173
+ self, chat_id: int, text: str, parse_mode: Optional[str] = "HTML"
174
+ ) -> None:
175
+ for chunk in chunk_message(text):
176
+ payload: dict[str, Any] = {
177
+ "chat_id": chat_id,
178
+ "text": chunk,
179
+ "link_preview_options": {"is_disabled": True},
180
+ }
181
+ if parse_mode:
182
+ payload["parse_mode"] = parse_mode
183
+ try:
184
+ await self._call("sendMessage", **payload)
185
+ except RuntimeError as exc:
186
+ # Malformed HTML from a merchant name shouldn't swallow the
187
+ # reply -- retry once as plain text so the user still sees it.
188
+ if parse_mode:
189
+ log.warning("HTML send failed (%s); retrying as plain text", exc)
190
+ await self._call(
191
+ "sendMessage",
192
+ chat_id=chat_id,
193
+ text=_strip_tags(chunk),
194
+ )
195
+ else:
196
+ raise
197
+
198
+ async def send_document(
199
+ self, chat_id: int, filename: str, data: bytes, caption: str = ""
200
+ ) -> None:
201
+ files = {"document": (filename, data, "text/csv")}
202
+ form: dict[str, Any] = {"chat_id": str(chat_id)}
203
+ if caption:
204
+ form["caption"] = caption[:1024]
205
+ form["parse_mode"] = "HTML"
206
+ response = await self._http().post("/sendDocument", data=form, files=files)
207
+ payload = response.json()
208
+ if not payload.get("ok"):
209
+ raise RuntimeError(f"sendDocument failed: {payload.get('description')}")
210
+
211
+ async def send_typing(self, chat_id: int) -> None:
212
+ try:
213
+ await self._call("sendChatAction", chat_id=chat_id, action="typing")
214
+ except RuntimeError:
215
+ pass # cosmetic only
216
+
217
+ async def download(self, file_id: str) -> tuple[bytes, str]:
218
+ """Fetch a file by id. Returns (bytes, telegram_file_path)."""
219
+ info = await self._call("getFile", file_id=file_id)
220
+ file_path = info.get("file_path")
221
+ if not file_path:
222
+ raise RuntimeError("Telegram did not return a file path")
223
+
224
+ url = f"{API_ROOT}/file/bot{self.token}/{file_path}"
225
+ async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
226
+ response = await client.get(url)
227
+ response.raise_for_status()
228
+ return response.content, file_path
229
+
230
+ async def set_webhook(self, url: str, secret: str) -> dict:
231
+ return await self._call(
232
+ "setWebhook",
233
+ url=url,
234
+ secret_token=secret,
235
+ allowed_updates=["message", "edited_message"],
236
+ drop_pending_updates=True,
237
+ )
238
+
239
+ async def delete_webhook(self) -> dict:
240
+ return await self._call("deleteWebhook", drop_pending_updates=True)
241
+
242
+ async def get_webhook_info(self) -> dict:
243
+ return await self._call("getWebhookInfo")
244
+
245
+ async def set_my_commands(self, commands: list[dict[str, str]]) -> dict:
246
+ return await self._call("setMyCommands", commands=commands)
247
+
248
+
249
+ def _strip_tags(text: str) -> str:
250
+ import re
251
+
252
+ return re.sub(r"<[^>]+>", "", text)
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.115,<0.121
2
+ uvicorn[standard]>=0.32,<0.40
3
+ httpx>=0.27,<0.29
4
+ pydantic>=2.9,<3
5
+ pydantic-settings>=2.6,<3
6
+ huggingface_hub>=0.34,<1.0
7
+ pdfplumber>=0.11,<0.12
8
+ openpyxl>=3.1,<4
9
+ python-dateutil>=2.9,<3
tests/test_analytics.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date
2
+
3
+ import pytest
4
+
5
+ from finbot.analytics import (
6
+ by_category,
7
+ budget_status,
8
+ detect_recurring,
9
+ dominant_currency,
10
+ month_bounds,
11
+ period_bounds,
12
+ previous_month_bounds,
13
+ project_month_end,
14
+ split_by_currency,
15
+ summarise,
16
+ top_merchants,
17
+ total_minor,
18
+ )
19
+ from finbot.models import Budget, Expense
20
+
21
+ TODAY = date(2026, 8, 15)
22
+
23
+
24
+ def make(amount, category="other", currency="INR", day=TODAY, merchant="", desc="x"):
25
+ return Expense(
26
+ user_id=1,
27
+ amount_minor=amount,
28
+ currency=currency,
29
+ description=desc,
30
+ category=category,
31
+ occurred_on=day,
32
+ merchant=merchant,
33
+ )
34
+
35
+
36
+ class TestPeriods:
37
+ @pytest.mark.parametrize(
38
+ "name,start,end",
39
+ [
40
+ ("today", TODAY, TODAY),
41
+ ("yesterday", date(2026, 8, 14), date(2026, 8, 14)),
42
+ ("week", date(2026, 8, 10), TODAY), # Monday of that week
43
+ ("month", date(2026, 8, 1), TODAY),
44
+ ("last_month", date(2026, 7, 1), date(2026, 7, 31)),
45
+ ("year", date(2026, 1, 1), TODAY),
46
+ ("30d", date(2026, 7, 17), TODAY),
47
+ ],
48
+ )
49
+ def test_bounds(self, name, start, end):
50
+ got_start, got_end, _label = period_bounds(name, TODAY)
51
+ assert (got_start, got_end) == (start, end)
52
+
53
+ def test_unknown_period_defaults_to_month(self):
54
+ start, end, _ = period_bounds("nonsense", TODAY)
55
+ assert (start, end) == (date(2026, 8, 1), TODAY)
56
+
57
+ def test_month_bounds_handles_month_length(self):
58
+ assert month_bounds(date(2026, 2, 10))[1] == date(2026, 2, 28)
59
+
60
+ def test_previous_month_across_a_year_boundary(self):
61
+ assert previous_month_bounds(date(2026, 1, 5)) == (
62
+ date(2025, 12, 1),
63
+ date(2025, 12, 31),
64
+ )
65
+
66
+
67
+ class TestAggregation:
68
+ def test_total_and_by_category(self):
69
+ expenses = [
70
+ make(10000, "food_dining"),
71
+ make(5000, "food_dining"),
72
+ make(20000, "rent"),
73
+ ]
74
+ assert total_minor(expenses) == 35000
75
+ assert by_category(expenses) == [("rent", 20000), ("food_dining", 15000)]
76
+
77
+ def test_split_by_currency(self):
78
+ expenses = [make(100, currency="INR"), make(200, currency="USD")]
79
+ buckets = split_by_currency(expenses)
80
+ assert set(buckets) == {"INR", "USD"}
81
+
82
+ def test_dominant_currency_by_count(self):
83
+ expenses = [make(100, currency="INR"), make(100, currency="INR"), make(999999, currency="USD")]
84
+ assert dominant_currency(expenses, "EUR") == "INR"
85
+
86
+ def test_dominant_currency_falls_back_when_empty(self):
87
+ assert dominant_currency([], "SGD") == "SGD"
88
+
89
+ def test_summarise_only_counts_the_named_currency(self):
90
+ expenses = [make(10000, currency="INR"), make(50000, currency="USD")]
91
+ summary = summarise(expenses, "INR", "Aug", date(2026, 8, 1), TODAY)
92
+ assert summary.total_minor == 10000
93
+ assert summary.count == 1
94
+
95
+ def test_delta_pct(self):
96
+ current = [make(15000)]
97
+ previous = [make(10000)]
98
+ summary = summarise(current, "INR", "Aug", date(2026, 8, 1), TODAY, previous)
99
+ assert summary.delta_pct == pytest.approx(50.0)
100
+
101
+ def test_delta_pct_is_none_without_a_baseline(self):
102
+ summary = summarise([make(100)], "INR", "Aug", date(2026, 8, 1), TODAY, [])
103
+ assert summary.delta_pct is None
104
+
105
+ def test_top_merchants(self):
106
+ expenses = [
107
+ make(100, merchant="Swiggy"),
108
+ make(300, merchant="Swiggy"),
109
+ make(200, merchant="Uber"),
110
+ ]
111
+ assert top_merchants(expenses)[0] == ("Swiggy", 400)
112
+
113
+
114
+ class TestProjection:
115
+ def test_projects_to_month_end(self):
116
+ # 15 days elapsed of a 31-day August, ₹150 spent -> ~₹310.
117
+ expenses = [make(15000, day=date(2026, 8, 5))]
118
+ projected = project_month_end(expenses, TODAY)
119
+ assert projected == pytest.approx(31000, rel=0.01)
120
+
121
+
122
+ class TestRecurring:
123
+ def _monthly(self, merchant, amount, months):
124
+ return [
125
+ make(amount, day=date(2026, m, 5), merchant=merchant, desc=merchant)
126
+ for m in months
127
+ ]
128
+
129
+ def test_detects_a_monthly_subscription(self):
130
+ expenses = self._monthly("Netflix", 64900, [5, 6, 7])
131
+ found = detect_recurring(expenses)
132
+ assert len(found) == 1
133
+ assert found[0].months == 3
134
+ assert found[0].typical_minor == 64900
135
+
136
+ def test_ignores_charges_in_too_few_months(self):
137
+ expenses = self._monthly("Netflix", 64900, [6, 7])
138
+ assert detect_recurring(expenses) == []
139
+
140
+ def test_ignores_repeat_spending_with_erratic_amounts(self):
141
+ # Same shop every month, wildly different amounts -- not a subscription.
142
+ expenses = [
143
+ make(10000, day=date(2026, 5, 5), merchant="Bigbasket", desc="Bigbasket"),
144
+ make(90000, day=date(2026, 6, 5), merchant="Bigbasket", desc="Bigbasket"),
145
+ make(250000, day=date(2026, 7, 5), merchant="Bigbasket", desc="Bigbasket"),
146
+ ]
147
+ assert detect_recurring(expenses) == []
148
+
149
+ def test_three_charges_in_one_month_is_not_recurring(self):
150
+ expenses = [
151
+ make(64900, day=date(2026, 7, d), merchant="Netflix", desc="Netflix")
152
+ for d in (1, 10, 20)
153
+ ]
154
+ assert detect_recurring(expenses) == []
155
+
156
+ def test_normalises_noisy_merchant_strings(self):
157
+ expenses = [
158
+ make(64900, day=date(2026, 5, 5), desc="NETFLIX.COM 447192"),
159
+ make(64900, day=date(2026, 6, 5), desc="NETFLIX.COM 883021"),
160
+ make(64900, day=date(2026, 7, 5), desc="Netflix"),
161
+ ]
162
+ found = detect_recurring(expenses)
163
+ assert len(found) == 1
164
+ assert found[0].months == 3
165
+
166
+ def test_annualised(self):
167
+ found = detect_recurring(self._monthly("Netflix", 50000, [5, 6, 7]))
168
+ assert found[0].annualised_minor == 600000
169
+
170
+ def test_separates_currencies(self):
171
+ expenses = self._monthly("Netflix", 64900, [5, 6, 7]) + [
172
+ make(1000, day=date(2026, m, 5), merchant="Netflix", desc="Netflix", currency="USD")
173
+ for m in (5, 6, 7)
174
+ ]
175
+ found = detect_recurring(expenses)
176
+ assert {f.currency for f in found} == {"INR", "USD"}
177
+
178
+
179
+ class TestBudgets:
180
+ def test_status_calculation(self):
181
+ budgets = [Budget(user_id=1, category="food_dining", amount_minor=100000, currency="INR")]
182
+ expenses = [make(60000, "food_dining")]
183
+ status = budget_status(budgets, expenses)[0]
184
+ assert status.spent_minor == 60000
185
+ assert status.pct == pytest.approx(60.0)
186
+ assert status.remaining_minor == 40000
187
+ assert not status.over
188
+
189
+ def test_over_budget(self):
190
+ budgets = [Budget(user_id=1, category="rent", amount_minor=10000, currency="INR")]
191
+ status = budget_status(budgets, [make(15000, "rent")])[0]
192
+ assert status.over
193
+ assert status.remaining_minor == -5000
194
+
195
+ def test_ignores_other_currencies(self):
196
+ budgets = [Budget(user_id=1, category="rent", amount_minor=10000, currency="INR")]
197
+ status = budget_status(budgets, [make(99999, "rent", currency="USD")])[0]
198
+ assert status.spent_minor == 0
199
+
200
+ def test_no_budgets_yields_no_statuses(self):
201
+ assert budget_status([], [make(100)]) == []
tests/test_app.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end webhook tests with a stubbed Telegram transport.
2
+
3
+ Environment is configured at import time, before the app's lifespan calls
4
+ ``get_settings()`` (which is lru_cached, so the first call wins).
5
+ """
6
+
7
+ import os
8
+ import tempfile
9
+
10
+ os.environ.update(
11
+ {
12
+ "DATA_DIR": tempfile.mkdtemp(prefix="finbot-test-"),
13
+ "TELEGRAM_BOT_TOKEN": "", # keeps lifespan from calling Telegram
14
+ "TELEGRAM_WEBHOOK_SECRET": "test-secret",
15
+ "ALLOWED_TELEGRAM_USER_IDS": "42",
16
+ "HF_TOKEN": "", # deterministic paths only; no network
17
+ "HF_DATASET_REPO": "",
18
+ "DEFAULT_CURRENCY": "INR",
19
+ "TIMEZONE": "Asia/Kolkata",
20
+ }
21
+ )
22
+
23
+ import pytest # noqa: E402
24
+ from fastapi.testclient import TestClient # noqa: E402
25
+
26
+ from app import app # noqa: E402
27
+
28
+ SECRET = {"X-Telegram-Bot-Api-Secret-Token": "test-secret"}
29
+
30
+
31
+ class FakeTelegram:
32
+ """Records what would have been sent instead of calling the Bot API."""
33
+
34
+ def __init__(self):
35
+ self.username = "FinBotTest"
36
+ self.messages: list[tuple[int, str]] = []
37
+ self.documents: list[tuple[int, str, bytes]] = []
38
+ self.enabled = True
39
+
40
+ async def send_message(self, chat_id, text, parse_mode="HTML"):
41
+ self.messages.append((chat_id, text))
42
+
43
+ async def send_document(self, chat_id, filename, data, caption=""):
44
+ self.documents.append((chat_id, filename, data))
45
+
46
+ async def send_typing(self, chat_id):
47
+ pass
48
+
49
+ async def download(self, file_id):
50
+ return b"", "file.bin"
51
+
52
+ async def close(self):
53
+ pass
54
+
55
+ @property
56
+ def last(self) -> str:
57
+ return self.messages[-1][1] if self.messages else ""
58
+
59
+
60
+ @pytest.fixture
61
+ def client():
62
+ with TestClient(app) as test_client:
63
+ fake = FakeTelegram()
64
+ app.state.telegram = fake
65
+ test_client.fake = fake
66
+ # Isolate each test: empty the ledger and any pending imports.
67
+ store = app.state.services.store
68
+ for expense in store.list_expenses(42):
69
+ store.delete_expense(42, expense.id)
70
+ app.state.services.pending.clear()
71
+ yield test_client
72
+
73
+
74
+ def send(client, text, user_id=42, update_id=None):
75
+ import random
76
+
77
+ payload = {
78
+ "update_id": update_id if update_id is not None else random.randint(1, 10**9),
79
+ "message": {
80
+ "from": {"id": user_id},
81
+ "chat": {"id": user_id},
82
+ "text": text,
83
+ },
84
+ }
85
+ return client.post("/telegram/webhook", json=payload, headers=SECRET)
86
+
87
+
88
+ class TestEndpoints:
89
+ def test_health(self, client):
90
+ response = client.get("/health")
91
+ assert response.status_code == 200
92
+ assert response.json()["status"] == "ok"
93
+
94
+ def test_health_leaks_no_ledger_detail(self, client):
95
+ # The Space may be public; liveness must not publish personal metrics.
96
+ body = client.get("/health").json()
97
+ assert set(body) == {"status", "version"}
98
+
99
+ def test_index_renders(self, client):
100
+ response = client.get("/")
101
+ assert response.status_code == 200
102
+ assert "Oracle" in response.text
103
+
104
+ def test_index_leaks_no_ledger_detail(self, client):
105
+ send(client, "coffee 250")
106
+ text = client.get("/").text.lower()
107
+ for leak in ("rows", "ledger", "mirroring", "not configured", "hf_dataset_repo"):
108
+ assert leak not in text
109
+
110
+ def test_diagnostics_requires_the_secret(self, client):
111
+ assert client.get("/admin/diagnostics").status_code == 403
112
+ assert client.get(
113
+ "/admin/diagnostics", headers={"X-Admin-Secret": "wrong"}
114
+ ).status_code == 403
115
+
116
+ def test_diagnostics_with_the_secret(self, client):
117
+ response = client.get("/admin/diagnostics", headers={"X-Admin-Secret": "test-secret"})
118
+ assert response.status_code == 200
119
+ assert response.json()["ok"] is True
120
+ assert "rows" in response.json()
121
+
122
+
123
+ class TestWebhookSecurity:
124
+ def test_rejects_a_missing_secret(self, client):
125
+ response = client.post("/telegram/webhook", json={})
126
+ assert response.status_code == 403
127
+
128
+ def test_rejects_a_wrong_secret(self, client):
129
+ response = client.post(
130
+ "/telegram/webhook",
131
+ json={},
132
+ headers={"X-Telegram-Bot-Api-Secret-Token": "wrong"},
133
+ )
134
+ assert response.status_code == 403
135
+
136
+ def test_rejects_malformed_json(self, client):
137
+ response = client.post("/telegram/webhook", content=b"not json", headers=SECRET)
138
+ assert response.status_code == 400
139
+
140
+ def test_unauthorised_user_is_refused_and_told_their_id(self, client):
141
+ send(client, "coffee 250", user_id=9999)
142
+ assert "9999" in client.fake.last
143
+ assert "allowlist" in client.fake.last
144
+
145
+ def test_unauthorised_user_writes_nothing(self, client):
146
+ send(client, "coffee 250", user_id=9999)
147
+ assert app.state.services.store.count(9999) == 0
148
+
149
+ def test_duplicate_update_is_processed_once(self, client):
150
+ send(client, "coffee 250", update_id=555)
151
+ send(client, "coffee 250", update_id=555)
152
+ assert app.state.services.store.count(42) == 1
153
+
154
+
155
+ class TestLoggingFlow:
156
+ def test_plain_text_is_logged(self, client):
157
+ response = send(client, "coffee 250")
158
+ assert response.status_code == 200
159
+ assert "Logged" in client.fake.last
160
+ assert "₹250.00" in client.fake.last
161
+ assert app.state.services.store.count(42) == 1
162
+
163
+ def test_text_without_an_amount_gets_guidance(self, client):
164
+ send(client, "hello there")
165
+ assert "couldn't find an amount" in client.fake.last
166
+ assert app.state.services.store.count(42) == 0
167
+
168
+ def test_spend_command(self, client):
169
+ send(client, "/spend 450 swiggy dinner")
170
+ assert "Logged" in client.fake.last
171
+ assert app.state.services.store.count(42) == 1
172
+
173
+ def test_spend_without_args_explains(self, client):
174
+ send(client, "/spend")
175
+ assert "/spend 250 coffee" in client.fake.last
176
+
177
+ def test_undo_removes_the_last_entry(self, client):
178
+ send(client, "coffee 250")
179
+ send(client, "/undo")
180
+ assert "Removed" in client.fake.last
181
+ assert app.state.services.store.count(42) == 0
182
+
183
+ def test_undo_with_nothing_to_undo(self, client):
184
+ send(client, "/undo")
185
+ assert "Nothing to undo" in client.fake.last
186
+
187
+
188
+ class TestSummaries:
189
+ def test_today(self, client):
190
+ send(client, "coffee 250")
191
+ send(client, "300 uber")
192
+ send(client, "/today")
193
+ assert "₹550.00" in client.fake.last
194
+
195
+ def test_month(self, client):
196
+ send(client, "1000 rent")
197
+ send(client, "/month")
198
+ assert "₹1,000.00" in client.fake.last
199
+
200
+ def test_empty_period(self, client):
201
+ send(client, "/today")
202
+ assert "Nothing logged" in client.fake.last
203
+
204
+ def test_multi_currency_is_reported_separately(self, client):
205
+ send(client, "coffee 250")
206
+ send(client, "€40 dinner")
207
+ send(client, "/today")
208
+ # Two currencies must never be summed into one figure.
209
+ assert "Also:" in client.fake.last
210
+
211
+ def test_list(self, client):
212
+ send(client, "coffee 250")
213
+ send(client, "/list")
214
+ assert "coffee" in client.fake.last
215
+
216
+ def test_report_accepts_a_period(self, client):
217
+ send(client, "/report last_month")
218
+ assert client.fake.last
219
+
220
+
221
+ class TestBudgets:
222
+ def test_set_and_view(self, client):
223
+ send(client, "/budget food_dining 8000")
224
+ assert "Budget set" in client.fake.last
225
+ send(client, "/budgets")
226
+ assert "Food &amp; Dining" in client.fake.last
227
+
228
+ def test_budget_tracks_spending(self, client):
229
+ send(client, "/budget food_dining 1000")
230
+ send(client, "500 lunch")
231
+ send(client, "/budgets")
232
+ assert "50%" in client.fake.last
233
+
234
+ def test_unknown_category_is_rejected(self, client):
235
+ send(client, "/budget nonsense 500")
236
+ assert "don't recognise" in client.fake.last
237
+
238
+ def test_budget_without_amount(self, client):
239
+ send(client, "/budget groceries")
240
+ assert "need an amount" in client.fake.last
241
+
242
+ def test_categories_listing(self, client):
243
+ send(client, "/categories")
244
+ assert "food_dining" in client.fake.last
245
+
246
+
247
+ class TestCoreCommands:
248
+ def test_start(self, client):
249
+ send(client, "/start")
250
+ assert "Hello" in client.fake.last
251
+
252
+ def test_help_lists_registered_features(self, client):
253
+ send(client, "/help")
254
+ text = client.fake.last
255
+ assert "/spend" in text
256
+ assert "/budget" in text
257
+ assert "/advice" in text
258
+
259
+ def test_unknown_command(self, client):
260
+ send(client, "/nonsense")
261
+ assert "don't know" in client.fake.last
262
+
263
+ def test_status_warns_about_ephemeral_storage(self, client):
264
+ send(client, "/status")
265
+ # HF_DATASET_REPO is unset in this environment, so it must say so.
266
+ assert "ephemeral" in client.fake.last.lower()
267
+
268
+ def test_whoami(self, client):
269
+ send(client, "/whoami")
270
+ assert "42" in client.fake.last
271
+
272
+ def test_advice_works_without_inference(self, client):
273
+ send(client, "500 lunch")
274
+ send(client, "/advice")
275
+ assert client.fake.last # deterministic observations still fire
276
+
277
+ def test_export_sends_a_csv(self, client):
278
+ send(client, "coffee 250")
279
+ send(client, "/export")
280
+ assert len(client.fake.documents) == 1
281
+ _chat, filename, payload = client.fake.documents[0]
282
+ assert filename.endswith(".csv")
283
+ assert b"coffee" in payload
284
+
285
+ def test_export_with_nothing_logged(self, client):
286
+ send(client, "/export")
287
+ assert "Nothing to export" in client.fake.last
tests/test_categories.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from finbot.categories import CATEGORIES, categorise, coerce, is_valid, label
4
+
5
+
6
+ class TestCategorise:
7
+ @pytest.mark.parametrize(
8
+ "text,expected",
9
+ [
10
+ ("uber ride home", "transport"),
11
+ ("netflix monthly", "subscriptions"),
12
+ ("bigbasket order", "groceries"),
13
+ ("swiggy dinner", "food_dining"),
14
+ ("petrol pump", "fuel"),
15
+ ("electricity bill", "utilities"),
16
+ ("apollo pharmacy", "health"),
17
+ ("bookmyshow tickets", "entertainment"),
18
+ ("amazon order", "shopping"),
19
+ ("atm withdrawal", "cash"),
20
+ ("salary credited", "income"),
21
+ ],
22
+ )
23
+ def test_keyword_hits(self, text, expected):
24
+ category, confidence = categorise(text)
25
+ assert category == expected
26
+ assert confidence == 1.0
27
+
28
+ def test_unknown_returns_other_with_zero_confidence(self):
29
+ category, confidence = categorise("blorptastic widget")
30
+ assert category == "other"
31
+ assert confidence == 0.0
32
+
33
+ def test_empty_input(self):
34
+ assert categorise("") == ("other", 0.0)
35
+
36
+ def test_specificity_order_fuel_beats_transport(self):
37
+ # A petrol pump is fuel, even though it is transport-adjacent.
38
+ assert categorise("indian oil petrol")[0] == "fuel"
39
+
40
+ def test_specificity_order_subscription_beats_entertainment(self):
41
+ assert categorise("netflix")[0] == "subscriptions"
42
+
43
+ def test_word_boundaries_prevent_false_hits(self):
44
+ # "cashew" must not match the "cash" keyword.
45
+ assert categorise("cashew nuts")[0] != "cash"
46
+ # "gasket" must not match "gas station".
47
+ assert categorise("engine gasket")[0] != "fuel"
48
+
49
+ def test_merchant_is_considered(self):
50
+ category, _ = categorise("monthly charge", merchant="Spotify")
51
+ assert category == "subscriptions"
52
+
53
+ def test_case_insensitive(self):
54
+ assert categorise("UBER RIDE")[0] == "transport"
55
+
56
+
57
+ class TestCoerce:
58
+ @pytest.mark.parametrize(
59
+ "raw,expected",
60
+ [
61
+ ("food_dining", "food_dining"),
62
+ ("Food", "food_dining"),
63
+ ("dining", "food_dining"),
64
+ ("grocery", "groceries"),
65
+ ("healthcare", "health"),
66
+ ("Subscription", "subscriptions"),
67
+ ("misc", "other"),
68
+ ("", "other"),
69
+ ("total nonsense", "other"),
70
+ ],
71
+ )
72
+ def test_coerce(self, raw, expected):
73
+ assert coerce(raw) == expected
74
+
75
+ def test_every_result_is_a_valid_category(self):
76
+ # A stray label from a model must never reach the ledger, or totals
77
+ # fragment across categories that do not exist.
78
+ for raw in ["", "???", "Investments", "food", "TRANSPORT", "x" * 200]:
79
+ assert is_valid(coerce(raw))
80
+
81
+
82
+ class TestLabels:
83
+ def test_every_category_has_a_label(self):
84
+ for category in CATEGORIES:
85
+ assert label(category)
86
+ assert label(category) != category or "_" not in category
tests/test_extract.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+
3
+ from finbot.extract import extract, extract_csv, extract_xlsx, rows_to_text
4
+
5
+ CSV_BYTES = (
6
+ b"Date,Narration,Withdrawal,Deposit\n"
7
+ b"01/08/2026,SWIGGY BANGALORE,450.00,\n"
8
+ b"03/08/2026,SALARY AUG,,85000.00\n"
9
+ b"05/08/2026,NETFLIX.COM,649.00,\n"
10
+ )
11
+
12
+
13
+ def make_xlsx() -> bytes:
14
+ from openpyxl import Workbook
15
+
16
+ workbook = Workbook()
17
+ sheet = workbook.active
18
+ sheet.append(["Date", "Description", "Amount"])
19
+ sheet.append(["2026-08-01", "Swiggy", 450])
20
+ sheet.append([None, None, None]) # blank row must be dropped
21
+ sheet.append(["2026-08-05", "Netflix", 649])
22
+ buffer = io.BytesIO()
23
+ workbook.save(buffer)
24
+ return buffer.getvalue()
25
+
26
+
27
+ class TestCSV:
28
+ def test_parses_rows(self):
29
+ doc = extract_csv(CSV_BYTES)
30
+ assert doc.kind == "csv"
31
+ assert len(doc.rows) == 4 # header + 3
32
+ assert doc.rows[1][1] == "SWIGGY BANGALORE"
33
+
34
+ def test_semicolon_delimiter(self):
35
+ doc = extract_csv(b"a;b;c\n1;2;3\n4;5;6\n")
36
+ assert doc.rows[0] == ["a", "b", "c"]
37
+
38
+ def test_skips_blank_rows(self):
39
+ doc = extract_csv(b"a,b\n\n1,2\n")
40
+ assert len(doc.rows) == 2
41
+
42
+ def test_handles_bom(self):
43
+ doc = extract_csv(b"\xef\xbb\xbfDate,Amount\n2026-08-01,10\n")
44
+ assert doc.rows[0][0] == "Date"
45
+
46
+ def test_handles_non_utf8(self):
47
+ doc = extract_csv("Caf\xe9,10\n".encode("latin-1"))
48
+ assert "Caf" in doc.rows[0][0]
49
+
50
+ def test_single_column_does_not_crash(self):
51
+ doc = extract_csv(b"justonecolumn\nvalue\n")
52
+ assert len(doc.rows) == 2
53
+
54
+
55
+ class TestXLSX:
56
+ def test_parses_rows(self):
57
+ doc = extract_xlsx(make_xlsx())
58
+ assert doc.kind == "xlsx"
59
+ assert doc.rows[0] == ["Date", "Description", "Amount"]
60
+ assert ["2026-08-05", "Netflix", "649"] in doc.rows
61
+
62
+ def test_drops_blank_rows(self):
63
+ doc = extract_xlsx(make_xlsx())
64
+ assert all(any(cell for cell in row) for row in doc.rows)
65
+
66
+
67
+ class TestDispatch:
68
+ def test_by_extension(self):
69
+ assert extract(CSV_BYTES, "statement.csv").kind == "csv"
70
+ assert extract(make_xlsx(), "book.xlsx").kind == "xlsx"
71
+
72
+ def test_by_mime_when_no_extension(self):
73
+ assert extract(CSV_BYTES, "", "text/csv").kind == "csv"
74
+
75
+ def test_plain_text(self):
76
+ doc = extract(b"hello", "notes.txt")
77
+ assert doc.kind == "text"
78
+ assert doc.text == "hello"
79
+
80
+ def test_legacy_xls_is_reported_not_crashed(self):
81
+ doc = extract(b"\xd0\xcf\x11\xe0", "old.xls")
82
+ assert doc.kind == "unsupported"
83
+ assert ".xlsx" in doc.note
84
+
85
+ def test_unknown_type_explains_itself(self):
86
+ doc = extract(b"\x00\x01", "thing.bin")
87
+ assert doc.kind == "unsupported"
88
+ assert doc.note
89
+
90
+ def test_is_empty(self):
91
+ assert extract(b"", "empty.txt").is_empty
92
+
93
+
94
+ class TestRowsToText:
95
+ def test_pipe_delimited(self):
96
+ text = rows_to_text([["a", "b"], ["1", "2"]])
97
+ assert text == "a | b\n1 | 2"
98
+
99
+ def test_respects_limit(self):
100
+ text = rows_to_text([["x"]] * 100, limit=3)
101
+ assert len(text.splitlines()) == 3
tests/test_ingest.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date
2
+
3
+ import pytest
4
+
5
+ from finbot.extract import ExtractedDocument
6
+ from finbot.ingest import Ingestor, _coerce_amount, _draft_from_obj, _parse_any_date
7
+ from finbot.llm import LLMUnavailable
8
+
9
+ TODAY = date(2026, 8, 15)
10
+
11
+
12
+ class StubLLM:
13
+ """Stands in for LLMClient without touching the network."""
14
+
15
+ def __init__(self, payload=None, enabled=True, raises=None):
16
+ self.payload = payload
17
+ self.enabled = enabled
18
+ self.raises = raises
19
+ self.calls = []
20
+
21
+ def chat_json(self, system, user, **kwargs):
22
+ self.calls.append(("chat_json", system, user))
23
+ if self.raises:
24
+ raise self.raises
25
+ return self.payload
26
+
27
+ def vision_json(self, system, user, image_bytes, mime_type, **kwargs):
28
+ self.calls.append(("vision_json", system, user))
29
+ if self.raises:
30
+ raise self.raises
31
+ return self.payload
32
+
33
+
34
+ class TestHelpers:
35
+ @pytest.mark.parametrize(
36
+ "raw,expected",
37
+ [(10, 10.0), (-10, 10.0), ("1,234.56", 1234.56), ("₹450", 450.0), ("", None), (None, None)],
38
+ )
39
+ def test_coerce_amount(self, raw, expected):
40
+ assert _coerce_amount(raw) == expected
41
+
42
+ @pytest.mark.parametrize(
43
+ "raw,expected",
44
+ [
45
+ ("2026-08-01", date(2026, 8, 1)),
46
+ ("01/08/2026", date(2026, 8, 1)), # day-first
47
+ ("", TODAY),
48
+ ("garbage", TODAY),
49
+ (None, TODAY),
50
+ ],
51
+ )
52
+ def test_parse_any_date(self, raw, expected):
53
+ assert _parse_any_date(raw, TODAY) == expected
54
+
55
+ def test_draft_from_obj(self):
56
+ draft = _draft_from_obj(
57
+ {
58
+ "amount": 450,
59
+ "currency": "inr",
60
+ "description": "Swiggy order",
61
+ "date": "2026-08-01",
62
+ "category": "food_dining",
63
+ },
64
+ "USD",
65
+ TODAY,
66
+ )
67
+ assert draft.amount_minor == 45000
68
+ assert draft.currency == "INR"
69
+ assert draft.category == "food_dining"
70
+
71
+ def test_draft_rejects_zero_and_missing_amounts(self):
72
+ assert _draft_from_obj({"amount": 0}, "INR", TODAY) is None
73
+ assert _draft_from_obj({}, "INR", TODAY) is None
74
+ assert _draft_from_obj("not a dict", "INR", TODAY) is None
75
+
76
+ def test_draft_falls_back_to_keyword_category(self):
77
+ # Model gave an unusable category, but the text is obviously transport.
78
+ draft = _draft_from_obj(
79
+ {"amount": 100, "description": "uber ride", "category": "???"}, "INR", TODAY
80
+ )
81
+ assert draft.category == "transport"
82
+
83
+ def test_draft_defaults_currency(self):
84
+ draft = _draft_from_obj({"amount": 10}, "SGD", TODAY)
85
+ assert draft.currency == "SGD"
86
+
87
+
88
+ class TestFromText:
89
+ def test_deterministic_path_does_not_call_the_model(self):
90
+ llm = StubLLM()
91
+ draft = Ingestor(llm, "INR").from_text("coffee 250", TODAY)
92
+ assert draft.amount_minor == 25000
93
+ assert llm.calls == [] # the whole point of the fast path
94
+
95
+ def test_falls_back_to_the_model_without_an_amount(self):
96
+ llm = StubLLM(payload={"amount": 12, "currency": "USD", "description": "a coffee"})
97
+ draft = Ingestor(llm, "INR").from_text("bought a coffee for twelve dollars", TODAY)
98
+ assert draft is not None
99
+ assert draft.amount_minor == 1200
100
+ assert len(llm.calls) == 1
101
+
102
+ def test_returns_none_when_the_model_is_disabled(self):
103
+ llm = StubLLM(enabled=False)
104
+ assert Ingestor(llm, "INR").from_text("no numbers here", TODAY) is None
105
+
106
+ def test_model_failure_degrades_quietly(self):
107
+ llm = StubLLM(raises=LLMUnavailable("down"))
108
+ assert Ingestor(llm, "INR").from_text("no numbers here", TODAY) is None
109
+
110
+
111
+ class TestFromReceipt:
112
+ def test_reads_a_total(self):
113
+ llm = StubLLM(
114
+ payload={
115
+ "merchant": "Blue Tokai",
116
+ "amount": 480,
117
+ "currency": "INR",
118
+ "date": "2026-08-14",
119
+ "category": "food_dining",
120
+ "confidence": 0.9,
121
+ }
122
+ )
123
+ draft, error = Ingestor(llm, "INR").from_receipt(b"jpeg", "image/jpeg", TODAY)
124
+ assert error == ""
125
+ assert draft.amount_minor == 48000
126
+ assert draft.merchant == "Blue Tokai"
127
+
128
+ def test_explains_when_disabled(self):
129
+ llm = StubLLM(enabled=False)
130
+ draft, error = Ingestor(llm, "INR").from_receipt(b"x", "image/jpeg", TODAY)
131
+ assert draft is None
132
+ assert "HF_TOKEN" in error
133
+
134
+ def test_explains_an_unreadable_image(self):
135
+ llm = StubLLM(payload={"amount": 0})
136
+ draft, error = Ingestor(llm, "INR").from_receipt(b"x", "image/jpeg", TODAY)
137
+ assert draft is None
138
+ assert "couldn't find a total" in error
139
+
140
+
141
+ class TestFromDocument:
142
+ def _doc(self):
143
+ return ExtractedDocument(kind="csv", rows=[["Date", "Desc", "Amt"], ["01/08/2026", "Swiggy", "450"]])
144
+
145
+ def test_extracts_debits(self):
146
+ llm = StubLLM(
147
+ payload={
148
+ "transactions": [
149
+ {
150
+ "date": "2026-08-01",
151
+ "description": "Swiggy",
152
+ "amount": 450,
153
+ "currency": "INR",
154
+ "direction": "debit",
155
+ "category": "food_dining",
156
+ }
157
+ ]
158
+ }
159
+ )
160
+ pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
161
+ assert error == ""
162
+ assert len(pending.drafts) == 1
163
+ assert pending.total_by_currency() == {"INR": 45000}
164
+
165
+ def test_credits_are_counted_not_stored(self):
166
+ # Storing income alongside spend would corrupt every total.
167
+ llm = StubLLM(
168
+ payload={
169
+ "transactions": [
170
+ {"date": "2026-08-01", "description": "Swiggy", "amount": 450,
171
+ "currency": "INR", "direction": "debit"},
172
+ {"date": "2026-08-03", "description": "Salary", "amount": 85000,
173
+ "currency": "INR", "direction": "credit"},
174
+ ]
175
+ }
176
+ )
177
+ pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
178
+ assert len(pending.drafts) == 1
179
+ assert "credits skipped" in pending.label
180
+
181
+ def test_counts_unreadable_rows(self):
182
+ llm = StubLLM(
183
+ payload={
184
+ "transactions": [
185
+ {"date": "2026-08-01", "description": "Swiggy", "amount": 450,
186
+ "currency": "INR", "direction": "debit"},
187
+ {"description": "junk"}, # no amount
188
+ ]
189
+ }
190
+ )
191
+ pending, _ = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
192
+ assert len(pending.drafts) == 1
193
+ assert pending.skipped == 1
194
+
195
+ def test_accepts_a_bare_array(self):
196
+ llm = StubLLM(
197
+ payload=[{"date": "2026-08-01", "description": "Swiggy", "amount": 450,
198
+ "currency": "INR", "direction": "debit"}]
199
+ )
200
+ pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
201
+ assert error == ""
202
+ assert len(pending.drafts) == 1
203
+
204
+ def test_reports_when_nothing_spendable_is_found(self):
205
+ llm = StubLLM(payload={"transactions": []})
206
+ pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
207
+ assert pending is None
208
+ assert "no spending transactions" in error
209
+
210
+ def test_unsupported_document_short_circuits(self):
211
+ llm = StubLLM()
212
+ doc = ExtractedDocument(kind="unsupported", note="nope")
213
+ pending, error = Ingestor(llm, "INR").from_document(doc, user_id=1, today=TODAY)
214
+ assert pending is None
215
+ assert error == "nope"
216
+ assert llm.calls == [] # never spend a call on a file we cannot read
217
+
218
+ def test_flags_truncation(self):
219
+ llm = StubLLM(
220
+ payload={"transactions": [{"date": "2026-08-01", "description": "x",
221
+ "amount": 1, "currency": "INR", "direction": "debit"}]}
222
+ )
223
+ doc = self._doc()
224
+ doc.truncated = True
225
+ pending, _ = Ingestor(llm, "INR").from_document(doc, user_id=1, today=TODAY)
226
+ assert "truncated" in pending.label
227
+
228
+ def test_disabled_model_explains_itself(self):
229
+ llm = StubLLM(enabled=False)
230
+ pending, error = Ingestor(llm, "INR").from_document(self._doc(), user_id=1, today=TODAY)
231
+ assert pending is None
232
+ assert "HF_TOKEN" in error
tests/test_limits.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from finbot.config import Settings
4
+ from finbot.limits import WINDOW_SECONDS, RateLimiter
5
+
6
+
7
+ def settings(**overrides):
8
+ base = dict(
9
+ telegram_bot_token="",
10
+ telegram_webhook_secret="",
11
+ allowed_telegram_user_ids="",
12
+ hf_token="",
13
+ hf_dataset_repo="",
14
+ open_access=False,
15
+ )
16
+ base.update(overrides)
17
+ return Settings(**base)
18
+
19
+
20
+ class TestAuthorisation:
21
+ def test_empty_allowlist_authorises_nobody(self):
22
+ # The safe default: a forgotten allowlist must not mean "open to all".
23
+ assert settings().is_authorised(42) is False
24
+
25
+ def test_allowlist_match(self):
26
+ s = settings(allowed_telegram_user_ids="42, 99")
27
+ assert s.is_authorised(42)
28
+ assert s.is_authorised(99)
29
+ assert not s.is_authorised(7)
30
+
31
+ def test_open_access_authorises_anyone(self):
32
+ s = settings(open_access=True)
33
+ assert s.is_authorised(1)
34
+ assert s.is_authorised(123456789)
35
+
36
+ def test_open_access_ignores_the_allowlist(self):
37
+ s = settings(open_access=True, allowed_telegram_user_ids="42")
38
+ assert s.is_authorised(7)
39
+
40
+ def test_malformed_ids_are_dropped_not_fatal(self):
41
+ s = settings(allowed_telegram_user_ids="42,,abc, 99 ,")
42
+ assert s.allowed_user_ids == frozenset({42, 99})
43
+
44
+ def test_open_access_removes_the_allowlist_warning(self):
45
+ assert any("ALLOWED" in g for g in settings().missing_required())
46
+ assert not any(
47
+ "ALLOWED" in g for g in settings(open_access=True).missing_required()
48
+ )
49
+
50
+
51
+ class TestRateLimiter:
52
+ def test_allows_up_to_the_cap(self):
53
+ limiter = RateLimiter(3)
54
+ assert [limiter.check(1) for _ in range(3)] == [True, True, True]
55
+
56
+ def test_blocks_past_the_cap(self):
57
+ limiter = RateLimiter(2)
58
+ limiter.check(1)
59
+ limiter.check(1)
60
+ assert limiter.check(1) is False
61
+
62
+ def test_users_are_independent(self):
63
+ limiter = RateLimiter(1)
64
+ assert limiter.check(1) is True
65
+ assert limiter.check(2) is True
66
+ assert limiter.check(1) is False
67
+
68
+ def test_zero_disables_the_cap(self):
69
+ limiter = RateLimiter(0)
70
+ assert not limiter.enabled
71
+ assert all(limiter.check(1) for _ in range(500))
72
+
73
+ def test_negative_disables_the_cap(self):
74
+ assert not RateLimiter(-1).enabled
75
+
76
+ def test_window_expires(self):
77
+ limiter = RateLimiter(1)
78
+ assert limiter.check(1, now=1000.0) is True
79
+ assert limiter.check(1, now=1000.0) is False
80
+ # Just past the 24h window, the old hit no longer counts.
81
+ assert limiter.check(1, now=1000.0 + WINDOW_SECONDS + 1) is True
82
+
83
+ def test_remaining(self):
84
+ limiter = RateLimiter(3)
85
+ assert limiter.remaining(1) == 3
86
+ limiter.check(1)
87
+ assert limiter.remaining(1) == 2
88
+
89
+ def test_remaining_is_unlimited_when_disabled(self):
90
+ assert RateLimiter(0).remaining(1) == -1
91
+
92
+ @pytest.mark.parametrize("cap", [1, 5, 20])
93
+ def test_never_exceeds_cap(self, cap):
94
+ limiter = RateLimiter(cap)
95
+ granted = sum(1 for _ in range(cap * 3) if limiter.check(1, now=500.0))
96
+ assert granted == cap
tests/test_money.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from finbot.money import (
4
+ detect_currency,
5
+ exponent,
6
+ format_money,
7
+ from_minor,
8
+ parse_amount,
9
+ to_minor,
10
+ )
11
+
12
+
13
+ class TestExponent:
14
+ @pytest.mark.parametrize(
15
+ "currency,expected",
16
+ [("INR", 2), ("USD", 2), ("EUR", 2), ("JPY", 0), ("KRW", 0), ("KWD", 3), ("xyz", 2)],
17
+ )
18
+ def test_exponent(self, currency, expected):
19
+ assert exponent(currency) == expected
20
+
21
+
22
+ class TestToMinor:
23
+ @pytest.mark.parametrize(
24
+ "value,currency,expected",
25
+ [
26
+ (250, "INR", 25000),
27
+ ("250.50", "INR", 25050),
28
+ (1234.56, "USD", 123456),
29
+ (1000, "JPY", 1000), # zero-decimal currency
30
+ (1.5, "KWD", 1500), # three-decimal currency
31
+ ("0.01", "USD", 1),
32
+ ],
33
+ )
34
+ def test_to_minor(self, value, currency, expected):
35
+ assert to_minor(value, currency) == expected
36
+
37
+ def test_rounds_half_up_not_bankers(self):
38
+ # Python's round() would give 8.34 here; money should round half up.
39
+ assert to_minor("8.345", "USD") == 835
40
+
41
+ def test_no_float_drift(self):
42
+ # The classic 0.1 + 0.2 problem must not reach the ledger.
43
+ total = to_minor("0.1", "USD") + to_minor("0.2", "USD")
44
+ assert total == 30
45
+ assert from_minor(total, "USD") == 0.3
46
+
47
+
48
+ class TestFormatMoney:
49
+ @pytest.mark.parametrize(
50
+ "minor,currency,expected",
51
+ [
52
+ (25000, "INR", "₹250.00"),
53
+ (123456, "USD", "$1,234.56"),
54
+ (1000, "JPY", "¥1,000"),
55
+ (-5000, "INR", "-₹50.00"),
56
+ (99, "USD", "$0.99"),
57
+ ],
58
+ )
59
+ def test_format(self, minor, currency, expected):
60
+ assert format_money(minor, currency) == expected
61
+
62
+ def test_unknown_currency_uses_suffix(self):
63
+ assert format_money(150000, "AED") == "1,500.00 AED"
64
+
65
+
66
+ class TestDetectCurrency:
67
+ @pytest.mark.parametrize(
68
+ "text,expected",
69
+ [
70
+ ("₹250 coffee", "INR"),
71
+ ("€40 dinner", "EUR"),
72
+ ("£12 book", "GBP"),
73
+ ("paid 50 USD", "USD"),
74
+ ("Rs 300 chai", "INR"),
75
+ ],
76
+ )
77
+ def test_explicit(self, text, expected):
78
+ assert detect_currency(text, "SGD") == expected
79
+
80
+ def test_falls_back_to_default(self):
81
+ assert detect_currency("coffee 250", "SGD") == "SGD"
82
+
83
+ def test_letter_symbols_need_word_boundaries(self):
84
+ # "cars" contains "rs" -- it must not be read as rupees.
85
+ assert detect_currency("cars 250", "USD") == "USD"
86
+ assert detect_currency("chfoo 12", "USD") == "USD"
87
+
88
+ def test_ambiguous_dollar_uses_default(self):
89
+ assert detect_currency("$40 lunch", "SGD") == "SGD"
90
+
91
+
92
+ class TestParseAmount:
93
+ @pytest.mark.parametrize(
94
+ "text,expected_minor",
95
+ [
96
+ ("coffee 250", 25000),
97
+ ("₹1,200 groceries", 120000),
98
+ ("45.50 lunch", 4550),
99
+ ("1,234.56 spent", 123456),
100
+ ("1.234,56 gekocht", 123456), # European separators
101
+ ("1,23,456.78 rent", 12345678), # Indian lakh grouping
102
+ ],
103
+ )
104
+ def test_amounts(self, text, expected_minor):
105
+ parsed = parse_amount(text, "INR")
106
+ assert parsed is not None
107
+ assert parsed.minor == expected_minor
108
+
109
+ def test_returns_none_without_a_number(self):
110
+ assert parse_amount("just some words", "INR") is None
111
+
112
+ def test_prefers_the_largest_number(self):
113
+ # "2 coffees 250" -- the spend is 250, not 2.
114
+ parsed = parse_amount("2 coffees 250", "INR")
115
+ assert parsed is not None
116
+ assert parsed.minor == 25000
117
+
118
+ def test_reports_the_span_it_consumed(self):
119
+ parsed = parse_amount("coffee 250", "INR")
120
+ assert parsed is not None
121
+ start, end = parsed.span
122
+ assert "coffee 250"[start:end] == "250"
123
+
124
+ def test_thousands_vs_decimal_disambiguation(self):
125
+ # 3 digits after a single comma is grouping, 2 digits is decimal.
126
+ assert parse_amount("1,500", "INR").minor == 150000
127
+ assert parse_amount("1,50", "INR").minor == 150
128
+
129
+ @pytest.mark.parametrize(
130
+ "text,expected_minor",
131
+ [
132
+ ("1000", 100_000),
133
+ ("rent 25000", 2_500_000),
134
+ ("123456", 12_345_600),
135
+ ("9999999 wire", 999_999_900),
136
+ ],
137
+ )
138
+ def test_unseparated_large_numbers(self, text, expected_minor):
139
+ # Regression: the old regex capped the integer part at three digits,
140
+ # so "1000" silently became 100 and every large amount was wrong.
141
+ parsed = parse_amount(text, "INR")
142
+ assert parsed is not None
143
+ assert parsed.minor == expected_minor
144
+
145
+ def test_space_separated_numbers_are_not_merged(self):
146
+ # "250 300" must read as two numbers (largest wins), never as 250300.
147
+ parsed = parse_amount("250 300", "INR")
148
+ assert parsed is not None
149
+ assert parsed.minor == 30000
150
+
151
+ def test_non_breaking_space_is_a_group_separator(self):
152
+ assert parse_amount("1 234,56", "EUR").minor == 123456
153
+
154
+ def test_trailing_punctuation_is_not_consumed(self):
155
+ parsed = parse_amount("spent 250.", "INR")
156
+ assert parsed is not None
157
+ assert parsed.minor == 25000
tests/test_parser.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date
2
+
3
+ import pytest
4
+
5
+ from finbot.parser import parse_date_hint, parse_expense
6
+
7
+ TODAY = date(2026, 8, 15) # a Saturday
8
+
9
+
10
+ class TestParseDateHint:
11
+ def test_defaults_to_today(self):
12
+ resolved, span = parse_date_hint("coffee 250", TODAY)
13
+ assert resolved == TODAY
14
+ assert span is None
15
+
16
+ @pytest.mark.parametrize(
17
+ "text,expected",
18
+ [
19
+ ("lunch yesterday", date(2026, 8, 14)),
20
+ ("coffee today", TODAY),
21
+ ("dinner day before yesterday", date(2026, 8, 13)),
22
+ ("paid 3 days ago", date(2026, 8, 12)),
23
+ ("rent on 2026-08-01", date(2026, 8, 1)),
24
+ ("bought 12 aug", date(2026, 8, 12)),
25
+ ("bought aug 12", date(2026, 8, 12)),
26
+ ],
27
+ )
28
+ def test_resolves(self, text, expected):
29
+ resolved, span = parse_date_hint(text, TODAY)
30
+ assert resolved == expected
31
+ assert span is not None
32
+
33
+ def test_future_dates_roll_back_a_year(self):
34
+ # "12 dec" typed in August means last December, not a future spend.
35
+ resolved, _ = parse_date_hint("gift 12 dec", TODAY)
36
+ assert resolved == date(2025, 12, 12)
37
+
38
+ def test_bare_weekday_means_the_most_recent_past_one(self):
39
+ # TODAY is a Saturday; "friday" is yesterday.
40
+ resolved, _ = parse_date_hint("drinks friday", TODAY)
41
+ assert resolved == date(2026, 8, 14)
42
+
43
+ def test_same_weekday_goes_back_a_full_week(self):
44
+ resolved, _ = parse_date_hint("shopping saturday", TODAY)
45
+ assert resolved == date(2026, 8, 8)
46
+
47
+ def test_day_first_preferred_for_ambiguous_slashes(self):
48
+ resolved, _ = parse_date_hint("paid 03/04", TODAY)
49
+ assert resolved == date(2026, 4, 3)
50
+
51
+ def test_falls_back_to_month_first_when_day_first_impossible(self):
52
+ # 25 is not a month, so 12/25 can only be December 25th.
53
+ resolved, _ = parse_date_hint("gift 12/25", TODAY)
54
+ assert resolved == date(2025, 12, 25)
55
+
56
+ def test_invalid_date_does_not_crash(self):
57
+ resolved, _ = parse_date_hint("paid 2026-02-30", TODAY)
58
+ assert resolved == TODAY
59
+
60
+
61
+ class TestParseExpense:
62
+ def test_returns_none_without_an_amount(self):
63
+ assert parse_expense("hello there", "INR", TODAY) is None
64
+
65
+ def test_returns_none_for_empty(self):
66
+ assert parse_expense("", "INR", TODAY) is None
67
+ assert parse_expense(" ", "INR", TODAY) is None
68
+
69
+ def test_simple_entry(self):
70
+ draft = parse_expense("coffee 250", "INR", TODAY)
71
+ assert draft is not None
72
+ assert draft.amount_minor == 25000
73
+ assert draft.currency == "INR"
74
+ assert "coffee" in draft.description
75
+ assert draft.category == "food_dining"
76
+ assert draft.occurred_on == TODAY
77
+
78
+ def test_strips_the_amount_from_the_description(self):
79
+ draft = parse_expense("₹1,200 groceries at bigbasket", "INR", TODAY)
80
+ assert draft is not None
81
+ assert "1" not in draft.description
82
+ assert "₹" not in draft.description
83
+ assert draft.category == "groceries"
84
+
85
+ def test_strips_filler_words(self):
86
+ draft = parse_expense("spent 500 on dinner", "INR", TODAY)
87
+ assert draft is not None
88
+ assert draft.description == "dinner"
89
+
90
+ def test_date_phrase_leaves_the_description(self):
91
+ draft = parse_expense("45.50 lunch yesterday", "INR", TODAY)
92
+ assert draft is not None
93
+ assert draft.occurred_on == date(2026, 8, 14)
94
+ assert "yesterday" not in draft.description
95
+ assert draft.description == "lunch"
96
+
97
+ def test_hashtag_overrides_the_keyword_guess(self):
98
+ draft = parse_expense("320 lunch #transport", "INR", TODAY)
99
+ assert draft is not None
100
+ # "lunch" would normally be food_dining; the tag wins.
101
+ assert draft.category == "transport"
102
+ assert "#" not in draft.description
103
+
104
+ def test_unknown_hashtag_falls_back_to_other(self):
105
+ draft = parse_expense("100 thing #nonsense", "INR", TODAY)
106
+ assert draft is not None
107
+ assert draft.category == "other"
108
+
109
+ def test_explicit_currency_beats_the_default(self):
110
+ draft = parse_expense("€40 dinner", "INR", TODAY)
111
+ assert draft is not None
112
+ assert draft.currency == "EUR"
113
+ assert draft.amount_minor == 4000
114
+
115
+ def test_description_never_empty(self):
116
+ draft = parse_expense("250", "INR", TODAY)
117
+ assert draft is not None
118
+ assert draft.description == "unlabelled"
119
+
120
+ def test_keeps_the_raw_input(self):
121
+ draft = parse_expense(" coffee 250 ", "INR", TODAY)
122
+ assert draft is not None
123
+ assert draft.raw == "coffee 250"
tests/test_registry.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The registry is the extension point, so its contract is worth pinning down."""
2
+
3
+ from datetime import date
4
+ from typing import Optional, Sequence
5
+
6
+ import pytest
7
+
8
+ from finbot.features.base import (
9
+ Attachment,
10
+ BotContext,
11
+ CommandSpec,
12
+ Feature,
13
+ Reply,
14
+ Services,
15
+ SimpleFeature,
16
+ )
17
+ from finbot.registry import DuplicateCommand, FeatureRegistry
18
+
19
+ TODAY = date(2026, 8, 15)
20
+
21
+
22
+ class Greeter(SimpleFeature):
23
+ name = "greeter"
24
+ description = "Greetings"
25
+ priority = 10
26
+
27
+ def commands(self) -> Sequence[CommandSpec]:
28
+ return (
29
+ CommandSpec("hello", "Say hello", aliases=("hi",)),
30
+ CommandSpec("secret", "Hidden thing", hidden=True),
31
+ )
32
+
33
+ def cmd_hello(self, ctx: BotContext) -> Reply:
34
+ return Reply(f"hello {ctx.args or 'world'}")
35
+
36
+ def cmd_secret(self, ctx: BotContext) -> Reply:
37
+ return Reply("shh")
38
+
39
+
40
+ class Echo(Feature):
41
+ name = "echo"
42
+ description = "Echo"
43
+ priority = 50
44
+
45
+ def handle_message(self, ctx: BotContext) -> Optional[Reply]:
46
+ if ctx.text.startswith("say "):
47
+ return Reply(ctx.text[4:])
48
+ return None
49
+
50
+
51
+ class Filer(Feature):
52
+ name = "filer"
53
+ description = "Files"
54
+ priority = 5
55
+
56
+ def handle_attachment(self, ctx: BotContext) -> Optional[Reply]:
57
+ return Reply(f"got {ctx.attachment.filename}")
58
+
59
+
60
+ class Exploder(Feature):
61
+ name = "exploder"
62
+ priority = 1
63
+
64
+ def commands(self) -> Sequence[CommandSpec]:
65
+ return (CommandSpec("boom", "Raises"),)
66
+
67
+ def handle_command(self, ctx: BotContext) -> Reply:
68
+ raise RuntimeError("intentional")
69
+
70
+
71
+ def make_ctx(registry, text="", command="", args="", attachment=None) -> BotContext:
72
+ services = Services(
73
+ settings=None, store=None, llm=None, ingestor=None, advisor=None, registry=registry
74
+ )
75
+ return BotContext(
76
+ user_id=1,
77
+ chat_id=1,
78
+ services=services,
79
+ today=TODAY,
80
+ text=text,
81
+ command=command,
82
+ args=args,
83
+ attachment=attachment,
84
+ )
85
+
86
+
87
+ @pytest.fixture
88
+ def registry():
89
+ reg = FeatureRegistry()
90
+ reg.register_all([Greeter(), Echo(), Filer()])
91
+ return reg
92
+
93
+
94
+ class TestRegistration:
95
+ def test_features_sorted_by_priority(self, registry):
96
+ assert [f.name for f in registry.features] == ["filer", "greeter", "echo"]
97
+
98
+ def test_duplicate_command_is_rejected(self, registry):
99
+ class Clash(SimpleFeature):
100
+ name = "clash"
101
+
102
+ def commands(self):
103
+ return (CommandSpec("hello", "clashing"),)
104
+
105
+ with pytest.raises(DuplicateCommand, match="greeter"):
106
+ registry.register(Clash())
107
+
108
+ def test_duplicate_alias_is_rejected(self, registry):
109
+ class Clash(SimpleFeature):
110
+ name = "clash"
111
+
112
+ def commands(self):
113
+ return (CommandSpec("other", "x", aliases=("hi",)),)
114
+
115
+ with pytest.raises(DuplicateCommand):
116
+ registry.register(Clash())
117
+
118
+ def test_known_command(self, registry):
119
+ assert registry.known_command("hello")
120
+ assert registry.known_command("hi")
121
+ assert not registry.known_command("nope")
122
+
123
+
124
+ class TestDispatch:
125
+ def test_command(self, registry):
126
+ reply = registry.dispatch(make_ctx(registry, command="hello", args="there"))
127
+ assert reply.text == "hello there"
128
+
129
+ def test_alias(self, registry):
130
+ reply = registry.dispatch(make_ctx(registry, command="hi"))
131
+ assert reply.text == "hello world"
132
+
133
+ def test_unknown_command_is_reported(self, registry):
134
+ reply = registry.dispatch(make_ctx(registry, command="nonsense"))
135
+ assert "don't know" in reply.text
136
+
137
+ def test_plain_message_routes_to_a_message_handler(self, registry):
138
+ reply = registry.dispatch(make_ctx(registry, text="say cheese"))
139
+ assert reply.text == "cheese"
140
+
141
+ def test_declined_message_returns_none(self, registry):
142
+ assert registry.dispatch(make_ctx(registry, text="unhandled")) is None
143
+
144
+ def test_attachment_routes_to_attachment_handler(self, registry):
145
+ attachment = Attachment(data=b"x", filename="a.csv")
146
+ reply = registry.dispatch(make_ctx(registry, attachment=attachment))
147
+ assert reply.text == "got a.csv"
148
+
149
+ def test_a_failing_feature_does_not_crash_the_bot(self, registry):
150
+ registry.register(Exploder())
151
+ reply = registry.dispatch(make_ctx(registry, command="boom"))
152
+ assert "went wrong" in reply.text
153
+
154
+
155
+ class TestHelpGeneration:
156
+ def test_help_lists_visible_commands(self, registry):
157
+ text = registry.help_text()
158
+ assert "/hello" in text
159
+ assert "Greetings" in text
160
+
161
+ def test_help_omits_hidden_commands(self, registry):
162
+ assert "/secret" not in registry.help_text()
163
+
164
+ def test_telegram_commands_payload(self, registry):
165
+ payload = registry.telegram_commands()
166
+ names = {c["command"] for c in payload}
167
+ assert "hello" in names
168
+ assert "secret" not in names
169
+ assert all(len(c["description"]) <= 256 for c in payload)
170
+
171
+ def test_help_escapes_html(self):
172
+ class Sneaky(SimpleFeature):
173
+ name = "sneaky"
174
+ description = "<b>bold</b>"
175
+
176
+ def commands(self):
177
+ return (CommandSpec("x", "a & b"),)
178
+
179
+ reg = FeatureRegistry()
180
+ reg.register(Sneaky())
181
+ text = reg.help_text()
182
+ assert "&lt;b&gt;bold&lt;/b&gt;" in text
183
+ assert "a &amp; b" in text
tests/test_store.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date
2
+
3
+ import pytest
4
+
5
+ from finbot.config import Settings
6
+ from finbot.models import Budget, Expense
7
+ from finbot.store import Store
8
+
9
+ TODAY = date(2026, 8, 15)
10
+
11
+
12
+ @pytest.fixture
13
+ def store(tmp_path):
14
+ settings = Settings(
15
+ data_dir=str(tmp_path),
16
+ hf_dataset_repo="", # sync off: no network in tests
17
+ hf_token="",
18
+ telegram_bot_token="",
19
+ telegram_webhook_secret="",
20
+ allowed_telegram_user_ids="",
21
+ )
22
+ store = Store(settings)
23
+ yield store
24
+ store.close()
25
+
26
+
27
+ def make(amount=25000, category="food_dining", day=TODAY, merchant="", batch=""):
28
+ return Expense(
29
+ user_id=1,
30
+ amount_minor=amount,
31
+ currency="INR",
32
+ description="coffee",
33
+ category=category,
34
+ occurred_on=day,
35
+ merchant=merchant,
36
+ batch_id=batch,
37
+ )
38
+
39
+
40
+ class TestExpenses:
41
+ def test_add_and_read_back(self, store):
42
+ expense = store.add_expense(make())
43
+ rows = store.list_expenses(1)
44
+ assert len(rows) == 1
45
+ assert rows[0].id == expense.id
46
+ assert rows[0].amount_minor == 25000
47
+ assert rows[0].occurred_on == TODAY
48
+
49
+ def test_isolated_per_user(self, store):
50
+ store.add_expense(make())
51
+ other = make()
52
+ other.user_id = 2
53
+ store.add_expense(other)
54
+ assert len(store.list_expenses(1)) == 1
55
+ assert len(store.list_expenses(2)) == 1
56
+
57
+ def test_date_filtering(self, store):
58
+ store.add_expense(make(day=date(2026, 8, 1)))
59
+ store.add_expense(make(day=date(2026, 7, 1)))
60
+ rows = store.list_expenses(1, date(2026, 8, 1), date(2026, 8, 31))
61
+ assert len(rows) == 1
62
+
63
+ def test_category_filtering(self, store):
64
+ store.add_expense(make(category="rent"))
65
+ store.add_expense(make(category="food_dining"))
66
+ assert len(store.list_expenses(1, category="rent")) == 1
67
+
68
+ def test_limit(self, store):
69
+ for _ in range(5):
70
+ store.add_expense(make())
71
+ assert len(store.list_expenses(1, limit=3)) == 3
72
+
73
+ def test_last_expense(self, store):
74
+ store.add_expense(make(amount=100))
75
+ latest = store.add_expense(make(amount=999))
76
+ assert store.last_expense(1).id == latest.id
77
+
78
+ def test_last_expense_none_when_empty(self, store):
79
+ assert store.last_expense(1) is None
80
+
81
+ def test_delete(self, store):
82
+ expense = store.add_expense(make())
83
+ assert store.delete_expense(1, expense.id) is True
84
+ assert store.list_expenses(1) == []
85
+
86
+ def test_delete_wrong_user_is_a_no_op(self, store):
87
+ expense = store.add_expense(make())
88
+ assert store.delete_expense(999, expense.id) is False
89
+ assert len(store.list_expenses(1)) == 1
90
+
91
+ def test_delete_batch(self, store):
92
+ for _ in range(3):
93
+ store.add_expense(make(batch="batch-1"))
94
+ store.add_expense(make(batch=""))
95
+ assert store.delete_batch(1, "batch-1") == 3
96
+ assert len(store.list_expenses(1)) == 1
97
+
98
+ def test_delete_batch_ignores_empty_id(self, store):
99
+ store.add_expense(make(batch=""))
100
+ assert store.delete_batch(1, "") == 0
101
+
102
+ def test_count(self, store):
103
+ store.add_expense(make())
104
+ store.add_expense(make())
105
+ assert store.count(1) == 2
106
+ assert store.count() == 2
107
+
108
+ def test_currencies_used(self, store):
109
+ store.add_expense(make())
110
+ usd = make()
111
+ usd.currency = "USD"
112
+ store.add_expense(usd)
113
+ assert set(store.currencies_used(1)) == {"INR", "USD"}
114
+
115
+
116
+ class TestDedupe:
117
+ def test_matching_transactions_share_a_key(self, store):
118
+ a = make(merchant="Swiggy")
119
+ b = make(merchant="swiggy!") # different case and punctuation
120
+ assert a.dedupe_key == b.dedupe_key
121
+
122
+ def test_key_changes_with_amount(self, store):
123
+ assert make(amount=100).dedupe_key != make(amount=200).dedupe_key
124
+
125
+ def test_key_changes_with_date(self, store):
126
+ assert make(day=date(2026, 8, 1)).dedupe_key != make(day=date(2026, 8, 2)).dedupe_key
127
+
128
+ def test_existing_keys_lookup(self, store):
129
+ expense = store.add_expense(make(merchant="Swiggy"))
130
+ found = store.existing_dedupe_keys(1, [expense.dedupe_key, "nope"])
131
+ assert found == {expense.dedupe_key}
132
+
133
+ def test_existing_keys_empty_input(self, store):
134
+ assert store.existing_dedupe_keys(1, []) == set()
135
+
136
+ def test_duplicates_are_still_insertable(self, store):
137
+ # Two identical coffees on one day are both real -- dedupe is advisory
138
+ # at import time, not a database constraint.
139
+ store.add_expense(make())
140
+ store.add_expense(make())
141
+ assert store.count(1) == 2
142
+
143
+ def test_chunks_large_key_lists(self, store):
144
+ expense = store.add_expense(make())
145
+ keys = [f"k{i}" for i in range(1200)] + [expense.dedupe_key]
146
+ assert store.existing_dedupe_keys(1, keys) == {expense.dedupe_key}
147
+
148
+
149
+ class TestBudgets:
150
+ def test_set_and_get(self, store):
151
+ store.set_budget(Budget(1, "food_dining", 100000, "INR"))
152
+ budgets = store.get_budgets(1)
153
+ assert len(budgets) == 1
154
+ assert budgets[0].amount_minor == 100000
155
+
156
+ def test_upsert_replaces(self, store):
157
+ store.set_budget(Budget(1, "food_dining", 100000, "INR"))
158
+ store.set_budget(Budget(1, "food_dining", 250000, "INR"))
159
+ budgets = store.get_budgets(1)
160
+ assert len(budgets) == 1
161
+ assert budgets[0].amount_minor == 250000
162
+
163
+ def test_delete(self, store):
164
+ store.set_budget(Budget(1, "rent", 100, "INR"))
165
+ assert store.delete_budget(1, "rent") is True
166
+ assert store.get_budgets(1) == []
167
+
168
+ def test_delete_missing(self, store):
169
+ assert store.delete_budget(1, "rent") is False
170
+
171
+
172
+ class TestPersistence:
173
+ def test_survives_reopen(self, tmp_path):
174
+ settings = Settings(
175
+ data_dir=str(tmp_path),
176
+ hf_dataset_repo="",
177
+ hf_token="",
178
+ telegram_bot_token="",
179
+ telegram_webhook_secret="",
180
+ )
181
+ first = Store(settings)
182
+ first.add_expense(make())
183
+ first.close()
184
+
185
+ second = Store(settings)
186
+ assert second.count(1) == 1
187
+ second.close()
188
+
189
+ def test_sync_disabled_without_config(self, store):
190
+ assert store.sync_status == "disabled"
tests/test_telegram.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from finbot.telegram import SeenUpdates, chunk_message, parse_update
2
+
3
+
4
+ def message_update(update_id=1, **message):
5
+ base = {"from": {"id": 42}, "chat": {"id": 99}}
6
+ base.update(message)
7
+ return {"update_id": update_id, "message": base}
8
+
9
+
10
+ class TestParseUpdate:
11
+ def test_plain_text(self):
12
+ parsed = parse_update(message_update(text="coffee 250"))
13
+ assert parsed.user_id == 42
14
+ assert parsed.chat_id == 99
15
+ assert parsed.text == "coffee 250"
16
+ assert parsed.command == ""
17
+
18
+ def test_command_with_args(self):
19
+ parsed = parse_update(message_update(text="/report last_month"))
20
+ assert parsed.command == "report"
21
+ assert parsed.args == "last_month"
22
+
23
+ def test_bare_command(self):
24
+ parsed = parse_update(message_update(text="/month"))
25
+ assert parsed.command == "month"
26
+ assert parsed.args == ""
27
+
28
+ def test_command_is_lowercased(self):
29
+ assert parse_update(message_update(text="/MONTH")).command == "month"
30
+
31
+ def test_group_mention_is_stripped(self):
32
+ parsed = parse_update(message_update(text="/month@FinBot"), bot_username="FinBot")
33
+ assert parsed.command == "month"
34
+
35
+ def test_mention_of_another_bot_is_ignored(self):
36
+ assert parse_update(message_update(text="/month@OtherBot"), bot_username="FinBot") is None
37
+
38
+ def test_photo_picks_the_largest_size(self):
39
+ parsed = parse_update(
40
+ message_update(
41
+ photo=[
42
+ {"file_id": "small", "file_size": 100},
43
+ {"file_id": "large", "file_size": 9000},
44
+ ],
45
+ caption="lunch",
46
+ )
47
+ )
48
+ assert parsed.file_id == "large"
49
+ assert parsed.file_kind == "photo"
50
+ assert parsed.mime_type == "image/jpeg"
51
+ assert parsed.caption == "lunch"
52
+ assert parsed.has_file
53
+
54
+ def test_document(self):
55
+ parsed = parse_update(
56
+ message_update(
57
+ document={
58
+ "file_id": "doc1",
59
+ "file_name": "statement.pdf",
60
+ "mime_type": "application/pdf",
61
+ "file_size": 2048,
62
+ }
63
+ )
64
+ )
65
+ assert parsed.file_kind == "document"
66
+ assert parsed.file_name == "statement.pdf"
67
+ assert parsed.file_size == 2048
68
+
69
+ def test_edited_message_is_handled(self):
70
+ update = {"update_id": 5, "edited_message": {"from": {"id": 42}, "chat": {"id": 99}, "text": "x"}}
71
+ assert parse_update(update).text == "x"
72
+
73
+ def test_non_message_updates_are_skipped(self):
74
+ assert parse_update({"update_id": 1, "poll": {}}) is None
75
+
76
+ def test_malformed_update_is_skipped(self):
77
+ assert parse_update({"update_id": 1, "message": {"text": "hi"}}) is None
78
+ assert parse_update({}) is None
79
+
80
+ def test_no_file_means_no_file(self):
81
+ assert not parse_update(message_update(text="hi")).has_file
82
+
83
+
84
+ class TestChunkMessage:
85
+ def test_short_message_is_untouched(self):
86
+ assert chunk_message("hello") == ["hello"]
87
+
88
+ def test_splits_on_line_boundaries(self):
89
+ text = "\n".join(f"line {i}" for i in range(500))
90
+ chunks = chunk_message(text, limit=200)
91
+ assert len(chunks) > 1
92
+ assert all(len(c) <= 200 for c in chunks)
93
+ # No content lost.
94
+ assert "\n".join(chunks) == text
95
+
96
+ def test_never_splits_mid_line(self):
97
+ chunks = chunk_message("aaa\nbbb\nccc", limit=8)
98
+ for chunk in chunks:
99
+ for line in chunk.split("\n"):
100
+ assert line in ("aaa", "bbb", "ccc")
101
+
102
+
103
+ class TestSeenUpdates:
104
+ def test_first_time_is_new(self):
105
+ seen = SeenUpdates()
106
+ assert seen.add_if_new(1) is True
107
+
108
+ def test_redelivery_is_rejected(self):
109
+ seen = SeenUpdates()
110
+ seen.add_if_new(1)
111
+ assert seen.add_if_new(1) is False
112
+
113
+ def test_evicts_oldest_beyond_capacity(self):
114
+ seen = SeenUpdates(capacity=3)
115
+ for i in range(5):
116
+ seen.add_if_new(i)
117
+ # 0 and 1 were evicted, so they read as new again.
118
+ assert seen.add_if_new(0) is True
119
+ assert seen.add_if_new(4) is False