# Ledger · 一角账本 **English** | [中文](README.zh-CN.md) A self-hosted, mobile-first expense tracker built for a student living in the UK. Snap a receipt, forward an order email, or send one line to a Telegram bot, and the expense is recognised, categorised and converted between GBP and CNY. The interface is in Chinese.

Monthly dashboard Split bills Pantry Add an expense

Screenshots use the built-in demo data (`DEMO_MODE=1`); every record is fictional. ## What you need Only a password is required. Each other item switches on more features; leave it out and the rest still works. | Item | Required? | What it enables | How to get it | |---|---|---|---| | `ADMIN_TOKEN` | **Yes** | Site password (also the Telegram bind phrase) | Choose one | | Zhipu API key (`VISION_API_KEY`) | Recommended | Receipt / screenshot recognition, one-line entries, email bills, bank SMS | [open.bigmodel.cn](https://open.bigmodel.cn); any OpenAI-compatible endpoint works via `VISION_API_BASE_URL` | | Telegram bot token | Recommended | Record by chat, daily reminders, monthly report, email-bill notifications | @BotFather → `/newbot` | | A domain on Cloudflare | For email bills | A forwarding address such as `ledger@your-domain` (Email Routing + a Worker), no mailbox password needed | Free Cloudflare plan | | IMAP mailbox + app password | Optional | Alternative to Cloudflare: poll a mailbox (e.g. Gmail) for bills | Your mail provider | | Google service account + Sheet | Optional | Two-way sync of a shared flat-expenses sheet | Google Cloud console | | A server | For hosting | Anything that runs Docker; the included scripts target single-node K3s | — | Exchange rates come from frankfurter.dev and need no key. ## Features - **Four ways to record** - Receipt photo or screenshot → a vision model (Zhipu GLM, OpenAI-compatible API) extracts merchant, date, total, line items and categories; review, then save. - Manual entry. - Telegram bot: send a receipt photo, or a line such as `12.5 coffee`, `Tesco 23.40`, `¥68 hotpot`. - Email bills: forward Deliveroo / Uber Eats / Just Eat / Amazon / Trainline emails to a dedicated address. An LLM extracts the items and order number; repeat emails for the same order are merged, marketing mail is ignored. - **Categories**: 11 top-level categories with sub-categories, assignable per line item (shampoo bought at Tesco counts as shopping, not groceries). - **Monthly summary**: totals in £ and ≈¥, daily average, month-end projection, comparison with last month, category breakdown, daily bars, optional budget. - **Exchange rates**: daily GBP→CNY rate from frankfurter.dev (ECB), locked per expense; CNY / EUR / USD receipts are converted automatically. - **Reminders**: Telegram nudge at 21:00 (London time) if nothing was logged that day; monthly report on the 1st. - **Personal reminders**: tell the bot "remind me tomorrow at lunch to bring a gift" (in Chinese or English); it parses the time, pings you before then, and the reminder shows on the History calendar. `/reminders` lists them. - **Reconciliation**: paste bank SMS or a statement screenshot; each transaction is matched as recorded / probably recorded (confirm with `/ok`) / missing (added automatically). Bank charges with no receipt are listed once a day at 20:00 (and on `/recall`); reply "the 11.21 one was the airport shuttle" and it is filled in. - **Split bills**: swipe to split with flatmates, per-item "mine / shared / theirs", or mark as paid for someone else; totals owed per person. - **Shared flat sheet** (optional): two-way sync with a Google Sheet shared with flatmates; only the shared part of each bill is pushed. - **Pantry**: perishable groceries enter a pantry with an estimated shelf life; reminders before they expire, and optional links to a recipe site. - **De-duplication**: order number, same day / amount / merchant, and SMS placeholders within ±3 days merge emails, screenshots, receipts and SMS into one expense. - **Demo mode**: `DEMO_MODE=1` serves generated sample data, read-only, with no background jobs. ## Try it locally Requirements: Python 3.11+, Node.js 20+. ```bash # backend (demo data, no API keys needed) python3 -m venv .venv && .venv/bin/pip install -r backend/requirements.txt cd backend && DEMO_MODE=1 ADMIN_TOKEN=guest ../.venv/bin/uvicorn app:app --port 8070 # frontend, in another terminal cd frontend && npm install && DEMO_BACKEND_ORIGIN=http://127.0.0.1:8070 npm run dev # open http://localhost:3070/?demo=1 ``` For real use, start the backend without `DEMO_MODE` and set `ADMIN_TOKEN` to your password. Without `VISION_API_KEY` receipts can only be entered manually; without `TELEGRAM_BOT_TOKEN` the bot and reminders stay off. All options are documented in [`backend/config.py`](backend/config.py). ## Project layout ``` backend/ FastAPI + SQLite (data/ledger.db; receipt images in data/receipts/) app.py routes; everything except /api/login and /api/health needs Bearer ADMIN_TOKEN service.py saving an expense (FX), Telegram messages, monthly report vision.py receipt recognition and one-line classification mail.py email bills: MIME → text → LLM → expense (push and IMAP entry points) telegram.py bot long-polling scheduler.py reminders (one tick per minute) pantry.py pantry sync and shelf-life estimates sheets.py Google Sheet two-way sync demo_seed.py sample data for DEMO_MODE frontend/ Next.js 16 (port 3070, proxies /api to the backend on 8070) deploy/ K3s manifests, Dockerfiles, one-command deploy, Cloudflare email worker ``` ## Telegram

Recording by Telegram Email bill notification and reminder

Send a receipt photo and the bot replies with the recognised items, translated names and the CNY equivalent; a line such as `Pret coffee 3.45` or `¥68 hotpot` works too. Every reply links back to the web page for edits, and `/undo` removes the last entry. Other commands: `/today`, `/month`, `/fridge`, `/used`, `/split`, `/recall`, `/help`. With the shared sheet enabled, "我转了 Alex 99.47" / "Sam 转给我 17.01" records a transfer between flatmates. 1. Create a bot with @BotFather and put the token in `deploy/secret.yaml` (`TELEGRAM_BOT_TOKEN`). 2. After deploying, send your site password to the bot as a message to bind it. 3. Reminder times, test messages and unbinding are on the settings page. The chat images are illustrations: the reply texts are generated by the bot's own code with sample data. ## Email bills Takeaway, online shopping and train tickets arrive as emails anyway, so forwarding them is the least effort: set one rule in Outlook or Gmail once, and every future bill is recorded with its line items, with a Telegram notice when it lands (right-hand image above). No mailbox password is shared with the server. ```mermaid flowchart LR A["Deliveroo / Amazon / Trainline
order email"] --> B["Your mailbox
(forwarding rule)"] B --> C["ledger@your-domain
Cloudflare Email Routing"] C --> D["Email Worker"] D --> E["Ledger backend
LLM extracts items"] E --> F["Telegram notice"] ``` - Several emails for one order (confirmation, then delivery) are merged by order number; marketing mail is ignored. - Only senders in `MAIL_ALLOWED_SENDERS` (comma-separated, `@domain` allowed) are accepted. **Setup (push, recommended)** 1. Cloudflare → Email → Email Routing: enable it for your domain (MX records are added automatically). 2. Workers & Pages → create a Worker from [`deploy/cloudflare-email-worker.js`](deploy/cloudflare-email-worker.js) with variables `LEDGER_URL` (your site) and `LEDGER_SECRET` (= `MAIL_INBOUND_SECRET`). 3. Email Routing → custom address, e.g. `ledger@`, action "Send to a Worker". 4. Set `MAIL_INBOUND_ADDRESS` and `MAIL_ALLOWED_SENDERS` in `deploy/local.env`. 5. In your mailbox, add a rule such as "sender contains deliveroo → forward to ledger@your-domain". **Alternative (IMAP polling)**: set `MAIL_USER` / `MAIL_PASSWORD` / `MAIL_FOLDER` for mailboxes that support app passwords (e.g. Gmail); the backend checks for unread mail every 2 minutes. ## Deploy (K3s) ```bash cp deploy/local.env.example deploy/local.env # server, domain, sender allow-list cp deploy/secret.example.yaml deploy/secret.yaml # password and API keys bash deploy/sync-and-deploy.sh ``` The script rsyncs the source to the node, builds both images there, imports them into containerd, fills the placeholders in `deploy/k8s.yaml` from `local.env`, and applies it. The secret is applied only on first deploy (or with `SEED_SECRET=1`). A demo backend runs alongside the real one; the frontend routes requests with the `guest` token to it, so visitors can look around without seeing your data. Run exactly **one** backend replica: Telegram long-polling and reminder de-duplication are in-process. ## License [AGPL-3.0](LICENSE)