Data model¶
One account, many identities¶
A messenger id is never the account. It is a pointer to one.
That distinction is the reason a person can lose a Telegram account, switch to Bale, or change a phone number without their books moving with them. The alternative — using the Telegram id as the primary key — works right up until the day it does not, and then it is unrecoverable.
A unique constraint on (provider, external_id) enforces the other direction:
one Telegram account cannot feed two sets of books.
Linking¶
Two flows, both through a one-time LinkToken whose digest is stored, never
the token.
| Direction | Who starts | What the other side does |
|---|---|---|
FROM_WEB |
a logged-in account asks for a link | the messenger opens a deep link carrying it |
FROM_MESSENGER |
an unknown messenger says /start |
the web panel claims the code it was shown |
Both expire in 15 minutes: long enough to switch apps and paste, short enough that a leaked link is dead before anyone finds it.
A token issued for Telegram is refused if redeemed from Bale.
Books¶
Book
├── type: personal | business | team | organization
├── base_currency
├── owner_user_id
└── Membership[] → User, Role
The type is not a label. It decides the default Scope for a transaction, and
whether payroll exists at all — splitting profit between one person is not a
feature.
Roles and permissions¶
Role maps to a set of Permission. Roles are coarse because people
understand them; permissions are fine because code needs them.
| Role | Roughly |
|---|---|
OWNER |
everything, including locking a period and transferring the book |
ADMIN |
everything except locking a period |
MANAGER |
records, reports, budgets, payroll |
MEMBER |
records their own work, sees reports |
VIEWER |
reads |
Every service call passes through one gate:
It raises NotFound — not PermissionDenied — when the caller is not a member
at all. A non-member is told the book does not exist, so book ids cannot be
probed by watching which error comes back.
Transactions¶
Transaction
flow income | expense
scope personal | work | team
category free text, 80 chars
original_amount / original_currency / conversion_rate / converted_amount
occurred_on a date, Gregorian in storage
receipt_file_id the provider's own id, if a photo was attached
→ JournalEntry balanced, always
scope exists so a team book never accidentally records someone's personal
groceries. It defaults from the book type and can be overridden.
Receipts store the provider's file id, never the bytes. Showing one sends that id back. Nothing is downloaded, nothing is stored, and there is no bucket of other people's photographs to secure.
Alongside the id sit receipt_kind, receipt_file_name and
receipt_mime_type, because the id is opaque and says none of that. Without
them a PDF invoice and a photograph of a till roll are the same row: the screen
can only say "there is one", and sending it back has to try sendPhoto and
wear the rejection before reaching sendDocument. All three are nullable —
every receipt attached before they existed has them empty, and the code falls
back to guessing for those.
Currencies and the wallet¶
A book may hold more than one currency. BookCurrency lists the ones it is
allowed to hold; the base currency is never a row there, so it is always
allowed and cannot be switched off.
BookCurrency book_id, code, is_active
CurrencyConversion from_currency/from_amount → to_currency/to_amount
base_value, base_currency what the swap was worth here
Balances are derived, never stored. What a book holds of a currency is its income minus its expenses in that currency, plus and minus what conversions moved. A stored balance is a second copy of a number the transactions already contain, and the two drift the first time a row is edited.
What arrives stays in the currency it arrived in. Ten tethers received are ten tethers held; the toman figure is frozen on the transaction as the valuation, not as the holding.
An expense in a currency the book does not hold enough of is refused. The base currency is exempt: its balance here is income minus expense rather than a bank balance — nothing records an opening balance or a transfer in from outside — so enforcing it would refuse the first expense of every new book.
A conversion is deliberately not a transaction. Payroll decides what everyone is paid by summing income and expense, so a swap recorded as both would inflate the pie and move every member's share for a trade that earned the book nothing.
Conversions do not post a journal entry. The journal is an income-and-expense journal in the base currency, and payroll payments and treasury allocations do not post to it either; a conversion is neither income nor expense, so the trial balance is unchanged by one.
Paying a member is the third kind of money movement, alongside income/expense
and conversion. It leaves the wallet but is not an expense of the business:
payroll decides shares by summing income and expense, so counting a payout
there would take last period's payouts out of this period's pie. The Payment
row is the record, and the wallet reads it.
Live prices¶
kasbbook.rates.swapwallet reads SwapWallet's public market endpoint, which
needs no key. The bot fills in the rate when a foreign amount is entered, and
shows it with a button to override — an OTC deal is often not the market price.
It lives outside modules/ on purpose: every service here talks to the
database and nothing else, and a rule that depends on a third party being
reachable fails at the worst moment. The source is passed in; a service without
one has no quote and asks the person instead. A dead feed never raises.
Only prices are read. The same API can execute swaps and withdraw funds, and nothing here can reach that.
Recorded rates are never revalued. A transaction keeps the rate it was recorded at for ever, so last month's report does not move when the market does. What is revalued is the holding: the wallet shows what today's prices say the tokens are worth, which is a different question from what they were worth when they arrived.
Planning¶
| Model | What it holds |
|---|---|
Budget |
a ceiling, per category or per direction, and what has gone against it |
Debt |
who, how much, which way, when due — settling writes a real transaction |
Loan |
instalment amount and count; payments are LoanPayment rows each pointing at a transaction |
RecurringRule |
a definition plus next_run_on, which moves forward as it fires |
A recurring rule that has fallen behind catches up, capped at 400 firings — a rule that somehow fell years behind should not spend an afternoon writing transactions.
Payroll¶
FinancialPeriod a window, with a status
├── Adjustment[] bonuses and deductions, signed, approved separately
├── Payslip[] one per member, every input frozen onto it
│ └── Payment[] staged: a share is often paid across several transfers
└── TreasuryAllocation[] what the funds took, written once
Statuses move along a fixed path (PERIOD_TRANSITIONS); anything else is a
bug, not a decision. A locked period refuses edits — corrections go in a later
one, which is how accounting works.
A payslip snapshots the distributable total, the share basis, and the share value. Re-reading last year's payroll shows what was decided then, not what today's rules would say.
See payroll.md.
Credentials¶
| Model | Stored | Not stored |
|---|---|---|
User.password_hash |
Argon2id | the password |
LinkToken.token_digest |
SHA-256 | the code |
RefreshToken.token_digest |
SHA-256, plus a family id | the token |
ApiKey.token_digest |
SHA-256, plus an 8-char prefix | the key |
The prefix is kept in the clear so a key can be named in a list and revoked without its owner having to produce it.
An account can start with none of them¶
create_account_from_messenger makes an account with no email, no phone and no
password — because someone pressing "make me an account" in Telegram has given
us nothing else. That account works, but it is a dead end: it cannot sign in to
the API, a colleague cannot find it to add to a book, and losing the messenger
loses the books, which is the exact failure the identity model exists to
prevent.
So the bot says so on the screen that announces the account, and the account
panel keeps saying so until an email or phone is set. set_contact and
set_password are the way out, and both are reachable from the bot and the API.
See security.md.
Audit¶
AuditEvent is append-only and records anything that changes who can see what:
accounts created, identities linked and unlinked, members added and removed,
periods advanced, API keys issued and revoked, refresh tokens detected as
reused.
Deletion¶
Closing an account is two different operations depending on what depends on it.
Books nobody else is on are destroyed entirely — transactions, journal,
budgets, debts, loans, payroll, treasury. Every foreign key to books is
ON DELETE CASCADE, so the database does almost all of it; two RESTRICT edges
(journal_lines → accounts, treasury_allocations → treasury_funds) are
cleared first by BookService.delete_book, because the database does not
promise to process a cascade in an order that satisfies them.
Books shared with other people stop the whole thing. They are not one
person's to destroy, so the operation refuses and names them; hand them over
first with transfer_ownership.
The account row itself survives only if it has to. Three foreign keys are
RESTRICT — transactions.actor_user_id, adjustments.recorded_by,
recurring_rules.created_by_user_id — and each one is the ledger saying a
financial record must not lose its author. When records in other people's
books name this person, the row is anonymised rather than deleted: no name, no
email, no phone, no password, no identities, is_active false, and
token_generation bumped so nothing in flight still works. When nothing points
at it, the row is deleted outright.
Both paths free the messenger, so somebody who closes an account and comes back starts genuinely fresh.
Managed categories and consent records¶
Migration ddf5ddf99303 adds nullable transactions.last_edited_by_id and
last_edited_at. The editor foreign key uses SET NULL, preserving the edit time
if the editor is removed. Original actor_user_id and created_at are retained.
Legacy modification timestamps are copied from updated_at; historical editor
IDs are not inferred because receipt/category edits previously lacked complete
audit coverage. These metadata changes never modify amounts or journal lines.
Migration cce4cce99202 adds nullable income/expense categories.flow.
Existing categories stay unclassified because historical names can span both
directions. New categories require an explicit type; automatic categories created
by ledger recording receive that transaction's type. Classification changes do
not change transaction flow, balances, payroll snapshots or journal lines.
categories has a book-scoped unique name. Transactions retain their historical
category text and also reference category_id through a deferred composite
foreign key including book_id. Migration bba3bba99101 backfills one category
per distinct existing book/name pair without changing descriptions, receipts,
rates or amounts. Nullable IDs preserve compatibility with legacy raw rows;
all service-created transactions populate the reference. The deferred key
rejects cross-book references and deletion of a used category while permitting
whole-book cascades. Category renaming keeps name-based planning filters aligned.
team_invitations stores recipient user and messenger identity, offered role,
expiry, consent status and successful delivery timestamp. account_login_challenges
stores source, requester identity, destination user/identity, identifier/code
hashes, expiry, attempts and consumption time. Neither table transfers financial
ownership. All mutations remain inside caller-owned transactions.