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.
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:
The portfolio narrative (“strict double-entry from day one”) is credible.
The ledger backend can be swapped to TigerBeetle later without touching
domain/, ports/, or application/ (ADR-0001 + ADR-0006).
The whitelist (WHITELIST_TELEGRAM_IDS env var, comma-separated) is the authoritative ACL — any change requires a redeploy.
A-02
Telegram delivers each user message as exactly one Update (no message edits trigger ledger writes — edits are ignored).
A-03
The two users trust each other’s data — there is no per-user ACL beyond user_id filtering at the repo layer.
A-04
Server 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-05
Telegram redelivers updates idempotently keyed by update_id (verified empirically; documented in ADR-0022).
A-06
Migration order is forward-only (Alembic upgrade head); rollback is via PG dump/restore, not alembic downgrade.
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.
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):
Telegram Bot API → bot: getUpdates long-poll returns one Update.
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:
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.
A transaction (one user message → one ledger.transfer row) traverses a
short state machine:
State
Trigger to enter
Allowed next
Parsed
bot received text matching parser
Validated, Rejected
Validated
parser ok; refs (account, category) resolved
Posted, Rejected
Posted
ledger insert + balance update committed
Voided (terminal otherwise)
Rejected
parse error or invariant violation pre-commit
terminal
Voided
user typed /del{id}; compensating transfer posted
terminal
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.
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.
PostgreSQL — asyncpg 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).
Telegram update_id → app.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.
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.
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.
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.
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.
AC-FR-PFIN-01-1 — Given a user whose telegram_id is in WHITELIST_TELEGRAM_IDSWhen they send any message Then the system MUST process the message normally.
AC-FR-PFIN-01-2 — Given 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-3 — Given the env var WHITELIST_TELEGRAM_IDS is empty When any user sends a message Then the system MUST deny all (fail-closed).
AC-FR-PFIN-02-1 — Given an update with update_id=X has already been processed When Telegram redelivers the same update_id=XThen the system MUST silently drop the redelivery without re-invoking the parser or any use-case.
AC-FR-PFIN-02-2 — Given a fresh update_idWhen it arrives Thenapp.processed_update(update_id)MUST be inserted before any business logic runs.
AC-FR-PFIN-03-1 — Given 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-2 — Given 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-3 — Given 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-4 — Given 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-5 — Given 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-1 — Given 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-2 — Given 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.
AC-FR-PFIN-05-1 — Given user has accounts card and cash with matching currency When they send /transfer 1500 card->cashThen the system MUST post a transfer of 1500.00 UAH from card to cash and reply with a confirmation.
AC-FR-PFIN-05-2 — Givendebit_account.currency ≠ credit_account.currencyWhen the transfer is attempted Then the system MUST reply with ERR_LDG_003 (currency mismatch) and MUST NOT write.
AC-FR-PFIN-05-3 — Givendebit_alias == credit_aliasWhen the transfer is attempted Then the system MUST reply with ERR_LDG_001 (same account) and MUST NOT write.
AC-FR-PFIN-05-4 — Given 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-1 — Given a posted transfer exists for the user with short id a3f1When the user sends /dela3f1Then 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-2 — Given 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-3 — Given 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-4 — Given 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-1 — Given the user sent /todayWhen 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-2 — Given there are no transactions today When/today arrives Then the system MUST reply with zeros and an empty breakdown (“Сьогодні порожньо.”).
AC-FR-PFIN-08-1 — Given the user sent /monthWhen 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.
AC-FR-PFIN-09-1 — Given a whitelisted user When they send /catsThen the system MUST list 8 system-default categories plus any user-defined categories.
AC-FR-PFIN-09-2 — Given 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.
AC-FR-PFIN-10-1 — Given 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).
AC-FR-PFIN-11-1 — Given any path inserts into ledger.entryWhen the transaction is about to COMMIT ThenSUM(D) = SUM(C) per transfer_idMUST hold at COMMIT (trg_transfer_balanced).
AC-FR-PFIN-11-2 — Given any path updates ledger.account_balanceWhen the BEFORE UPDATE trigger fires Thennew_balance ≥ min_balance_minorMUST hold (when min_balance_minor is set; BEFORE UPDATE trigger).
AC-FR-PFIN-11-3 — Given 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).
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.id — ledger.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).
Метод: 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).
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-02 — LedgerPort.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).
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).
None — too small a user base; rollout is “deploy and monitor”
Поетапний rollout
N/A — single deployment to single VPS; both users get the new version simultaneously
Сегмент для початкового rollout
author 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
Підготовка support
N/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.
Every 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 account
A virtual account (external_expense, external_income) that absorbs/sources money so every operation can be modeled as a transfer between two accounts.
Compensating transfer
The 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 key
The transfer_id (UUIDv7) — UNIQUE on ledger.transfer.id. Retry-safe: re-sending the same transfer_id is a no-op.
MVP-1 / Tier-2
Scope tier: expenses, income, transfers, budgets, simple stats. No multi-currency, no recurring, no goals, no CSV. See design doc §2.
TigerBeetle
A purpose-built financial database. Deferred per ADR-0001; the LedgerPort boundary is the abstraction that enables the future swap.
Reverse-engineered SRS
An 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.