Перейти до вмісту

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.


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. Correctness — ledger invariants (balance = sum of transfers, atomic debit+credit, no orphan rows) hold always, enforced at the database level.
  2. Production-readiness for two real users within ~4 weeks of part-time work.
  3. Portfolio quality — every non-trivial decision is documented as an ADR with explicit alternatives and trade-offs.
  4. Future-proofing — clean port boundary so the ledger backend can be swapped to TigerBeetle later without touching domain or application code.
  • 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).

FeatureIn 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 seedAlias-based matching
Per-category budgets (monthly limit)Read-only display in /today, /month
/today, /month statisticsSum, by category, base vs total
/expenses last-N list, /del{id} voidingVoiding via compensating transfer (ledger pattern)
Multi-currency / FXTier-3 / MVP-3
Recurring transactionsTier-3
Goals / savingsTier-3
CSV import / bank statement parsingTier-3
Self-onboarding, multi-tenant SaaSMVP-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.


LayerChoiceWhy
LanguagePython 3.12Continuity with original; ecosystem fit
Bot frameworkaiogram 3.xAsync-first; major rewrite from 2.4; actively maintained — ADR-0003
System DBPostgreSQL 16Mature, ACID, migrations — ADR-0002
LedgerPostgreSQL 16 (same instance, separate schema)Defer TigerBeetle until fact-based trigger — ADR-0001
DB driverasyncpgNative async, fast
Schema migrationsAlembicStandard for SQLAlchemy ecosystem
Entity primary keysUUIDv7TB-compatible, time-ordered, b-tree-friendly — ADR-0009
Package manageruvFast, single binary, modern
Lint / formatruffReplaces flake8 + isort + black
Type checkermypy —strict (domain/application)Bulletproof core — see §6
Test runnerpytest + pytest-asyncio + testcontainersIndustry standard
Loggingstructlog (JSON in prod)PII-aware redaction baked in
MetricsPrometheus clientPull-based, simple
DashboardsGrafana (local on VPS)Single-binary deploy
Configpydantic-settingsTyped env vars
HostingSingle VPS, docker-composeADR-0013
Update deliveryTelegram long-pollingNAT-friendly, no TLS cert — ADR-0012
CIGitHub ActionsFree for public/personal
AuthTelegram-id whitelist (env var)MVP-1 only; replaced by self-onboarding in MVP-2

PlantUML Diagram

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.


  • 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.
  • Category — metadata (for reports + budgets), not a ledger concept. user_id IS NULL ⇒ system seed.
  • Transaction — one operation = one row in ledger.transfer. Carries debit_account_id, credit_account_id, amount_minor, currency, plus metadata columns.
  • Budget — per-category monthly/weekly limit.
PlantUML Diagram
  • 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_minor is 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.id itself, UUIDv7) is UNIQUE — protects against Telegram update redelivery and retry storms.
PlantUML Diagram

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__.py
PlantUML Diagram

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).

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/.
  • metadata is opaque pass-through (PG stores in typed columns it does not interpret; TB will store in user_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).


PlantUML Diagram

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.

PlantUML Diagram

Other flows (income, transfer, /today, /month, /expenses) follow the same shape and will be documented in the SRS.


Two schemas: app.* for users, accounts, categories, budgets, audit_log; ledger.* for the ledger.

  • id UUID DEFAULT uuid_generate_v7() everywhere — time-ordered, b-tree-friendly, TB-compatible.
  • amount_minor BIGINT for MVP-1 — sufficient for personal finance (max ~9.2 × 10¹⁸). At TigerBeetle migration, lossless conversion to TB’s u128.
  • BEFORE INSERT trigger on ledger.transfer enforces all cross-row invariants and updates account.balance_minor atomically. 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.
  • metadata columns (category_id, kind, raw_text) are stored in the ledger row but not interpreted by the ledger adapter — they are pass-through from LedgerPort.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)”
ConceptPostgres nowTigerBeetle later
transfer.id (UUIDv7)UUID columnTransfer.id: u128 — UUID bytes map directly
account.id (UUIDv7)UUID columnAccount.id: u128
amount_minorBIGINTTransfer.amount: u128 (lossless)
currencyTEXT ISO-4217 alphaTransfer.ledger: u32 ISO-4217 numeric — ADR-0010
atomicityPG transactionTB native (per-batch)
balanceaccount.balance_minor cachedebits_pending/posted + credits_pending/posted — derived
metadata textraw_text TEXT columnsidecar PG transfer_metadata table keyed by transfer id
voidnew compensating transfer (this design)same — TB officially recommends this (recipes/correcting-transfers.md)
account history per-transfernot 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-read api-changes.md on each upgrade.

LevelExamplesUser replyAlert?
Domain (expected)ERR_PARSE_001, ERR_VAL_*, ERR_LDG_001..004Friendly + suggestionNo (rate-limited per user)
Application (rare)ERR_AUTH_001”Access Denied”No
InfrastructureERR_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.

  1. Telegram update_id → row in app.processed_update(update_id PK) via INSERT ON CONFLICT DO NOTHING in the access middleware, before any business logic. Duplicate redeliveries from Telegram are silently dropped. Cost: one cheap unique-key insert per update.
  2. Transfer ID (UUIDv7) — generated by the use-case (record_expense, record_transfer, …) and passed into LedgerPort.post_transfer. UNIQUE constraint on ledger.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.
  3. Void idempotency — implemented via the voided_by_id FK on the original transfer, not via a deterministic compensating ID. The void adapter takes a row-lock on the original (SELECT ... FOR UPDATE); if voided_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 /del a fast read-only path and avoids the UUIDv7-vs-deterministic-UUID tension.
  • structlog JSON 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=DEBUG env flag enables verbose mode for local debugging. Never set in production.
  • request_id propagates via aiogram middleware → asyncio contextvars → all log lines.

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}.
  • ledger_invariant_violations_total > 0 (5m rate) → page.
  • Bot stopped receiving updates >5 min → page.
  • bot_message_processing_seconds{outcome="infra_error"} > 1% → warn.
  • aiogram has built-in retry on Telegram API network errors.
  • DB errors: fail fast, do not retry. Telegram redelivers the update.
  • asyncpg query timeout = 5 s; aiogram API timeout = 30 s; handler timeout = 60 s.
  • Graceful shutdown on SIGTERM: finish current update, do not accept new, exit.
  • Secrets via env (pydantic-settings); .env in .gitignore.
  • Whitelist via WHITELIST_TELEGRAM_IDS env var (comma-separated).
  • Dedicated PG user with grants only on app.* and ledger.*, 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_log table — append-only; auth events and voids logged.

Test pyramid: ~75% unit, ~20% integration, ~5% e2e. Approximate counts: 120 / 30 / 10.

LayerScopeTools
Unitdomain/, application/, parsers; fakes for ports; 100% required for domain & portspytest, hypothesis (property-based for parsers and ledger invariants)
IntegrationAdapters vs real Postgres; trigger invariants; Alembic up/downpytest, testcontainers-postgres
E2EGolden-path flows: add expense / transfer / void / whitelist denial / /today, /monthaiogram 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 == computed

This 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.

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.


#TitleStatus
ADR-0001Defer TigerBeetle, start with Postgres + LedgerPort abstractionAccepted
ADR-0002PostgreSQL as system-of-recordAccepted
ADR-0003Migration to aiogram 3.x; original repo as UX reference onlyAccepted
ADR-0004Modular monolith over microservices for MVP-1Accepted
ADR-0005Money: BIGINT minor units + ISO-4217 alpha currencyAccepted
ADR-0006Hexagonal architecture (Ports & Adapters)Accepted
ADR-0007Default account with optional override in expense parserAccepted
ADR-0008Database-level invariants for ledger (triggers + CHECK)Accepted
ADR-0009UUIDv7 for ledger IDs (TigerBeetle compatibility)Accepted
ADR-0010ISO-4217 numeric mapped to TB ledger field at swap-timeAccepted
ADR-0011PII masking in logs by default; opt-in DEBUG verboseAccepted
ADR-0012Long-polling over webhook for MVP-1Accepted
ADR-0013Self-hosted single VPS (docker-compose) for MVP-1Accepted
ADR-NN-futureAdopt TigerBeetle for ledger (trigger TBD)
ADR-NN-futureMulti-currency support with FX
ADR-NN-futureWebhook-based update delivery
ADR-NN-futureSelf-onboarding (remove whitelist)

Each ADR will be a separate file under docs/adr/NNNN-title.md using the project’s lean ADR template.


PhaseScope additionTrigger to start
MVP-1Tier-2 features for whitelist users (this design)now
MVP-2Self-onboarding, recurring transactions, basic multi-tenant isolation hardeningwhen ≥3 external users want in
MVP-3Multi-currency + FX, savings goals, CSV import/export, TigerBeetle adoptionwhen ledger-write throughput or domain complexity justifies it (NOT a calendar deadline)
v1Paid tier, advanced reports, web companionlater

The TigerBeetle trigger is intentionally vague (“when justified”) — premature commitment to a date undermines the ADR-0001 narrative (“evidence-based, not speculative”).


IDQuestionResolves before
OQ-01Initial seed of system-default categories — copy from original repo (Russian) or rewrite in Ukrainian?First migration
OQ-02Default 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-03min_balance_minor per account — default NULL (overdraft allowed) for MVP-1; revisit when MVP-2 onboarding shipsMVP-2

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.