Architecture¶
The shape¶
Telegram Bale Rubika
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────┐
│ adapters/ translate payloads │ no database, no rules
└───────────────────┬─────────────────────┘
▼
┌─────────────────────────────────────────┐
│ bot/ screens + routing │ no provider names
└───────────────────┬─────────────────────┘
│
┌───────────────────┼─────────────────────┐
│ api/ ───────────┤ │ parse, call, translate errors
└───────────────────┼─────────────────────┘
▼
┌─────────────────────────────────────────┐
│ modules/*/service.py │ every rule, exactly once
└───────────────────┬─────────────────────┘
▼
┌─────────────────────────────────────────┐
│ modules/*/models.py · shared/ │
└───────────────────┬─────────────────────┘
▼
PostgreSQL · Redis · Alembic
Two clients — the bot and the HTTP API — sit on one set of application services. That is the whole design in a sentence, and everything below is a consequence of it.
Why the seams are where they are¶
Adapters translate and nothing else¶
MessagingAdapter is a protocol: payload in, IncomingEvent out; OutgoingMessage
in, provider call out. An adapter has no session, no service, and no idea what
a permission is.
This is enforced rather than asked for. test_the_adapter_never_reaches_the_database
parses every module in adapters/ and fails if any of them imports SQLAlchemy
or a service. It walks the import graph rather than searching the text, so a
comment mentioning a model cannot fail the build and a real import cannot hide
inside a string.
The payoff was measurable: adding Bale took four lines of configuration,
because Bale's API is the Telegram Bot API with a different host, and the
dialect lives in adapters/botapi.py once.
The conversation layer names no provider¶
bot/conversation.py holds what no feature owns: the savepoint around each
update, people not linked yet, commands, and two dispatchers. Everything a
person can do lives in bot/features/, one module per area. Each feature
declares the callback prefixes it answers and the draft flows it reads text
for, and is given a Context (the services and the update's outbox), never the
conversation. Two features claiming the same prefix or flow fail at import.
bot/screens/ renders, split the same way: one module per feature
(account, books, payroll, planning, reports, transactions,
wallet) and common for what several share — rtl(), money formatting, the
home, error, stale and cancelled screens, and the navigation vocabulary every
feature's keyboards are built from (back_row(), cancel_row(), rows_of()).
bot/screens/__init__.py re-exports every name explicitly, so callers keep
writing screens.X. The package is a star: a feature's screens import only
common, and common imports none of them.
That star is why shared copy and layout belong in common and nowhere else:
a rule that lives inside one feature cannot be reached by the others, so they
reinvent it, and the bot ends up saying two things for one action. See
conversation-design.md for the vocabulary and the
keyboard grammar those helpers carry.
Screens are pure functions — data in, text and buttons out — so every one of
them is tested without a token, a network, or a database. A screen imports no
session, service, database, conversation or adapter beyond Button;
tests/test_screens_architecture.py fails the build if one does, and also
holds the package to:
- the names it promised, frozen;
- one definition per name;
- 64-byte callback payloads;
- Persian through rtl();
- no English error reaching a person.
The provider arrives as a constructor argument, taken from adapter.provider.
It used to be hardcoded to Provider.TELEGRAM in the runner, which meant a
Bale update would have resolved to whichever Telegram account shared its
numeric id. The test that runs the same loop against Bale is what found it.
Whichever step commits is the step that claims the draft¶
A multi-step bot flow keeps a draft in the state store and records it at one step. In webhook mode each update gets its own session and two of them run concurrently, so two copies of that one step can arrive at once — a double tap, or the same line sent twice. The guard is that the committing step takes the draft out of the store atomically before recording: one copy finds a draft, the other finds nothing and is told its message is old.
The invariant that matters is the coupling, not the mechanism: a claim sitting one step behind the commit protects nothing, so when the commit moves the claim moves with it. The recording flow has already exercised that in both directions — the commit went from the description prompt to the amount message and back. Nothing in the type system says so, so it is said here, in conversation-design.md, and asserted by tests that race two identical updates.
Rules live in services, once¶
If a rule were in a route, the bot would not obey it. If it were in a screen,
the API would not. So books.require(book_id, user_id, permission) is the one
gate, and both clients pass through it.
The corollary is that neither client may reach past it. When the reports route
wanted a transaction count it did not query for one — Summary gained a
count field, and both callers got it.
Money is a Decimal everywhere and a string at the edge¶
shared/money.py defines Money, a TypeDecorator that is Numeric(28, 4)
on PostgreSQL and String(40) on SQLite. Nothing is ever a float.
At the HTTP boundary money is serialised as a string, because JSON's only
numeric type is a float and 12500000.15 does not survive that round trip.
The calendar is Jalali, the storage is not¶
Dates are stored as ordinary date columns. A period like 1403-05 is
converted to a Gregorian [start, end] range before any query runs, in
shared/jalali.py. The database never sees a Jalali value, and the user never
sees a Gregorian one.
The identity model¶
A KasbBook account is a User with a UUID. A Telegram account is an
Identity pointing at it.
No messenger id is ever the account. A person can lose a Telegram account,
change a phone number, or move to Bale, and their books do not move with it.
A unique constraint on (provider, external_id) is what stops one messenger
account feeding two sets of books.
The ledger¶
Every transaction writes a Transaction row and a balanced JournalEntry
with at least two JournalLines. trial_balance() sums debits and credits for
a book and the tests assert they agree — after recording, after deleting, after
settling a debt, after paying a loan instalment.
See double-entry.md.
What runs where¶
| Process | Unit | What it is |
|---|---|---|
| Bot | kasbbook-bot |
polls one provider, handles updates, runs the reminder loop beside the poller |
| API | kasbbook-api |
uvicorn, two workers, bound to loopback |
| PostgreSQL | container | isolated from the host instance on purpose |
| Redis | container | conversation state and rate limiting |
One process runs one provider. A Bale bot is a second copy of the unit with its own environment file — never one process holding two identities.
Directory map¶
src/kasbbook/
adapters/ base.py (the protocol), botapi.py (the shared dialect),
telegram.py, bale.py, rubika.py
bot/ conversation.py, features/ and screens/ (one module per area), quick.py, state.py
api/ app.py, deps.py, schemas.py, errors.py, ratelimit.py, routers/
modules/ identity, books, ledger, budgets, debts, loans, recurring,
reports, reminders, treasury, payroll
shared/ money, jalali, parsing, settings, database, errors, security
models.py the registry Alembic autogenerate reads
apps/bot/ runner.py (the entry point), reminders.py
migrations/ alembic env, comparators, versions/
deploy/ docker-compose.yml, the two systemd units
scripts/ install, update, backup, restore, uninstall, lib
tests/ the whole suite
Deliberate non-choices¶
- No ORM lazy loading across the async boundary. Relationships that a
caller needs are
lazy="selectin"; a payslip built in memory setspayments=[]explicitly rather than triggering a load nobody can await. - No service commits. The caller owns the transaction — with exactly one exception, documented where it happens: refresh-token theft detection commits its revocation before raising, because the caller rolls back on failure and the revocation must survive the failure it causes.
- No
select *into a dict. Services return models or small dataclasses, so a renamed column is a failing import rather than a missing key at runtime.