Engineering case
Order dispatch with debt accounting
Orders for hired workers: dispatch, statuses, disputes and payout maths. One file, the standard library and a single external dependency.
- balance columns
- 0
- external dependency
- 1
- lines in one file
- 4,266
A manager assembles an order step by step — worker, item, quantity, the data to hand over — the worker receives it in a direct message and marks it done. The bot credits the amount, tracks what is owed to each worker, freezes money on disputed orders and works out payouts. Four access roles, two interface languages, and an optional HTTP contract for an external panel.
The debt is in no column — it is recomputed every time
There is no “balance” or “debt” field in the schema. There is one ledger table with four kinds of entry: credit, hold, release, payout. The debt is their sum.
The obvious design — keep a number on the worker and update it on every event — breaks here faster than usual: a single order flips sign up to three times, plus partial payouts. Any failure between “we updated the number” and “we wrote the history” produces two different truths, and a dispute with a worker becomes unresolvable — they see one figure, the manager another. Recomputing makes the discrepancy impossible: the history is the balance.
The trade-off is stated honestly: the balances screen recomputes in a loop per worker, and the schema carries no indexes at all. Invisible across hundreds of orders, not across tens of thousands.
Status change and money are one operation, not two in a row
Every transition runs as an update conditioned on the status still being what it was, and the ledger entry is inserted only if the transition actually happened, inside the same transaction.
A button in a messenger is always pressed twice: a slipped finger, a stalled network, a repeated update. Code shaped as “read status → check → write” leaves a window between read and write in which a second thread credits the money again. Here the second click simply gets “zero rows changed” and falls through to “the status already moved”.
A network error on send is not “not delivered”
Sending returns a reason rather than yes/no: never started the bot, blocked it, text too long, interface error, network. The last one is handled apart: the order stays active and the manager sees “delivery not confirmed”.
If the messenger did not answer, that does not mean the message failed — it may already be sitting with the worker. Marking such an order undelivered nudges the manager towards “Retry” — and hands the same login and password to two people. So uncertainty is shown as uncertainty rather than collapsed into an error.
The reason for a failed delivery decides which buttons appear
The keyboard under the failure message is assembled per cause, and for “text too long” there is no “Retry” button — only “Another worker” and “Cancel”.
One “Retry” for everything looks simpler. But length is a property of the message, not a channel failure: retrying reproduces the same error forever. A button that cannot work is worse than a missing one — a person will keep pressing it until they decide the bot is broken.
Text length is measured the way the messenger measures it
The limit is checked in UTF-16 units, the way Telegram counts, not in Python characters. An emoji counts as two there and as one here.
Otherwise a message passes validation on entry and is rejected on send — the error surfaces a step away from where it could be fixed, and without a comprehensible explanation.
An invitation by handle does not grant access
A person can be added by a forwarded message, by numeric id, or by an @handle. The first two work
immediately. The third only files a request: when someone with that handle writes to the bot, the
owner gets a “is this really them?” prompt carrying the numeric id.
A handle is not an identifier — it can be released and taken. An invitation issued to
@ivan_manager may a month later be activated by an entirely different person, who then
reaches the till and other people’s credentials. A numeric id cannot be changed, so the final
decision always rests on it and always on a human.
Permissions are checked on the server, not by hiding buttons
A moderator sees a reduced menu, but that is not the point: an allowlist of permitted actions sits at the entry, and anything unknown is denied by default. Text input modes are checked separately.
In a messenger the button payload is sent by the client, not the server. It is enough to see a colleague’s message with a “Pay everything” button once — and they will, notifications go to all staff — and reuse its contents. Hiding buttons here is cosmetic; the server decides.
The bot cannot strip owners listed in its configuration
Roles live in the database, but owners from the configuration file are restored on every start, and their role is not read from the database at all. Any action against them is refused — as is any attempt to change one’s own role.
Without it you get the classic trap: two owners demote each other and the access section becomes unreachable for everyone — the only way back is editing the database by hand.
One action is deliberately withheld from the external panel
The HTTP contract reuses the same functions as the bot but accepts only some transitions. Marking an order done is not among them: that is the worker’s action, in their own messenger.
The temptation to expose every transition is understandable — they are already written. But “done” is the single event in the whole system confirmed by the worker, and it is the one that credits money. Give it to the panel and the confirmation stops meaning anything: “I never did that” becomes unresolvable.
Python 3.12 · pyTelegramBotAPI · SQLite (WAL) · http.server · systemd