Skip to content

SRS — Telegram Finance Bot

Reverse-engineered from existing code, design doc, and 22 ADRs. This SRS is the portfolio artifact for the System Analyst public site. The codebase already ships Plans 1–6 and is in production for two whitelisted users; Plan 7 (Observability & Audit) is in flight. Section confidence levels are reported at the bottom of the document — see Section Confidence.


ДатаРішенняКонтекст / альтернативиХто прийняв
2026-04-27Defer TigerBeetle, start with PostgreSQL ledger behind LedgerPortTB has steeper ops complexity for 2 users; revisit when write-throughput justifies itADR-0001
2026-04-27PostgreSQL as system of record (users + ledger)One DB, one operator; ledger schema separate (ledger.* vs app.*)ADR-0002
2026-04-28Migrate to aiogram 3.x; original Russian repo as UX reference onlyaiogram 2.4 obsolete; UX (250 кафе syntax) preservedADR-0003
2026-04-28Modular monolith over microservices for MVP-1<100 msg/day, single VPS, single operator[[projects/telegram-finance-bot/decisions/0004-modular-monolithADR-0004]]
2026-04-28Hexagonal architecture (Ports & Adapters)Required to make TB swap mechanical[[projects/telegram-finance-bot/decisions/0006-hexagonal-architectureADR-0006]]
2026-05-02Money = BIGINT minor units + ISO-4217 alpha currencyLossless arithmetic, TB-compatible (u128 widening)[[projects/telegram-finance-bot/decisions/0005-money-representationADR-0005]]
2026-05-02Default-with-override account policy in expense parserFirst non-amount token = category, second = accountADR-0007
2026-04-27Database-level ledger invariants (CHECK + triggers)Defense-in-depth: adapter primary, DB safety netADR-0008
2026-04-27UUIDv7 for ledger IDsTime-ordered, b-tree-friendly, TB u128-compatibleADR-0009
2026-05-02PII masking in logs by default; LOG_LEVEL=DEBUG opt-in onlyProduction logs never expose amounts/raw_text[[projects/telegram-finance-bot/decisions/0011-pii-masking-in-logsADR-0011]]
2026-04-27Long-polling over webhook for MVP-1NAT-friendly, no TLS cert needed; revisit if scale[[projects/telegram-finance-bot/decisions/0012-long-polling-over-webhookADR-0012]]
2026-04-27Self-hosted single VPS (docker-compose) for MVP-1Cheapest path; no PaaS lock-in[[projects/telegram-finance-bot/decisions/0013-single-vps-docker-composeADR-0013]]
2026-05-02Ledger schema: 4 tables + DEFERRABLE constraint trigger + BEFORE-UPDATE min-balance triggerBalanced double-entry checked at COMMITADR-0014
2026-05-02Raw asyncpg for ledger hot path (SQLAlchemy reserved for repos)Hot path needs FOR UPDATE ordering; ORM mapping noise unhelpfulADR-0015
2026-05-02Void via compensating entries; ledger.entry immutableMatches TB recipe; full audit trailADR-0016
2026-05-02SQLAlchemy async session lifecycle: session-per-use-caseOne unit-of-work boundary per use-caseADR-0017
2026-05-02Domain ↔ ORM boundary mapping in repo adaptersDomain types stay pure; ORM lives only in adapterADR-0018
2026-05-02Cross-port best-effort idempotencyTelegram update_id dedup + transfer_id retry-safetyADR-0019
2026-05-02Use-case shape: stateless coroutine + DI of portsOne file per use-case, no class ceremonyADR-0020
2026-05-03Telegram handler shape: aiogram Router + AsyncMock-friendly DIHandlers thin; logic in use-casesADR-0021
2026-05-03Telegram update idempotency: app.processed_update(update_id PK) in middlewareDrops duplicates before parsingADR-0022

АртефактПосилання
Design Doc (compact)docs/design/2026-04-27-mvp1-architecture.md
README (quickstart + smoke tests)README.md
ADRs (22)docs/adr/0001-defer-tigerbeetle.mddocs/adr/0022-telegram-update-idempotency.md
Implementation Plans (7)docs/plans/2026-04-28-plan-1-bootstrap.mddocs/plans/2026-05-04-plan-7-observability-audit.md
Source codesrc/finance_bot/{domain,ports,application,adapters} (private)
Schema migrationsmigrations/versions/0001..0004_*.py (Alembic)
Teststests/{unit,integration}/ — ~120 unit + ~30 integration

The reverse-engineered scope matches the design doc: no technical refactor or hidden modernization is layered on top. The only “delta” relative to the original Russian-language tutorial repo (alexey-goloburdin/telegram-finance-bot) is full rewrite under MVP-1 architecture — the original is a UX reference only (ADR-0003).


A Telegram bot that records personal finance transactions (expenses, income, inter-account transfers) into a strictly double-entry PostgreSQL ledger and serves simple time-bound aggregations (/today, /month). The system enforces ledger invariants at the database level so balances are always derivable from the transfer log — accountancy correctness is the single most important non-functional property.

The architecture is intentionally over-specified for MVP-1’s load profile (<100 msg/day, 2 users) so that:

  1. The portfolio narrative (“strict double-entry from day one”) is credible.
  2. The ledger backend can be swapped to TigerBeetle later without touching domain/, ports/, or application/ (ADR-0001 + ADR-0006).

IDОбмеженняДжерело
C-01Stack: Python 3.12, aiogram 3.xADR-0003 (aiogram), self-imposed (Python continuity with original)
C-02Datastore: PostgreSQL 16 (one instance, schemas app + ledger)ADR-0002
C-03Hosting: single VPS, docker-compose; no PaaS, no Kubernetes[[projects/telegram-finance-bot/decisions/0013-single-vps-docker-composeADR-0013]]
C-04Update delivery: Telegram long-polling (no webhook for MVP-1)[[projects/telegram-finance-bot/decisions/0012-long-polling-over-webhookADR-0012]]
C-05Authentication: Telegram-id whitelist via env var (no self-onboarding)self-imposed; replaced in MVP-2
C-06Money type: BIGINT minor units + ISO-4217 alpha currency[[projects/telegram-finance-bot/decisions/0005-money-representationADR-0005]]
C-07UUIDv7 for ledger entity IDsADR-0009 (TB compatibility)
C-08Single hryvnia ledger (UAH) — multi-currency deferred to Tier-3scope decision
C-09Domain & ports modules: mypy --strict mandatory[[projects/telegram-finance-bot/decisions/0006-hexagonal-architectureADR-0006]] (architectural integrity)

IDПрипущення
A-01The whitelist (WHITELIST_TELEGRAM_IDS env var, comma-separated) is the authoritative ACL — any change requires a redeploy.
A-02Telegram delivers each user message as exactly one Update (no message edits trigger ledger writes — edits are ignored).
A-03The two users trust each other’s data — there is no per-user ACL beyond user_id filtering at the repo layer.
A-04Server clock is reasonably accurate (NTP-synced); occurred_at uses server now(). Misordered transfers from clock drift are an accepted risk at MVP-1 scale.
A-05Telegram redelivers updates idempotently keyed by update_id (verified empirically; documented in ADR-0022).
A-06Migration order is forward-only (Alembic upgrade head); rollback is via PG dump/restore, not alembic downgrade.

IDПитанняOwnerБлокуєСтатус
OQ-01Initial seed of system-default categories — copy original Russian set or rewrite UA?authorfirst migrationClosed — UA seed of 8 categories shipped in 0004_seed_default_categories.py
OQ-02Default-account naming when user has only one — auto-name “Картка” or prompt?authorfirst onboardingClosed — manual scripted onboarding; auto-naming deferred to MVP-2
OQ-03min_balance_minor per account — default NULL (overdraft allowed) or 0?authoraccount creationClosedNULL for MVP-1
OQ-04Reconciliation cadence — nightly cron or on-demand?authorPlan 7🟡 Open — design doc §5.3 mentions nightly; not yet scheduled
OQ-05Multi-currency activation criterion (Tier-3 trigger)authorTigerBeetle ADR🟡 Open — open-ended trigger by design
IDРизикВпливMitigation
R-01Cached account_balance.balance_minor could drift from SUM(D)-SUM(C) if a future code path bypasses the adapterMedium — silent corruptionProperty-based test (test_balance_invariant) + nightly reconciliation job + DB-level constraint trigger (ADR-0008)
R-02Long-polling stops without crashing (silent stall)High — no transactions recordedPrometheus alert: “no bot_messages_received_total increment in 5m” → page; design doc §9.5
R-03TigerBeetle tigerbeetle Python client is pre-1.0 (API still evolves)Low MVP-1 / High MVP-3Pin version; re-read api-changes.md per upgrade; deferred per ADR-0001
R-04Whitelist env-var ACL bypass via misconfiguration (empty string allows all)High — data leak to non-usersMiddleware test asserts empty whitelist denies all; CI guards against the regression
R-05update_id collision across bot reinstallations (token rotation)Low — minor duplication on first day after rotationapp.processed_update is keyed (bot_token_hash, update_id) (planned for Plan 7); MVP-1 accepts the small window

SystemTypeDirectionRoleКонтракт готовий?
Telegram Bot APIHTTPS REST (aiogram client)in/outInbound updates (long-poll); outbound replies✅ — public, version-locked via aiogram
PostgreSQL 16TCP / asyncpgoutSystem of record + ledger✅ — schema in migrations/versions/
PrometheusHTTP (scrape)inMetrics scrape on :9090✅ — scrape config in infra/prometheus.yml
GrafanaHTTP UI— (operator-facing)Dashboards over Prometheus✅ — provisioned via docker-compose

C4 Context Diagram — generated by plantuml-master:

c4-context

System Context (C4 Level 1) — shows the deployed bot, the two whitelisted users, and the external Telegram Bot API on a single VPS running a docker-compose stack.


PlantUML Diagram

  • SRS section: SRS — Telegram Finance Bot §🔗 Integrations & Context Diagram
  • ADRs: ADR-0004 (modular monolith), ADR-0012 (long-polling), ADR-0013 (single VPS)

The bot exposes two flow shapes: free-text transactions (expense/income) and slash-commands (transfers, queries, voids). Both share a common ingress through the access middleware.

Phase 1 — Update ingestion (every flow):

  1. Telegram Bot API → bot: getUpdates long-poll returns one Update.
  2. Bot dispatcher → AccessMiddleware: check update.from_user.id ∈ whitelist.
  3. AccessMiddleware → PostgreSQL: INSERT INTO app.processed_update (update_id) ON CONFLICT DO NOTHING.
  4. If 0 rows affected → silently drop (duplicate Telegram redelivery).
  5. Else inject request_id, user_id into handler context and continue.

Phase 2A — Free-text transaction (250 кафе, +5000 зарплата):

  1. FreeTextHandlerMessageParser: parse(text)ParsedExpense | ParsedIncome | ParseError.
  2. Parser fails → reply "Не зрозумів. Приклад: 250 кафе" (ERR_PARSE_001).
  3. Parser succeeds → invoke record_expense / record_income use-case with a fresh UUIDv7 transfer_id.
  4. Use-case resolves category alias via CategoryRepo.resolve_alias; ERR_VAL_001 on unknown.
  5. Use-case resolves account: alias if provided (ADR-0007), else default; ERR_VAL_002 on unresolvable.
  6. Use-case calls LedgerPort.post_transfer(transfer_id, debit, credit, amount, metadata).
  7. Postgres adapter takes FOR UPDATE row-lock on (debit_account_id, credit_account_id) ordered ascending.
  8. Adapter inserts ledger.transfer + two ledger.entry rows; trigger asserts balanced at COMMIT.
  9. Trigger updates account_balance.balance_minor; BEFORE UPDATE trigger fires MinBalanceViolationError if new balance < min_balance_minor.
  10. Use-case returns PostedTransfer; handler renders Ukrainian receipt.

Phase 2B — Transfer command (/transfer 1500 card->cash):

  1. TransferCommandHandler parses the slash-command text (regex on <amount> <alias>-><alias>).
  2. Currency-match required: debit_account.currency == credit_account.currency (ADR-0014); else ERR_LDG_003.
  3. Same LedgerPort.post_transfer path as Phase 2A; both accounts are real (no external_*).

Phase 2C — Void (/del{id}):

  1. VoidCommandHandler extracts the short id (first 8 chars of UUIDv7).
  2. void_transaction use-case calls LedgerPort.void(transfer_id, compensating_id, reason).
  3. Adapter SELECT ... FOR UPDATE on the original transfer; if not found → ERR_LDG_005.
  4. If voided_by_id IS NOT NULL → return existing compensating PostedTransfer (idempotent).
  5. Else insert reverse-direction transfer (D↔C swapped), set voided_by_id on original, COMMIT.

Phase 2D — Stats (/today, /month, /expenses):

  1. Read-only query through TransactionReadRepo (CQRS-lite).
  2. Aggregations: SUM by direction, SUM by category, recent-N list.
  3. Renderer formats reply as Markdown-V2-safe Ukrainian text.

See projects/telegram-finance-bot/diagrams/sequence-record-expense and projects/telegram-finance-bot/diagrams/sequence-void (rendered in §⚙️ Technical Behavior > 🔀 Sequence Diagram below).


TriggerTypeInvokesNotes
/startcommandonboarding handlerWhitelist check; greets + /help hint
/helpcommandhelp handlerLists all commands
<amount> <category> [<account>] (e.g. 250 кафе, 200 кафе картка)free textrecord_expenseDefault-with-override account (ADR-0007)
+<amount> <category> [<account>] (e.g. +5000 зарплата)free textrecord_incomeSame parser, leading +
/transfer <amount> <from>-><to>commandrecord_transferBoth accounts real; currency-match required
/del<short_id> (e.g. /dela3f1)commandvoid_transactionCompensating-entry pattern
/todaycommandget_today_statsTotal + by-category for today
/monthcommandget_month_statsTotal + by-category for current month
/expensescommand(read repo, last 10)Returns recent N with /del{id} hints
/catscommandlist_categoriesLists 8 system + any user-defined
/addcat <codename>=<aliases>commandadd_categoryUser-defined category
/setdefault <alias>commandset_default_accountMark an account as default

LedgerPort contract (internal — TB-stable boundary)

Section titled “LedgerPort contract (internal — TB-stable boundary)”

The most important “API” in the system is the internal port that ADR-0001 commits to keeping stable across PostgreSQL → TigerBeetle. Reproduced verbatim from src/finance_bot/ports/ledger.py:

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, metadata: TransferMetadata,
) -> PostedTransfer: ...
async def void(
self, *, transfer_id: UUID, compensating_id: UUID, reason: str,
) -> PostedTransfer: ...
async def get_balance(self, account_id: UUID) -> Money: ...

Idempotency contract: post_transfer is idempotent on transfer_id; void is idempotent on the original transfer_id (returns existing compensation if already voided). open_account is idempotent on account_id.


Стани та переходи (transaction lifecycle)

Section titled “Стани та переходи (transaction lifecycle)”

A transaction (one user message → one ledger.transfer row) traverses a short state machine:

StateTrigger to enterAllowed next
Parsedbot received text matching parserValidated, Rejected
Validatedparser ok; refs (account, category) resolvedPosted, Rejected
Postedledger insert + balance update committedVoided (terminal otherwise)
Rejectedparse error or invariant violation pre-committerminal
Voideduser typed /del{id}; compensating transfer postedterminal

No confirmation step — auto-post on validation. Voiding is soft via a compensating transfer (debit↔credit reversed), kind='compensation', compensates_id linked to the original. The original row is never mutated except for the voided_by_id link (ADR-0016).

state-transaction-lifecycle

One user message → one ledger transfer → one path through this state machine. Soft voiding (compensating transfer) is terminal: the original row stays as-is with voided_by_transfer_id set; balances are restored by the compensating row’s own entries.



  • SRS section: SRS — Telegram Finance Bot §⚙️ Technical Behavior (Стани та переходи), §FR-PFIN-06
  • ADRs: ADR-0008 (DB-level invariants), ADR-0014 (ledger schema), ADR-0016 (void via compensating entries)

Обробка зовнішніх викликів

Section titled “Обробка зовнішніх викликів”
  • Telegram Bot API — aiogram retries on network errors (built-in). On 4xx from Bot API → log and surface as ERR_INFRA_TG. On 5xx → log; aiogram retries with backoff.
  • PostgreSQLasyncpg query timeout = 5s. On error → fail fast; do not retry inside the handler. Telegram will redeliver the update on its own schedule, and the update_id dedup at ingress prevents duplicate ledger writes (ADR-0022).

Idempotency layers (cross-port best-effort, ADR-0019)

Section titled “Idempotency layers (cross-port best-effort, ADR-0019)”
  1. Telegram update_idapp.processed_update(update_id PK) insert with ON CONFLICT DO NOTHING in AccessMiddleware. Cost: one cheap PK insert per update. Drops duplicate Telegram redeliveries before parser.
  2. Transfer ID (UUIDv7) — generated by the use-case. UNIQUE PK on ledger.transfer.id. Retry of the same use-case reuses the same id; second insert is a no-op; the adapter returns the existing PostedTransfer.
  3. Void idempotency — via voided_by_id FK on the original. The adapter’s SELECT ... FOR UPDATE short-circuits to the existing compensating transfer if voided_by_id IS NOT NULL.
  • Two concurrent transfers touching disjoint account pairs do not contend.
  • Two concurrent transfers touching overlapping accounts contend on the ascending-ordered FOR UPDATE row-lock; deadlock-free by ADR-0014 ordering rule.
  • The account_balance cache is updated inside the same transaction as the entry insert; reading and writing are linearizable per account.

sequence-record-expense

Happy-path + access-denied + parser-fail branches for the most common flow: user types 250 кафе, the bot posts a transfer default_account → external_expense. Demonstrates the update_id dedup at the middleware layer and the UUIDv7 transfer-id idempotency at the ledger layer.


PlantUML Diagram

  • SRS section: SRS — Telegram Finance Bot §🔄 High-Level Flow (Phase 2A) and §⚙️ Technical Behavior
  • ADRs: ADR-0007 (default-with-override), ADR-0014 (lock ordering), ADR-0019 (cross-port idempotency), ADR-0022 (update_id dedup)

sequence-void

The void flow demonstrates idempotency via the voided_by_id FK and the compensating-entry pattern from ADR-0016. A duplicate /del call returns the existing compensation without inserting another row.


PlantUML Diagram

  • SRS section: SRS — Telegram Finance Bot §FR-PFIN-06 — Void transaction, §⚙️ Technical Behavior (idempotency layers)
  • ADRs: ADR-0016 (void via compensating entries), ADR-0019 (cross-port idempotency)

Reverse-engineered from the 11 application use-cases plus middleware and read repos. AC IDs use the PFIN prefix.

  • AC-FR-PFIN-01-1Given a user whose telegram_id is in WHITELIST_TELEGRAM_IDS When they send any message Then the system MUST process the message normally.
  • AC-FR-PFIN-01-2Given a user whose telegram_id is NOT in the whitelist When they send any message Then the system MUST reply "Доступ заборонено." and MUST NOT write any row to app.* or ledger.*.
  • AC-FR-PFIN-01-3Given the env var WHITELIST_TELEGRAM_IDS is empty When any user sends a message Then the system MUST deny all (fail-closed).

FR-PFIN-02 — Telegram update idempotency

Section titled “FR-PFIN-02 — Telegram update idempotency”
  • AC-FR-PFIN-02-1Given an update with update_id=X has already been processed When Telegram redelivers the same update_id=X Then the system MUST silently drop the redelivery without re-invoking the parser or any use-case.
  • AC-FR-PFIN-02-2Given a fresh update_id When it arrives Then app.processed_update(update_id) MUST be inserted before any business logic runs.
  • AC-FR-PFIN-03-1Given a whitelisted user has a default account When they send 250 кафе Then the system MUST post a transfer default_account → external_expense of 250.00 UAH with category food/cafe and reply with a UA-language receipt containing the short transfer id.
  • AC-FR-PFIN-03-2Given a whitelisted user has multiple accounts When they send 250 кафе картка Then the system MUST post the transfer from the account whose alias is картка (override per ADR-0007).
  • AC-FR-PFIN-03-3Given the text fails to parse as <amount> <category> [<account>] When it arrives Then the system MUST reply with ERR_PARSE_001 (“Не зрозумів. Приклад: 250 кафе”) and MUST NOT write to the ledger.
  • AC-FR-PFIN-03-4Given the category alias is unknown When parsing succeeds otherwise Then the system MUST reply with ERR_VAL_001 and MUST NOT write.
  • AC-FR-PFIN-03-5Given the user has no default account AND no alias is provided When the message parses Then the system MUST reply with ERR_VAL_002 and MUST NOT write.
  • AC-FR-PFIN-04-1Given a whitelisted user has a default account When they send +5000 зарплата Then the system MUST post a transfer external_income → default_account of 5000.00 UAH and reply with an income receipt.
  • AC-FR-PFIN-04-2Given the leading + is missing When the same text would otherwise parse Then the system MUST treat it as an expense (FR-PFIN-03), not income.

FR-PFIN-05 — Transfer between own accounts

Section titled “FR-PFIN-05 — Transfer between own accounts”
  • AC-FR-PFIN-05-1Given user has accounts card and cash with matching currency When they send /transfer 1500 card->cash Then the system MUST post a transfer of 1500.00 UAH from card to cash and reply with a confirmation.
  • AC-FR-PFIN-05-2Given debit_account.currency ≠ credit_account.currency When the transfer is attempted Then the system MUST reply with ERR_LDG_003 (currency mismatch) and MUST NOT write.
  • AC-FR-PFIN-05-3Given debit_alias == credit_alias When the transfer is attempted Then the system MUST reply with ERR_LDG_001 (same account) and MUST NOT write.
  • AC-FR-PFIN-05-4Given the resulting debit_account.balance_minor would drop below min_balance_minor (when set) When the transfer is attempted Then the system MUST reply with ERR_LDG_004 and MUST NOT write.
  • AC-FR-PFIN-06-1Given a posted transfer exists for the user with short id a3f1 When the user sends /dela3f1 Then the system MUST post a compensating transfer (D↔C swapped, kind='compensation', compensates_id=<orig>) and MUST set voided_by_id on the original.
  • AC-FR-PFIN-06-2Given the same /del is sent twice When the second one arrives Then the system MUST return the existing compensating transfer (idempotent) and reply "✅ Вже скасовано.".
  • AC-FR-PFIN-06-3Given the short id does not match any of this user’s transfers When /del<id> arrives Then the system MUST reply with ERR_LDG_005.
  • AC-FR-PFIN-06-4Given the original transfer belongs to a different user When /del<id> arrives Then the system MUST treat it as not-found (ERR_LDG_005) — never reveal that the id exists.
  • AC-FR-PFIN-07-1Given the user sent /today When it arrives Then the system MUST reply with: total expenses today (UAH), total income today (UAH), and a per-category breakdown of expenses for today (UTC server day).
  • AC-FR-PFIN-07-2Given there are no transactions today When /today arrives Then the system MUST reply with zeros and an empty breakdown (“Сьогодні порожньо.”).
  • AC-FR-PFIN-08-1Given the user sent /month When it arrives Then the system MUST reply with monthly total (expense, income), per-category breakdown, and (if any budgets are set) per-category remaining-budget hint.

FR-PFIN-09 — Category management (/cats, /addcat)

Section titled “FR-PFIN-09 — Category management (/cats, /addcat)”
  • AC-FR-PFIN-09-1Given a whitelisted user When they send /cats Then the system MUST list 8 system-default categories plus any user-defined categories.
  • AC-FR-PFIN-09-2Given a whitelisted user When they send /addcat <codename>=<alias1>,<alias2> Then the system MUST insert a row into app.category(user_id=<user>, codename, aliases) and confirm.

FR-PFIN-10 — Default account management (/setdefault)

Section titled “FR-PFIN-10 — Default account management (/setdefault)”
  • AC-FR-PFIN-10-1Given a whitelisted user When they send /setdefault <alias> Then the system MUST update is_default=true on the matching account and is_default=false on all other accounts of the same user (within one transaction).

FR-PFIN-11 — Domain & DB invariants (always-on)

Section titled “FR-PFIN-11 — Domain & DB invariants (always-on)”
  • AC-FR-PFIN-11-1Given any path inserts into ledger.entry When the transaction is about to COMMIT Then SUM(D) = SUM(C) per transfer_id MUST hold at COMMIT (trg_transfer_balanced).
  • AC-FR-PFIN-11-2Given any path updates ledger.account_balance When the BEFORE UPDATE trigger fires Then new_balance ≥ min_balance_minor MUST hold (when min_balance_minor is set; BEFORE UPDATE trigger).
  • AC-FR-PFIN-11-3Given the cached account_balance.balance_minor for any account When verified by reconciliation job Then it MUST equal SUM(credit) - SUM(debit) from ledger.entry (verified by property-based test + reconciliation job — see Observability).

erd

Logical data model: app.* schema (system of record) and ledger.* schema (double-entry). The two schemas share UUID identifiers between app.account.id and ledger.account.idledger.account is a thin mirror keyed by the same UUID.



  • LEDGER_ACCOUNT is not a separate concept from ACCOUNT — same UUID, two schemas. The ledger schema can run independently of the app schema (this is the boundary that lets TigerBeetle replace ledger.* later — see ADR-0001).
  • LEDGER_ENTRY is append-only. Voiding never deletes entries; it inserts new ones (compensating). See ADR-0016.
  • LEDGER_ACCOUNT_BALANCE.balance_minor is a cache; the source of truth is SUM(direction='C') - SUM(direction='D') over LEDGER_ENTRY per account. Reconciled by trg_transfer_balanced at write time and by a nightly job (Plan 7).

  • SRS section: SRS — Telegram Finance Bot §🗄 Data Model
  • ADRs: ADR-0002 (PostgreSQL system of record), ADR-0008 (DB-level invariants), ADR-0014 (ledger schema design), ADR-0016 (void via compensating entries), ADR-0018 (domain ↔ ORM boundary)
  • Migrations: migrations/versions/0001_initial_app_user_and_processed_update.py, 0002_ledger_schema.py, 0003_app_account_category_budget.py, 0004_seed_default_categories.py

Data Dictionary (selected — full schema in migrations)

Section titled “Data Dictionary (selected — full schema in migrations)”
EntityAttributeTypeFormatRequiredDefaultNotes
app.useridUUIDuuidv7client-genPK
app.usertelegram_idbigintUNIQUE
app.userdefault_currencytextISO-4217 alpha (UAH)UAH
app.accountidUUIDuuidv7client-genPK
app.accountuser_idUUIDFK → app.user
app.accountnametext
app.accountcurrencytextISO-4217 alphamatches app.user.default_currency for MVP-1
app.accounttypetextenumcash|card|deposit|external_expense|external_income
app.accountis_defaultboolfalseexactly one true per user_id (partial unique idx)
app.accountmin_balance_minorbigintNULLNULL = overdraft allowed
app.categoryidUUIDuuidv7client-genPK
app.categoryuser_idUUIDFK → app.user, NULLABLENULLNULL = system seed
app.categorycodenametextsnake_caseUNIQUE per (user_id, codename)
app.categoryaliasestextcomma-sepresolved by CategoryRepo.resolve_alias
app.categoryis_base_expensebooltruefor /month base-vs-total split
app.processed_updateupdate_idbigintTelegram update idPK; ON CONFLICT DO NOTHING dedup
ledger.accountidUUIDuuidv7mirror of app.account.id for ledger ops
ledger.accountcurrencytextISO-4217 alphaCHECK ~ '^[A-Z]{3}$'
ledger.accountmin_balance_minorbigintNULLenforced by trg_account_balance_min
ledger.account_balanceaccount_idUUIDFK → ledger.accountPK
ledger.account_balancebalance_minorbigint0cache; reconciled vs entries nightly
ledger.transferidUUIDuuidv7client-genPK; idempotency key
ledger.transferstatustextenumPOSTEDPOSTED|VOIDED
ledger.transfervoided_by_transfer_idUUIDFK → self, NULLABLENULLset on void
ledger.transfervoids_transfer_idUUIDFK → self, NULLABLENULLset on compensating row
ledger.transfermetadatajsonbopaque{category_id, kind, raw_text, occurred_at} — opaque to adapter
ledger.transferposted_attimestamptzUTCnow()
ledger.entryidUUIDgen_random_uuid()DB-genPK
ledger.entrytransfer_idUUIDFK → ledger.transfer
ledger.entryaccount_idUUIDFK → ledger.account
ledger.entrydirectionchar(1)D|CCHECK
ledger.entryamount_minorbigintminor unitsCHECK > 0

See projects/telegram-finance-bot/diagrams/state-transaction-lifecycle (rendered in §⚙️ Technical Behavior above).

DB-enforced invariants matrix (defense-in-depth, ADR-0008 + ADR-0014)

Section titled “DB-enforced invariants matrix (defense-in-depth, ADR-0008 + ADR-0014)”
InvariantAdapter raisesDB raises
amount > 0AmountNotPositiveError (Money.is_positive)CHECK ledger_entry_amount_positive, ledger_transfer_amount_positive
debit ≠ creditSameAccountErrorCHECK ledger_transfer_distinct_accounts
Currency match across pairCurrencyMismatchErrorimplicit (one transfer.currency)
Account existsAccountNotFoundErrorFK on ledger.entry.account_id
Both entries balance per transferadapter inserts pair atomicallytrg_transfer_balanced (DEFERRED)
Balance ≥ min_balanceMinBalanceViolationErrortrg_account_balance_min (BEFORE UPDATE)
Idempotency on transfer.idadapter SELECT-existing returns prior PostedTransferUNIQUE PK
Void = new compensating transfervoid() inserts reverse-direction transfer + entriesstructural — ledger.entry is append-only (ADR-0016)

VR IDПоле / УмоваТипПеревіркаТип валідаціїTrigger Error
VR-01amount_minorbigint> 0businessERR_LDG_002 (AmountNotPositiveError)
VR-02debit_account_id vs credit_account_iduuidbusinessERR_LDG_001 (SameAccountError)
VR-03currency triple (debit.currency, credit.currency, transfer.currency)textall equalbusinessERR_LDG_003 (CurrencyMismatchError)
VR-04account_id referenced by transferuuidexists in ledger.accountbusinessERR_VAL_002 (AccountNotFoundError)
VR-05account.balance_minor after updatebigint≥ min_balance_minor (when set)businessERR_LDG_004 (MinBalanceViolationError)
VR-06category aliastextresolves via CategoryRepo.resolve_alias for the userbusinessERR_VAL_001 (CategoryNotFoundError)
VR-07parsed texttextmatches <amount> <category> [<account>] regexcontractERR_PARSE_001 (ParseError)
VR-08currency stringtext^[A-Z]{3}$ (ISO-4217 alpha)businessDB CHECK ledger_account_currency_iso
VR-09entry.directionchar(1)IN ('D','C')contractDB CHECK ledger_entry_direction_valid

CodeTrigger / VRReply (UA)Logged levelLinked AC
ERR_PARSE_001VR-07”Не зрозумів. Приклад: 250 кафеINFO (rate-limited)AC-FR-PFIN-03-3
ERR_VAL_001VR-06”Категорію <alias> не знайдено. Перевір /cats.”INFOAC-FR-PFIN-03-4
ERR_VAL_002VR-04”Рахунок не знайдено. Постав default через /setdefault.”INFOAC-FR-PFIN-03-5, AC-FR-PFIN-05-1
ERR_LDG_001VR-02”Не можна перевести на той самий рахунок.”WARNAC-FR-PFIN-05-3
ERR_LDG_002VR-01”Сума має бути додатньою.”WARN(defensive — should be unreachable from FE)
ERR_LDG_003VR-03”Валюти рахунків не співпадають.”WARNAC-FR-PFIN-05-2
ERR_LDG_004VR-05”Недостатньо коштів на рахунку.”INFOAC-FR-PFIN-05-4
ERR_LDG_005(void target not found / not owned)“Транзакцію не знайдено.”INFOAC-FR-PFIN-06-3, AC-FR-PFIN-06-4
ERR_AUTH_001whitelist miss”Доступ заборонено.”WARNAC-FR-PFIN-01-2
ERR_INFRA_DBasyncpg disconnect / timeout”Тимчасова помилка, спробуй за хвилину.”ERROR (alert)(any)
ERR_INFRA_TGTelegram API 4xx/5xx on reply(no reply — log only)ERROR (alert if rate >1%)(any)

UA-language reply templates live at src/finance_bot/adapters/telegram/_errors_uk.py (intentional Cyrillic, ruff-ignored for RUF001).


Автентифікація та авторизація

Section titled “Автентифікація та авторизація”
  • Метод: Telegram-id whitelist via env var WHITELIST_TELEGRAM_IDS (comma-separated bigints).
  • Enforcement point: AccessMiddleware runs before all handlers and routers (ADR-0021).
  • Per-user data isolation: every repo query filters by user_id. Cross-user reads are impossible by construction at the repo layer; ledger triggers further enforce that debit_account.user_id = credit_account.user_id = transfer.user_id.
  • Fail-closed: empty whitelist denies all (AC-FR-PFIN-01-3).
  • No self-onboarding for MVP-1; planned for MVP-2.
  • PII fields: raw_text, amount_minor, category aliases, account names, display_name.
  • Logging — PII-by-default-masked at INFO (ADR-0011):
    • Amounts NOT logged.
    • raw_text NOT logged.
    • Category and account names NOT logged.
    • request_id, user_id (UUID, not telegram_id), update_id, event ARE logged.
  • LOG_LEVEL=DEBUG unmasks for local debugging only — never set in production. CI test asserts INFO-level lines do not contain forbidden substrings.
  • Retention: ledger rows are immutable and retained indefinitely. Logs rotated by docker-compose log driver default (kept ~7 days locally).
  • Dedicated PG user with grants only on app.* and ledger.* (no SUPERUSER).
  • TLS for DB connection (sslmode=require) even inside the docker network.
  • All SQL parameterized via asyncpg / SQLAlchemy.
  • No eval/exec and no dynamic SQL on user input.
  • app.audit_log (Plan 7) — append-only; whitelist denials and voids logged.
  • Personal data scale: 2 users; no GDPR DPO ceremony. Right-to-erasure: one DDL DELETE statement (no automation needed).
  • Regulatory: none — this is not a regulated financial product; the ledger is a personal accounting tool, not a money-transmitter service.

  • NFR-PERF-01 — Median end-to-end latency from Telegram-message-received to reply ≤ 500 ms at MVP-1 load (<100 msg/day, 2 users), measured by bot_message_processing_seconds p50.
  • NFR-PERF-02LedgerPort.post_transfer p95 ≤ 50 ms against the local Postgres (single-VPS docker-compose), measured by ledger_transfer_duration_seconds{outcome="posted"} p95.
  • NFR-LOAD-01 — Sustained throughput target: 100 messages/day across 2 users; peak target 5 messages/minute. No formal load test for MVP-1 (out of scope per design doc §10).
  • NFR-AVAIL-01 — Uptime target: 99% (≈7 hours of downtime per month allowed). No SLA contract; the bot is best-effort.
  • NFR-AVAIL-02 — MTTR after VPS reboot: ≤ 2 min (docker-compose restart: unless-stopped).
  • NFR-CAP-01 — Storage growth: < 10 MB / month at 100 msg/day (transfers + entries + balances + minimal metadata). No retention pressure for years.

Correctness (NEW — promoted from default templates because of ledger nature)

Section titled “Correctness (NEW — promoted from default templates because of ledger nature)”
  • NFR-CORR-01 — Cached account_balance.balance_minor MUST equal SUM(credit) - SUM(debit) from ledger.entry for every account, always. Verified by:
    • Property-based test (tests/integration/test_balance_invariant.py via Hypothesis).
    • DB-level constraint trigger (trg_transfer_balanced).
    • Nightly reconciliation job (Plan 7).

  • Library: structlog, JSON renderer in production, console renderer locally.
  • Per-line context (always present): request_id, user_id (UUID), update_id, level, event.
  • Forbidden at INFO level (ADR-0011): amount_minor, raw_text, category names, account names.
  • Correlation: request_id propagates from AccessMiddleware via contextvars through all subsequent log lines for the same update.

Metrics (Prometheus, scraped by local Grafana)

Section titled “Metrics (Prometheus, scraped by local Grafana)”
MetricTypeSignificant values / SLO
bot_messages_received_total{kind}counterfreshness alert if no increment in 5m
bot_message_processing_seconds{kind, outcome}histogramp50 < NFR-PERF-01, p95 ≤ 1.5 s
ledger_transfer_duration_seconds{outcome}histogramp95 < NFR-PERF-02
ledger_invariant_violations_total{invariant}counterMUST be 0; paging alert if >0 in 5m
db_pool_connections_in_usegaugewarn at >80% saturation
bot_telegram_api_errors_total{method, error_code}counterwarn if rate >1% over 10m
УмоваКаналSeverityВиконує NFR
ledger_invariant_violations_total > 0 over 5mGrafana → emailHigh (page-equivalent)NFR-CORR-01
No bot_messages_received_total increment in 5mGrafana → emailHighNFR-AVAIL-01
bot_message_processing_seconds{outcome="infra_error"} > 1% over 10mGrafana → emailMediumNFR-AVAIL-01
  • Retry: aiogram retries Telegram API network errors with built-in backoff. DB errors are not retried inside the handler — Telegram redelivers; the update_id dedup prevents duplicate writes.
  • Timeouts: asyncpg query timeout 5 s; aiogram API timeout 30 s; per-handler timeout 60 s (asyncio).
  • Graceful shutdown: SIGTERM → finish current update → stop polling → exit.
  • Reconciliation job (Plan 7, planned): nightly cron — for every account compare cached balance_minor to SUM(D)-SUM(C); alert on drift.

АспектПлан
Feature flagNone — too small a user base; rollout is “deploy and monitor”
Поетапний rolloutN/A — single deployment to single VPS; both users get the new version simultaneously
Сегмент для початкового rolloutauthor only (whitelist temporarily reduced to 1 id) for risky changes (e.g. ledger schema migrations)
Rollback тригериAny ledger_invariant_violations_total > 0, alert from Phase-2 sequence drop, or user-reported balance corruption
Rollback планdocker-compose down && git checkout <prev_commit> && docker-compose up -d. For schema migrations: pre-deploy pg_dump; restore from dump if needed (Alembic downgrade not used per A-06)
Незворотні зміниSchema migrations are forward-only; once ledger.entry is populated, the schema cannot be downgraded by alembic downgrade
Підготовка supportN/A — author is the operator
Комунікація клієнтамTelegram DM to partner if the bot will be down >30 min

  • Unit tests (~120): tests/unit/{domain,application,adapters} — all use FakeLedger / FakeRepos / AsyncMock; no Postgres required.
  • Integration tests (~30): tests/integration/ — require finance_bot_test Postgres database (TRUNCATE per test, conftest aborts if TEST_DATABASE_URL is unset or doesn’t contain _test).
  • Property-based test (test_balance_invariant): Hypothesis-generated transfer sequences; asserts cached balance == SUM(credit)-SUM(debit) per account. The single most valuable test in the codebase.
  • E2E smoke: manual via real Telegram + a real bot token; commands listed in README “Plan 6 smoke” section.
  • CI matrix (GitHub Actions): lint (ruff), format (ruff format), typecheck (mypy —strict on core, non-strict on adapters), unit, integration, migrations (alembic upgrade head + downgrade + upgrade), security (pip-audit).

  • AC ↔ tests: every AC-FR-PFIN-NN-M has at least one unit test or integration test referencing the AC ID in the test name or docstring.
  • AC ↔ ERR codes: every AC with an “MUST reply with ERR_*” clause is the canonical failure path for that code; the unit test for the use-case asserts on exc.code.
  • ADR ↔ decisions: the Decision Log table at the top of this SRS lists each ADR with a one-line summary; full text in docs/adr/.

Lessons for the portfolio reader — what the code taught about the original design choices. Frame is learning, not failure.

  • L-01 — Reconciliation timing. Started without an automated reconciliation cron, relying solely on the property-based test + DB constraint trigger. Hindsight: the cron should have shipped in Plan 3, not deferred to Plan 7. Status: would change.
  • L-02 — update_id dedup scope. The current app.processed_update(update_id PK) doesn’t qualify by bot-token-hash. If the bot token rotates, there’s a small window where old update_id values from the new token could collide with stored ones. Two users at <100 msg/day make this a non-issue, but the right schema is (bot_token_hash, update_id). Status: would change in Plan 7.
  • L-03 — Long-polling vs. webhook. ADR-0012 picked long-polling for NAT-friendliness. With Cloudflare Tunnel now mature, webhooks would be just as easy to deploy and would cut idle CPU. Status: accepted tradeoff — revisit only when scale justifies.
  • L-04 — Money type as a class instead of a NewType. Money is a value object that carries its currency. A simpler approach would have been NewType('Hryvnia', int) for MVP-1 and introducing Money only in Tier-3 multi-currency. The class earned its keep when adding is_positive, __add__, __sub__ validation; in retrospect the boundary was right. Status: accepted tradeoff.
  • L-05 — Adapter-side FOR UPDATE lock ordering vs. trigger-side. ADR-0014 documents the choice; the adapter-side approach gives clearer error messages (MinBalanceViolationError vs. opaque trigger failure). However, every direct-SQL maintenance script (e.g. data fixes) also has to follow the lock-order rule. A lock-on-trigger would have made the schema self-protecting. Status: would re-evaluate — likely keep adapter-side but add a runbook checklist.
  • L-06 — metadata JSONB opacity. The ledger adapter treats metadata as opaque pass-through; the read repo queries it. Pros: TB-friendly. Cons: no index on metadata->>'category_id' means category-aggregation queries scan more rows than needed. For 2 users at 100 msg/day this is invisible; for any larger scale, a sidecar transfer_metadata table (already planned for the TB migration) is the right shape. Status: accepted tradeoff for MVP-1.
  • L-07 — No webhook-based bot for portfolio public demo. Because the bot is whitelist-only, there’s no live public demo a portfolio reader can play with. A read-only /demo web page that replays a canned sequence would have been a 1-day add and would dramatically improve the portfolio narrative. Status: would add as a Plan 8.

Excluded from any DoD checklist — these are reflective notes, not blocking work.


Базове:

  • Профіль SRS обраний (standard)
  • Source Materials заповнено (заміна BA Input)
  • Open Questions задокументовані (4 закриті, 1 відкрита)
  • Decision Log містить значущі рішення з обґрунтуванням (22 ADRs)

Standard:

  • Всі FR мають AC у форматі AC-FR-PFIN-NN-M (Given/When/Then)
  • Кожен AC з умовою має парний AC або ERR-код
  • Constraints і Assumptions мають IDs
  • Data Dictionary заповнений
  • API Contracts описані (Telegram command surface + LedgerPort)
  • Кожна VR має тип валідації + Trigger Error
  • Sequence/State/ERD/C4 діаграми згенеровані (5 діаграм у Diagrams/)
  • NFR має тільки цільові показники з числами
  • Observability & Resilience: алерти прив’язані до NFR
  • Rollout & Rollback план заповнений
  • Security перевірена

Reverse-engineered specific:

  • srs_origin: reverse-engineered у frontmatter
  • Source Materials замість BA Input
  • “What I’d Redo Today” замість open Open Questions
  • Per-section confidence table — see below

Three levels: ✅ from code (high), 🟡 inferred (medium), ⚠️ assumed (low).

РозділВпевненістьДжерело
Decision Log✅ from code22 ADR files in docs/adr/
Source Materials✅ from coderepo tree
Purpose✅ from codeREADME + design doc §1
Constraints✅ from codedesign doc §3 + ADR-0001..0013
Assumptions🟡 inferredmix of design-doc statements and code-review deductions
Open Questions✅ from codedesign doc §13 + ADR consequences
Technical Risks🟡 inferreddesign doc §10 + my own reverse-engineering review
Integrations✅ from codecompose.yml + infra/prometheus.yml + design doc §4
High-Level Flow✅ from codesrc/finance_bot/adapters/telegram/ + use-cases
API Contracts✅ from codesrc/finance_bot/ports/ledger.py + handler routing
Technical Behavior✅ from codedesign doc §9.2 + ADR-0019/0022
Functional Requirements✅ from code11 use-cases + middleware + read repo
Data Model✅ from code4 Alembic migrations
Validation Rules✅ from codesrc/finance_bot/domain/errors.py + design doc §8.1a
Error Handling✅ from codedomain/errors.py + _errors_uk.py
Security✅ from codeAccessMiddleware + ADR-0011 + compose.yml
NFR🟡 inferreddesign doc §9 + portfolio targets; not load-tested
Observability & Resilience🟡 inferredmetrics defined in code; reconciliation job is planned (Plan 7), not yet shipped
Rollout & Rollback⚠️ assumedno formal rollout doc; reconstructed from operator practice
Test Data✅ from codetests/ tree + README smoke sections
What I’d Redo Today🟡 inferredmy own reading of ADR consequences + TODO comments

ТермінВизначення
Double-entryEvery transaction is two entries: one debit (D) and one credit (C), summing to zero. The ledger schema enforces this at COMMIT via trg_transfer_balanced.
External accountA virtual account (external_expense, external_income) that absorbs/sources money so every operation can be modeled as a transfer between two accounts.
Compensating transferThe mechanism for “voiding” a posted transfer: insert a new transfer with debit/credit reversed; the original row is never mutated except for the voided_by_id link.
Idempotency keyThe transfer_id (UUIDv7) — UNIQUE on ledger.transfer.id. Retry-safe: re-sending the same transfer_id is a no-op.
MVP-1 / Tier-2Scope tier: expenses, income, transfers, budgets, simple stats. No multi-currency, no recurring, no goals, no CSV. See design doc §2.
TigerBeetleA purpose-built financial database. Deferred per ADR-0001; the LedgerPort boundary is the abstraction that enables the future swap.
Reverse-engineered SRSAn SRS authored after-the-fact, deriving its sections from existing code, design doc, and ADRs (rather than from a BA document). Marked with srs_origin: reverse-engineered.

  • Design doc: /Users/zipsybok/dev/telegram-finance-bot/docs/design/2026-04-27-mvp1-architecture.md
  • ADRs: /Users/zipsybok/dev/telegram-finance-bot/docs/adr/
  • Plans: /Users/zipsybok/dev/telegram-finance-bot/docs/plans/
  • Project MOC: _MOC