Appearance
ADR: Telegram bank-statement inbox v1
Status
Accepted — implemented (feature flag Telegram__Enabled, default off)
Date
2026-07-19
Context
Operators often receive Indonesian bank payment files (PDF, images, structured exports) in messengers and must re-upload them through the Pay web UI. A Telegram bot can act as a low-friction inbox channel into the existing bank-statement import pipeline (BankStatementImportService / document recognition).
WhatsApp is explicitly out of scope for v1 (higher API cost, business verification, weaker deep-link linking).
The bot token is an operational secret (env / deployment config), not the main security boundary. The real risk is unauthorized tenancy: a stranger must not be able to push files into someone else's organization by guessing an org id, reusing a forwarded link, or chatting with the bot without a confirmed binding.
Runtime is behind Telegram__Enabled (default false): personal one-time challenge, webhook dialog (ID/RU/EN), upload via NuGet Telegram.Bot into BankStatementImportService. Challenge/link also snapshot LocationId for the existing import pipeline.
Decision
MyHoreca Pay adopts Telegram-only bank-statement inbox v1 with the following rules:
- Telegram is a transport + identity binding layer only. Parsing, recognition, duplicate detection, matching, allocation, and posting stay in existing Pay modules.
- A file is imported into an organization only when an Active link exists for that Telegram user.
- Links are created only from an authenticated Pay session via a short-lived, single-use link challenge (never via a permanent public organization/client id).
- Bot token and webhook secret live in process environment / deployment secrets (same pattern as other API secrets). Feature flag defaults to off until the channel is ready.
Non-goals (v1)
- WhatsApp or any other messenger.
- Match / allocate / post workflows inside the chat UI.
- Multi-organization switcher inside the bot (
/organd similar) — deferred. - Using Telegram identity as a login method for the SPA.
Binding model (cabinet ↔ bot)
Entities
TelegramAccountLink
| Field | Rule |
|---|---|
Id | UUID v7 |
PayUserId | Pay user who owns the link |
OrganizationId | Target org for imports (from active auth context at challenge creation) |
TelegramUserId | Stable Telegram numeric user id |
TelegramChatId | Chat for bot replies |
Status | Active | Revoked |
LinkedAt / RevokedAt | Audit timestamps |
Uniqueness (v1):
- At most one Active link per
TelegramUserId. - At most one Active link per
(PayUserId, OrganizationId).
TelegramLinkChallenge
| Field | Rule |
|---|---|
Code | Short opaque code (e.g. A7K9Q2), not an org id |
PayUserId, OrganizationId | Snapshot from authenticated session |
ExpiresAt | 5–15 minutes |
ConsumedAt | Set on successful /start |
Attempts | Failed consume counter for brute-force limits |
Link flow
text
[Pay UI — JWT, active org selected]
POST /api/telegram/link-challenges
→ show code + deep link t.me/<Bot>?start=<code>
[Telegram]
/start <code>
→ webhook
→ validate challenge (exists, not expired, not consumed, attempts ok)
→ create Active TelegramAccountLink
→ consume challenge
→ reply: linked to Org X; send bank files hereUnlink
- Only from authenticated Pay UI (user or org admin with upload permission).
- Sets
Status=Revoked. Immediate effect: uploads from that Telegram user are rejected. - Bot-side
/unlink(if present) must require a second factor or only point the user to Pay UI — it must not silently revoke without proof of Pay ownership.
Upload flow
text
Telegram document/photo
→ webhook
→ resolve Active link by TelegramUserId
→ if none: reject (no import)
→ download file via Bot API
→ BankStatementImport / recognition (same as web upload)
→ actor = PayUserId, tenant = OrganizationId from link
→ audit Source=Telegram + TelegramUserId + LinkId + ImportId
→ reply: accepted / format error + deep link to Pay importWithout an Active link, the bot must not store the file into any organization inbox (optional: discard immediately; do not quarantine into a shared bucket).
Authorization and abuse controls
| Threat | Control |
|---|---|
| Guessing a permanent org/client id | No permanent public binding id; only single-use challenges |
| Forwarded deep link | First successful consume wins; second fails |
| Stolen fresh code | Short TTL + rate limit on challenge consume |
| Unlinked user sends files | Reject; no import |
| One Telegram bound to two cabinets | Forbidden while Active (must unlink first) |
| Former employee | Revoke link in Pay |
| Webhook spoofing | Telegram__WebhookSecret (secret token header) and/or trusted update verification |
| Spam | Per-link rate limits; max size; MIME whitelist |
| Leaked bot token | Token controls the bot API, not Pay tenancy; uploads still require Active link |
Permission to create/revoke links: same capability family as bank-statement upload for that organization (exact permission name decided at implementation time against the scoped RBAC matrix).
Configuration
| Key | Source | Notes |
|---|---|---|
Telegram__Enabled | env / appsettings | Feature flag; default false until rollout |
Telegram__BotToken | env only in production | Never commit real token; never log |
Telegram__WebhookSecret | env | Validates Telegram webhook calls |
Telegram__BotUsername | env / appsettings | For deep-link UI |
appsettings.json may contain a non-secret skeleton (Enabled, BotUsername). Real BotToken / WebhookSecret follow the same rule as Jwt:SigningKey / other deployment secrets: document in .env.example, inject via environment.
Module placement
| Concern | Module |
|---|---|
| Link / challenge use cases + DTOs | Application |
| EF entities / store | Persistence |
| Authenticated link/unlink API | Api (JWT) |
| Telegram webhook endpoint | Api (webhook secret scheme, not user JWT) |
| Bot API client (download/send) | Infrastructure |
| Import reuse | BankStatements / existing recognition path |
| Link status + challenge UI | Frontend Settings |
Do not duplicate bank parsers or recognition inside a Telegram-specific project.
Implementation phases
- Docs + flag off — this ADR; config skeleton; no production webhook.
- Link/unlink API + UI — challenges, consume rules, revoke; unit tests for expired/consumed/already-linked.
- Bot webhook —
/startbinding only; unlinked messages rejected. - Upload path — file → existing import + audit + chat reply.
- Hardening — rate limits, metrics, retention aligned with
BankFilespolicy.
Open product choices (decide before coding phase 4)
- Multi-org users (v1): one Active link = one org (active context at challenge time). Changing org requires unlink + re-link. Bot
/orgdeferred. - Multiple staff in one org: allowed — each Pay user may have their own Telegram link into the same
OrganizationId(better audit). - First-upload web confirmation: optional paranoia; not required for v1 if link challenge is solid.
Consequences
Positive
- Faster operator path: messenger → same import pipeline.
- Tenancy remains Pay-owned; Telegram never becomes a source of organization identity.
- WhatsApp complexity deferred.
Negative / costs
- New public webhook surface and bot operational ownership.
- Support for “why won’t the bot accept my file” (almost always: not linked / revoked / wrong Telegram account).
- PII/bank files transit Telegram’s infrastructure — retention and operator guidance must stay consistent with existing bank-file policy.
Rejected alternatives
- Permanent organization code typed into the bot — forgeable and forwardable forever.
- Binding by phone number alone — weak proof of Pay cabinet ownership.
- WhatsApp in the same MVP — out of scope.