Skip to content

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:

  1. Telegram is a transport + identity binding layer only. Parsing, recognition, duplicate detection, matching, allocation, and posting stay in existing Pay modules.
  2. A file is imported into an organization only when an Active link exists for that Telegram user.
  3. 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).
  4. 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 (/org and similar) — deferred.
  • Using Telegram identity as a login method for the SPA.

Binding model (cabinet ↔ bot) ​

Entities ​

TelegramAccountLink

FieldRule
IdUUID v7
PayUserIdPay user who owns the link
OrganizationIdTarget org for imports (from active auth context at challenge creation)
TelegramUserIdStable Telegram numeric user id
TelegramChatIdChat for bot replies
StatusActive | Revoked
LinkedAt / RevokedAtAudit timestamps

Uniqueness (v1):

  • At most one Active link per TelegramUserId.
  • At most one Active link per (PayUserId, OrganizationId).

TelegramLinkChallenge

FieldRule
CodeShort opaque code (e.g. A7K9Q2), not an org id
PayUserId, OrganizationIdSnapshot from authenticated session
ExpiresAt5–15 minutes
ConsumedAtSet on successful /start
AttemptsFailed consume counter for brute-force limits
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 here
  • 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 import

Without 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 ​

ThreatControl
Guessing a permanent org/client idNo permanent public binding id; only single-use challenges
Forwarded deep linkFirst successful consume wins; second fails
Stolen fresh codeShort TTL + rate limit on challenge consume
Unlinked user sends filesReject; no import
One Telegram bound to two cabinetsForbidden while Active (must unlink first)
Former employeeRevoke link in Pay
Webhook spoofingTelegram__WebhookSecret (secret token header) and/or trusted update verification
SpamPer-link rate limits; max size; MIME whitelist
Leaked bot tokenToken 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 ​

KeySourceNotes
Telegram__Enabledenv / appsettingsFeature flag; default false until rollout
Telegram__BotTokenenv only in productionNever commit real token; never log
Telegram__WebhookSecretenvValidates Telegram webhook calls
Telegram__BotUsernameenv / appsettingsFor 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 ​

ConcernModule
Link / challenge use cases + DTOsApplication
EF entities / storePersistence
Authenticated link/unlink APIApi (JWT)
Telegram webhook endpointApi (webhook secret scheme, not user JWT)
Bot API client (download/send)Infrastructure
Import reuseBankStatements / existing recognition path
Link status + challenge UIFrontend Settings

Do not duplicate bank parsers or recognition inside a Telegram-specific project.

Implementation phases ​

  1. Docs + flag off — this ADR; config skeleton; no production webhook.
  2. Link/unlink API + UI — challenges, consume rules, revoke; unit tests for expired/consumed/already-linked.
  3. Bot webhook — /start binding only; unlinked messages rejected.
  4. Upload path — file → existing import + audit + chat reply.
  5. Hardening — rate limits, metrics, retention aligned with BankFiles policy.

Open product choices (decide before coding phase 4) ​

  1. Multi-org users (v1): one Active link = one org (active context at challenge time). Changing org requires unlink + re-link. Bot /org deferred.
  2. Multiple staff in one org: allowed — each Pay user may have their own Telegram link into the same OrganizationId (better audit).
  3. 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.