MVP-1 Architecture Design — Telegram Finance Bot
Compact design document — input for the implementation plan. Full SRS, ADRs and per-feature specs are produced in a separate phase using the project’s hybrid SRS template.
1. Purpose & Context
Section titled “1. Purpose & Context”A Telegram bot that lets a small group of users (initially the author and his partner) record personal finance transactions — expenses, income and inter-account transfers — and view simple statistics. The system must enforce strict double-entry bookkeeping invariants so that account balances are always derivable from the transaction history.
The project also serves as a portfolio artifact for the author’s System Analyst public site: documentation will be published; source will not. Architectural decisions are therefore captured as ADRs even when the technical reason alone would not justify the ceremony.
The starting point is a Russian-language YouTube tutorial repo (telegram-finance-bot original on master) — single-user, SQLite, aiogram 2.4, RUB hardcoded. The original is treated as a UX reference only (message format 250 кафе, alias-based categories, /del{id} removal); the architecture and code are rewritten.
1.1 Goals (in priority order)
Section titled “1.1 Goals (in priority order)”- Correctness — ledger invariants (balance = sum of transfers, atomic debit+credit, no orphan rows) hold always, enforced at the database level.
- Production-readiness for two real users within ~4 weeks of part-time work.
- Portfolio quality — every non-trivial decision is documented as an ADR with explicit alternatives and trade-offs.
- Future-proofing — clean port boundary so the ledger backend can be swapped to TigerBeetle later without touching domain or application code.
1.2 Non-goals (MVP-1)
Section titled “1.2 Non-goals (MVP-1)”- Multi-currency, FX, exchange rates.
- Recurring transactions, scheduled rules, savings goals.
- CSV / bank import / export.
- Web frontend, mobile app, public API.
- Self-onboarding (whitelist only — friends/SaaS come later).
- Payment / billing tier.
- TigerBeetle (deferred — see ADR-0001).
2. Scope Summary (Tier-2)
Section titled “2. Scope Summary (Tier-2)”| Feature | In MVP-1? | Notes |
|---|---|---|
Add expense from text (250 кафе) | ✅ | Default account; optional account override (250 кафе card) |
Add income (+5000 зарплата) | ✅ | Same parser, leading + |
Transfer between own accounts (/transfer 1500 card->cash) | ✅ | Currency-match required |
| Multiple accounts per user (cash, card, deposit) | ✅ | One marked default |
| User-defined categories + system seed | ✅ | Alias-based matching |
| Per-category budgets (monthly limit) | ✅ | Read-only display in /today, /month |
/today, /month statistics | ✅ | Sum, by category, base vs total |
/expenses last-N list, /del{id} voiding | ✅ | Voiding via compensating transfer (ledger pattern) |
| Multi-currency / FX | ❌ | Tier-3 / MVP-3 |
| Recurring transactions | ❌ | Tier-3 |
| Goals / savings | ❌ | Tier-3 |
| CSV import / bank statement parsing | ❌ | Tier-3 |
| Self-onboarding, multi-tenant SaaS | ❌ | MVP-2 / v1 |
Scope explicitly simplified for solo context. No BA hand-off, no MAG / Keycloak integration, no Postman collection, no Jira traceability. Documentation focus is on architecture quality, not enterprise process compliance.
3. Stack at a Glance
Section titled “3. Stack at a Glance”| Layer | Choice | Why |
|---|---|---|
| Language | Python 3.12 | Continuity with original; ecosystem fit |
| Bot framework | aiogram 3.x | Async-first; major rewrite from 2.4; actively maintained — ADR-0003 |
| System DB | PostgreSQL 16 | Mature, ACID, migrations — ADR-0002 |
| Ledger | PostgreSQL 16 (same instance, separate schema) | Defer TigerBeetle until fact-based trigger — ADR-0001 |
| DB driver | asyncpg | Native async, fast |
| Schema migrations | Alembic | Standard for SQLAlchemy ecosystem |
| Entity primary keys | UUIDv7 | TB-compatible, time-ordered, b-tree-friendly — ADR-0009 |
| Package manager | uv | Fast, single binary, modern |
| Lint / format | ruff | Replaces flake8 + isort + black |
| Type checker | mypy —strict (domain/application) | Bulletproof core — see §6 |
| Test runner | pytest + pytest-asyncio + testcontainers | Industry standard |
| Logging | structlog (JSON in prod) | PII-aware redaction baked in |
| Metrics | Prometheus client | Pull-based, simple |
| Dashboards | Grafana (local on VPS) | Single-binary deploy |
| Config | pydantic-settings | Typed env vars |
| Hosting | Single VPS, docker-compose | ADR-0013 |
| Update delivery | Telegram long-polling | NAT-friendly, no TLS cert — ADR-0012 |
| CI | GitHub Actions | Free for public/personal |
| Auth | Telegram-id whitelist (env var) | MVP-1 only; replaced by self-onboarding in MVP-2 |
4. System Context (C4 L1)
Section titled “4. System Context (C4 L1)”Single Python process. Single PostgreSQL instance with two schemas (app, ledger). Local Grafana for portfolio narrative (“production-grade observability from day one”). No external dependencies beyond Telegram.
5. Domain Model
Section titled “5. Domain Model”5.1 Entities
Section titled “5.1 Entities”- User — Telegram-id whitelisted account. Owns Accounts, Categories, Budgets.
- Account — a logical wallet. Types:
cash,card,deposit(real assets),external_expense,external_income(virtual sinks/sources). Every transaction in the system is a transfer between two Accounts; this is the ledger model.- Expense = transfer real →
external_expense. - Income = transfer
external_income→ real. - Transfer = transfer real → real.
- Expense = transfer real →
- Category — metadata (for reports + budgets), not a ledger concept.
user_id IS NULL⇒ system seed. - Transaction — one operation = one row in
ledger.transfer. Carriesdebit_account_id,credit_account_id,amount_minor,currency, plus metadata columns. - Budget — per-category monthly/weekly limit.
5.2 ERD
Section titled “5.2 ERD”5.3 Invariants (database-enforced)
Section titled “5.3 Invariants (database-enforced)”Transaction.amount_minor > 0(CHECK).Transaction.debit_account_id <> Transaction.credit_account_id(CHECK).Transaction.debit_account.user_id = Transaction.credit_account.user_id = Transaction.user_id(trigger).Transaction.debit_account.currency = Transaction.credit_account.currency = Transaction.currency(trigger). Multi-currency exchange is Tier-3.account.balance_minoris a materialized cache; the trigger updates it inside the same row-lock as the insert. Source of truth =SUM(credit) - SUM(debit). Reconciliation job (nightly) compares cache to source-of-truth and alerts on drift.idempotency_key(ledger.transfer.iditself, UUIDv7) is UNIQUE — protects against Telegram update redelivery and retry storms.
5.4 Transaction Lifecycle
Section titled “5.4 Transaction Lifecycle”No confirmation step. Auto-post; /del{id} removes via compensation. Voiding is soft via a compensating transfer (debit/credit reversed); the original row is never deleted. This matches the TigerBeetle-recommended correction pattern (docs/coding/recipes/correcting-transfers.md) and gives a complete audit trail by design.
6. Module Structure (Hexagonal / Ports & Adapters)
Section titled “6. Module Structure (Hexagonal / Ports & Adapters)”src/finance_bot/├── domain/ # pure business types, no I/O│ ├── models.py # User, Account, Category, Transaction, Budget│ ├── money.py # Money value object│ └── errors.py # DomainError hierarchy├── ports/ # typing.Protocol interfaces│ ├── ledger.py # LedgerPort│ ├── repositories.py # *Repo│ └── clock.py # Clock├── application/ # one file per use-case│ ├── record_expense.py│ ├── record_income.py│ ├── record_transfer.py│ ├── void_transaction.py│ ├── get_today_stats.py│ ├── get_month_stats.py│ └── manage_categories.py├── adapters/│ ├── ledger/│ │ ├── postgres.py # PostgresLedgerAdapter (MVP-1)│ │ └── tigerbeetle.py # placeholder, future│ ├── repositories/postgres/ # *Repo implementations│ ├── telegram/ # aiogram routers, handlers, parsers, middlewares│ └── observability/ # metrics, logging├── config.py # pydantic-settings├── bootstrap.py # composition root└── __main__.py6.1 Dependency direction
Section titled “6.1 Dependency direction”Rule: arrows only point inward toward domain. domain and ports know nothing about Postgres, aiogram, or logging. application knows ports, never adapters. bootstrap.py is the only place that imports both ports and concrete adapters.
mypy --strict is enabled for domain/, ports/, application/. Adapters use --non-strict (aiogram introduces too much Any).
6.2 LedgerPort interface
Section titled “6.2 LedgerPort interface”class LedgerPort(Protocol): async def open_account( self, *, account_id: UUID, user_id: UUID, currency: str, min_balance_minor: int | None = None, ) -> None: ...
async def post_transfer( self, *, transfer_id: UUID, user_id: UUID, debit_account_id: UUID, credit_account_id: UUID, amount: Money, occurred_at: datetime, metadata: TransferMetadata, # category_id, raw_text, kind ) -> PostedTransfer: ...
async def void( self, *, transfer_id: UUID, compensating_id: UUID, reason: str, ) -> PostedTransfer: ...
async def get_balance(self, account_id: UUID) -> Money: ...TigerBeetle-friendly by construction:
- All IDs are client-generated UUIDv7 (matches TB’s
tb.id()time-ordered 128-bit requirement). - Operations are idempotent on the client-side ID (PG: UNIQUE; TB: native).
- No semantic concepts (“expense” / “income”) leak into the port — they live in
application/. metadatais opaque pass-through (PG stores in typed columns it does not interpret; TB will store inuser_data_*+ sidecar).
Read-side queries (/today, /month, /expenses) live in a separate TransactionReadRepo port, not in LedgerPort — CQRS-lite. After the eventual TB migration, this read repo stays on PG forever (TB cannot do aggregations).
7. Key Data Flows
Section titled “7. Key Data Flows”7.1 Add expense (250 кафе)
Section titled “7.1 Add expense (250 кафе)”The update_id deduplication happens in the middleware before parsing, so duplicate Telegram redeliveries cost only one cheap PG insert. The transfer_id is a separate UUIDv7 generated by the use-case; if the use-case is retried (e.g., transient PG error), the same transfer_id is reused and the UNIQUE constraint on ledger.transfer.id makes the retry idempotent.
7.2 Voiding (/del{id})
Section titled “7.2 Voiding (/del{id})”Other flows (income, transfer, /today, /month, /expenses) follow the same shape and will be documented in the SRS.
8. Ledger Storage (PostgreSQL adapter)
Section titled “8. Ledger Storage (PostgreSQL adapter)”Two schemas: app.* for users, accounts, categories, budgets, audit_log; ledger.* for the ledger.
8.1 Key SQL choices
Section titled “8.1 Key SQL choices”id UUID DEFAULT uuid_generate_v7()everywhere — time-ordered, b-tree-friendly, TB-compatible.amount_minor BIGINTfor MVP-1 — sufficient for personal finance (max ~9.2 × 10¹⁸). At TigerBeetle migration, lossless conversion to TB’su128.BEFORE INSERTtrigger onledger.transferenforces all cross-row invariants and updatesaccount.balance_minoratomically. Defense-in-depth: invariants hold even if a non-Python actor inserts directly. ADR-0008.- Lock-order in trigger:
SELECT ... WHERE id IN (debit, credit) ORDER BY id FOR UPDATE— deterministic, deadlock-safe under concurrent transfers. metadatacolumns (category_id,kind,raw_text) are stored in the ledger row but not interpreted by the ledger adapter — they are pass-through fromLedgerPort.metadata. Read repo queries them; write path treats them as opaque.
8.2 Migration considerations (PG → TB, future)
Section titled “8.2 Migration considerations (PG → TB, future)”| Concept | Postgres now | TigerBeetle later |
|---|---|---|
transfer.id (UUIDv7) | UUID column | Transfer.id: u128 — UUID bytes map directly |
account.id (UUIDv7) | UUID column | Account.id: u128 |
amount_minor | BIGINT | Transfer.amount: u128 (lossless) |
currency | TEXT ISO-4217 alpha | Transfer.ledger: u32 ISO-4217 numeric — ADR-0010 |
| atomicity | PG transaction | TB native (per-batch) |
| balance | account.balance_minor cache | debits_pending/posted + credits_pending/posted — derived |
| metadata text | raw_text TEXT column | sidecar PG transfer_metadata table keyed by transfer id |
| void | new compensating transfer (this design) | same — TB officially recommends this (recipes/correcting-transfers.md) |
| account history per-transfer | not needed (we have full SQL) | requires flags.history=true set at account creation; cannot be enabled retroactively |
Risks accepted:
- TB asset scale is permanent. We commit to scale 2 (minor units = kopecks/cents). Microtransactions or crypto would require a new ledger.
- TB is pre-1.0. The Python client (
tigerbeetle 0.17.2) is marked Production/Stable on PyPI but documented API changes still occur. Pin client version; re-readapi-changes.mdon each upgrade.
9. Error Handling & Observability
Section titled “9. Error Handling & Observability”9.1 Error taxonomy
Section titled “9.1 Error taxonomy”| Level | Examples | User reply | Alert? |
|---|---|---|---|
| Domain (expected) | ERR_PARSE_001, ERR_VAL_*, ERR_LDG_001..004 | Friendly + suggestion | No (rate-limited per user) |
| Application (rare) | ERR_AUTH_001 | ”Access Denied” | No |
| Infrastructure | ERR_INFRA_DB, ERR_INFRA_TG | ”Тимчасова помилка, спробуй за хвилину” | Yes (Grafana) |
Error codes are stable identifiers, mapped 1:1 to FRs in the future SRS (AC → ERR-code → handler). Corporate-template Error Handling table will be populated from this.
9.2 Idempotency layers
Section titled “9.2 Idempotency layers”- Telegram update_id → row in
app.processed_update(update_id PK)viaINSERT ON CONFLICT DO NOTHINGin the access middleware, before any business logic. Duplicate redeliveries from Telegram are silently dropped. Cost: one cheap unique-key insert per update. - Transfer ID (UUIDv7) — generated by the use-case (
record_expense,record_transfer, …) and passed intoLedgerPort.post_transfer. UNIQUE constraint onledger.transfer.id. If the use-case is retried before commit (transient DB error), the same UUID is reused and the second insert is a no-op; the adapter returns the existing row. - Void idempotency — implemented via the
voided_by_idFK on the original transfer, not via a deterministic compensating ID. Thevoidadapter takes a row-lock on the original (SELECT ... FOR UPDATE); ifvoided_by_id IS NOT NULL, it returns the existing compensating transfer; otherwise it inserts a new one and updates the original. This makes a duplicate/dela fast read-only path and avoids the UUIDv7-vs-deterministic-UUID tension.
9.3 Logging
Section titled “9.3 Logging”structlogJSON in production.- Every record:
request_id,user_id,update_id,level,event. - PII-by-default-masked at INFO: amounts, raw_text, category names, account names are NOT logged. ADR-0011.
LOG_LEVEL=DEBUGenv flag enables verbose mode for local debugging. Never set in production.request_idpropagates via aiogram middleware → asynciocontextvars→ all log lines.
9.4 Metrics
Section titled “9.4 Metrics”Prometheus, scraped by local Grafana. Key series:
bot_messages_received_total{kind}— throughput per use-case.bot_message_processing_seconds{kind, outcome}— latency P50/P95/P99.ledger_transfer_duration_seconds{outcome}— ledger-specific.ledger_invariant_violations_total{invariant}— should be ~0; paging alert if >0 in 5m (means a domain-layer bug).db_pool_connections_in_use— saturation.bot_telegram_api_errors_total{method, error_code}.
9.5 Alerts
Section titled “9.5 Alerts”ledger_invariant_violations_total > 0(5m rate) → page.- Bot stopped receiving updates >5 min → page.
bot_message_processing_seconds{outcome="infra_error"}> 1% → warn.
9.6 Resilience
Section titled “9.6 Resilience”- aiogram has built-in retry on Telegram API network errors.
- DB errors: fail fast, do not retry. Telegram redelivers the update.
asyncpgquery timeout = 5 s; aiogram API timeout = 30 s; handler timeout = 60 s.- Graceful shutdown on SIGTERM: finish current update, do not accept new, exit.
9.7 Security baseline
Section titled “9.7 Security baseline”- Secrets via env (
pydantic-settings);.envin.gitignore. - Whitelist via
WHITELIST_TELEGRAM_IDSenv var (comma-separated). - Dedicated PG user with grants only on
app.*andledger.*, no SUPERUSER. - TLS for DB connection (
sslmode=require) even in Docker network. - All SQL parameterized (asyncpg).
- No
eval/exec/ dynamic SQL on user input. app.audit_logtable — append-only; auth events and voids logged.
10. Testing Strategy
Section titled “10. Testing Strategy”Test pyramid: ~75% unit, ~20% integration, ~5% e2e. Approximate counts: 120 / 30 / 10.
| Layer | Scope | Tools |
|---|---|---|
| Unit | domain/, application/, parsers; fakes for ports; 100% required for domain & ports | pytest, hypothesis (property-based for parsers and ledger invariants) |
| Integration | Adapters vs real Postgres; trigger invariants; Alembic up/down | pytest, testcontainers-postgres |
| E2E | Golden-path flows: add expense / transfer / void / whitelist denial / /today, /month | aiogram test utilities + testcontainers |
Property-based ledger invariant test — the linchpin for approach β:
@given(transfers=st.lists(transfer_strategy(), min_size=1, max_size=100))async def test_balance_invariant(transfers, ledger_adapter): for t in transfers: try: await ledger_adapter.post_transfer(**t) except DomainError: pass # invalid generated transfers raise; that's fine for account_id in collected_account_ids(transfers): cached = await ledger_adapter.get_balance(account_id) computed = await sum_transfers_for_account(account_id) assert cached == computedThis proves the cache account.balance_minor never drifts from SUM(credit) - SUM(debit). It is the single most valuable test in the codebase and the one most clearly attributable to the chosen architecture.
Out of scope: load tests (2 users), mutation testing, snapshot tests for replies.
10.1 CI (GitHub Actions)
Section titled “10.1 CI (GitHub Actions)”Jobs on every PR: lint, typecheck, unit, integration, migrations, security (pip-audit). E2E runs on PR-to-main only.
On merge to main: build image → push to ghcr.io → SSH-deploy on VPS (rolling docker-compose up). Auto-deploy is acceptable risk for a personal bot; tighten if MVP-2 widens the audience.
11. ADR Inventory
Section titled “11. ADR Inventory”| # | Title | Status |
|---|---|---|
| ADR-0001 | Defer TigerBeetle, start with Postgres + LedgerPort abstraction | Accepted |
| ADR-0002 | PostgreSQL as system-of-record | Accepted |
| ADR-0003 | Migration to aiogram 3.x; original repo as UX reference only | Accepted |
| ADR-0004 | Modular monolith over microservices for MVP-1 | Accepted |
| ADR-0005 | Money: BIGINT minor units + ISO-4217 alpha currency | Accepted |
| ADR-0006 | Hexagonal architecture (Ports & Adapters) | Accepted |
| ADR-0007 | Default account with optional override in expense parser | Accepted |
| ADR-0008 | Database-level invariants for ledger (triggers + CHECK) | Accepted |
| ADR-0009 | UUIDv7 for ledger IDs (TigerBeetle compatibility) | Accepted |
| ADR-0010 | ISO-4217 numeric mapped to TB ledger field at swap-time | Accepted |
| ADR-0011 | PII masking in logs by default; opt-in DEBUG verbose | Accepted |
| ADR-0012 | Long-polling over webhook for MVP-1 | Accepted |
| ADR-0013 | Self-hosted single VPS (docker-compose) for MVP-1 | Accepted |
| ADR-NN-future | Adopt TigerBeetle for ledger (trigger TBD) | — |
| ADR-NN-future | Multi-currency support with FX | — |
| ADR-NN-future | Webhook-based update delivery | — |
| ADR-NN-future | Self-onboarding (remove whitelist) | — |
Each ADR will be a separate file under docs/adr/NNNN-title.md using the project’s lean ADR template.
12. Roadmap
Section titled “12. Roadmap”| Phase | Scope addition | Trigger to start |
|---|---|---|
| MVP-1 | Tier-2 features for whitelist users (this design) | now |
| MVP-2 | Self-onboarding, recurring transactions, basic multi-tenant isolation hardening | when ≥3 external users want in |
| MVP-3 | Multi-currency + FX, savings goals, CSV import/export, TigerBeetle adoption | when ledger-write throughput or domain complexity justifies it (NOT a calendar deadline) |
| v1 | Paid tier, advanced reports, web companion | later |
The TigerBeetle trigger is intentionally vague (“when justified”) — premature commitment to a date undermines the ADR-0001 narrative (“evidence-based, not speculative”).
13. Open Questions
Section titled “13. Open Questions”| ID | Question | Resolves before |
|---|---|---|
| OQ-01 | Initial seed of system-default categories — copy from original repo (Russian) or rewrite in Ukrainian? | First migration |
| OQ-02 | Default account naming convention when a user has only one — auto-name “Card” or prompt? | First user-onboarding flow in MVP-1 (manually scripted at first) |
| OQ-03 | min_balance_minor per account — default NULL (overdraft allowed) for MVP-1; revisit when MVP-2 onboarding ships | MVP-2 |
Appendix — Document Status & Audience
Section titled “Appendix — Document Status & Audience”This is a compact design document, not a full SRS. It captures decisions and provides enough context for the implementation plan (writing-plans skill is the next step).
The full SRS — using the project’s hybrid template (Corporate base minus BA/MAG/Postman, plus Personas/Roadmap from the Lean variant) — is produced in a separate phase via the sa-analyst skill, with one document per major feature module. ADRs are produced as individual files in docs/adr/. All artifacts are mirrored to the personal Obsidian vault Sliptonite and exported to the author’s portfolio site; the Corporate work vault is never touched.
Source code remains private. Documentation is the public artifact.