diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..3a9ce24
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,15 @@
+.git
+.github
+.env
+.venv
+__pycache__
+*.pyc
+*.db
+.coverage
+htmlcov
+.mypy_cache
+.pytest_cache
+.ruff_cache
+tests
+docs
+
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..1fc587e
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,22 @@
+# Telegram
+TELEGRAM_BOT_TOKEN=
+TELEGRAM_WEBHOOK_SECRET=
+
+# Public HTTPS origin for both webhook endpoints, without a trailing slash
+PUBLIC_BASE_URL=https://bot.example.com
+
+# MakePay / MakeCrypto server-side API credentials
+MAKEPAY_KEY_ID=
+MAKEPAY_KEY_SECRET=
+MAKEPAY_WEBHOOK_SECRET=
+
+# Optional runtime configuration
+MAKEPAY_API_BASE_URL=https://www.makecrypto.io
+MAKEPAY_CHECKOUT_BASE_URL=https://www.makepay.io
+DATABASE_PATH=./data/bot.db
+CATALOG_PATH=./catalog.json
+RECONCILE_INTERVAL_SECONDS=60
+NOTIFICATION_INTERVAL_SECONDS=10
+MAX_WEBHOOK_BODY_BYTES=1048576
+LOG_LEVEL=INFO
+
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..41f4772
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,23 @@
+version: 2
+updates:
+ - package-ecosystem: pip
+ directory: /
+ schedule:
+ interval: weekly
+ groups:
+ python-dependencies:
+ patterns: ["*"]
+
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ groups:
+ github-actions:
+ patterns: ["*"]
+
+ - package-ecosystem: docker
+ directory: /
+ schedule:
+ interval: weekly
+
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..53bf69b
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,17 @@
+## What changed
+
+
+
+## Security and payment impact
+
+
+
+## Validation
+
+- [ ] `ruff check .`
+- [ ] `ruff format --check .`
+- [ ] `mypy`
+- [ ] `pytest --cov --cov-report=term-missing`
+- [ ] `pip-audit --progress-spinner off`
+- [ ] Container build
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..3693aaf
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,66 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: Python ${{ matrix.python-version }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.12", "3.13"]
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: pip
+
+ - name: Install project and checks
+ run: python -m pip install --upgrade pip && python -m pip install -e '.[dev]'
+
+ - name: Lint
+ run: ruff check .
+
+ - name: Check formatting
+ run: ruff format --check .
+
+ - name: Type check
+ if: matrix.python-version == '3.12'
+ run: mypy
+
+ - name: Test
+ run: pytest --cov --cov-report=term-missing
+
+ - name: Audit dependencies
+ if: matrix.python-version == '3.12'
+ run: pip-audit --progress-spinner off
+
+ - name: Build wheel
+ run: python -m pip wheel . --no-deps --wheel-dir dist
+
+ container:
+ name: Container build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Build image
+ run: docker build --tag makepay-telegram-bot:test .
+
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..4c6bc0d
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,30 @@
+name: CodeQL
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ schedule:
+ - cron: "23 4 * * 1"
+
+permissions:
+ contents: read
+ security-events: write
+
+jobs:
+ analyze:
+ name: Analyze Python
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
+ with:
+ languages: python
+
+ - name: Analyze
+ uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0f4a02a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+.env
+.venv/
+__pycache__/
+*.py[cod]
+*.db
+*.db-shm
+*.db-wal
+.coverage
+htmlcov/
+.mypy_cache/
+.pytest_cache/
+.ruff_cache/
+build/
+dist/
+*.egg-info/
+.DS_Store
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..24d926c
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,25 @@
+# Contributing
+
+Issues and pull requests are welcome.
+
+1. Fork the repository and create a focused branch.
+2. Install the development dependencies with
+ `python -m pip install -e '.[dev]'`.
+3. Add or update tests for behavioral changes.
+4. Run:
+
+ ```bash
+ ruff check .
+ ruff format --check .
+ mypy
+ pytest --cov --cov-report=term-missing
+ pip-audit
+ ```
+
+5. Describe the user impact, security implications, and validation in the pull
+ request.
+
+Never commit credentials, Telegram updates from real users, checkout URLs, or
+production webhook payloads. By contributing, you agree that your contribution
+is licensed under the MIT License.
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..ef9a7eb
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,26 @@
+FROM python:3.12.13-slim-bookworm
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
+ PIP_NO_CACHE_DIR=1
+
+WORKDIR /app
+
+RUN groupadd --system app && useradd --system --gid app --home-dir /app app
+
+COPY pyproject.toml README.md LICENSE ./
+COPY src ./src
+RUN python -m pip install .
+
+COPY catalog.json ./
+RUN mkdir -p /app/data && chown -R app:app /app
+
+USER app
+EXPOSE 8000
+
+HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+ CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"]
+
+CMD ["makepay-telegram-bot", "serve", "--host", "0.0.0.0", "--port", "8000"]
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4faa9ae
--- /dev/null
+++ b/README.md
@@ -0,0 +1,209 @@
+# MakePay Telegram Bot
+
+An open-source, production-minded starter for accepting cryptocurrency payments
+from a Telegram bot with [MakePay](https://www.makepay.io/).
+
+The bot presents a small product catalog, creates an idempotent MakePay payment
+link, and sends the buyer to MakePay's hosted checkout. Signed MakePay webhooks
+and background reconciliation update a durable local order record and notify the
+buyer in Telegram.
+
+> This repository accepts payments; it does not implement product fulfillment.
+> Connect your own fulfillment only after an order reaches the local `paid`
+> state.
+
+## Why this starter
+
+- The bot never receives cryptocurrency, seed phrases, or private keys.
+- Telegram and MakePay webhooks are independently authenticated.
+- Payment-link creation is idempotent, so Telegram retries cannot create
+ duplicate checkouts.
+- SQLite stores orders, webhook deduplication keys, and a retryable notification
+ outbox.
+- A reconciliation worker recovers status changes if a webhook is delayed or
+ missed.
+- The container runs as a non-root user and exposes health/readiness endpoints.
+- Tests cover input validation, order transitions, webhook signatures,
+ credential isolation, and API behavior.
+
+## Payment flow
+
+```mermaid
+sequenceDiagram
+ participant U as Telegram user
+ participant B as Bot
+ participant M as MakePay
+ participant D as SQLite
+
+ U->>B: Choose a product
+ B->>D: Create/get order by callback ID
+ B->>M: Create payment link with idempotency key
+ M-->>B: Hosted checkout URL
+ B->>D: Store payment UID and URL
+ B-->>U: Pay securely with MakePay
+ U->>M: Complete hosted checkout
+ M->>B: Signed status webhook
+ B->>D: Deduplicate and update order
+ B-->>U: Payment status notification
+```
+
+See [the architecture notes](docs/architecture.md) for state transitions,
+failure handling, and scaling guidance.
+
+## Quick start with Docker
+
+You need:
+
+- a Telegram bot token from [@BotFather](https://t.me/BotFather);
+- a public HTTPS URL pointing to this service;
+- a MakePay API key ID and secret;
+- the MakePay webhook signing secret configured for your callback.
+
+Clone the repository and create your environment file:
+
+```bash
+git clone https://github.com/makepay-io/makepay-telegram-bot.git
+cd makepay-telegram-bot
+cp .env.example .env
+openssl rand -hex 32
+```
+
+Put the generated value in `TELEGRAM_WEBHOOK_SECRET`, fill the other values in
+`.env`, and edit `catalog.json`. Then start the bot:
+
+```bash
+docker compose up --build
+```
+
+The service registers its Telegram webhook on startup. In MakePay, set the
+company webhook/callback URL to:
+
+```text
+https://YOUR_PUBLIC_HOST/webhooks/makepay
+```
+
+Store the associated signing secret as `MAKEPAY_WEBHOOK_SECRET`. Do not reuse
+the Telegram secret or your MakePay API secret.
+
+Open the bot in a private Telegram chat and send `/shop`.
+
+## Local development
+
+Use Python 3.12 or newer:
+
+```bash
+python -m venv .venv
+source .venv/bin/activate
+python -m pip install -e '.[dev]'
+cp .env.example .env
+set -a
+source .env
+set +a
+makepay-telegram-bot serve --port 8000
+```
+
+Telegram and MakePay require a public HTTPS callback, so expose port `8000`
+through the tunnel or ingress of your choice and set `PUBLIC_BASE_URL` to that
+origin. Plain HTTP is accepted only for `localhost` when
+`ALLOW_INSECURE_LOCALHOST=true`.
+
+Useful commands:
+
+```bash
+makepay-telegram-bot set-telegram-webhook
+makepay-telegram-bot delete-telegram-webhook
+pytest --cov --cov-report=term-missing
+ruff check .
+ruff format --check .
+mypy
+pip-audit
+```
+
+## Configure the catalog
+
+`catalog.json` is intentionally simple:
+
+```json
+[
+ {
+ "id": "coffee",
+ "name": "Coffee voucher",
+ "description": "A demo voucher fulfilled by the merchant after payment.",
+ "amount": "5.00",
+ "fiatCurrency": "USD"
+ }
+]
+```
+
+Rules:
+
+- `id` must be 1–32 lowercase letters, numbers, `_`, or `-`;
+- `amount` must be a positive decimal string with at most two decimal places;
+- `fiatCurrency` must be a three-letter ISO code;
+- the configured MakePay account controls settlement assets and addresses.
+
+Restart the service after changing the catalog.
+
+## Environment variables
+
+| Variable | Required | Purpose |
+| --- | --- | --- |
+| `TELEGRAM_BOT_TOKEN` | Yes | Token issued by BotFather |
+| `TELEGRAM_WEBHOOK_SECRET` | Yes | Random secret Telegram sends in its webhook header |
+| `PUBLIC_BASE_URL` | Yes | Public HTTPS origin, without a path |
+| `MAKEPAY_KEY_ID` | Yes | MakePay/MakeCrypto API key identifier |
+| `MAKEPAY_KEY_SECRET` | Yes | MakePay/MakeCrypto API key secret |
+| `MAKEPAY_WEBHOOK_SECRET` | Yes | Secret used to verify `x-makepay-signature` |
+| `MAKEPAY_API_BASE_URL` | No | Defaults to `https://www.makecrypto.io` |
+| `MAKEPAY_CHECKOUT_BASE_URL` | No | Defaults to `https://www.makepay.io` |
+| `DATABASE_PATH` | No | Defaults to `./data/bot.db` |
+| `CATALOG_PATH` | No | Defaults to `./catalog.json` |
+| `RECONCILE_INTERVAL_SECONDS` | No | Status recovery interval; default `60` |
+| `NOTIFICATION_INTERVAL_SECONDS` | No | Outbox retry interval; default `10` |
+| `MAX_WEBHOOK_BODY_BYTES` | No | Request limit; default 1 MiB |
+| `LOG_LEVEL` | No | `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` |
+
+`ADMIN_TELEGRAM_USER_IDS` is reserved for merchant extensions. The starter does
+not expose buyer or order administration through Telegram.
+
+## Webhook behavior
+
+- Telegram requests must contain the exact
+ `x-telegram-bot-api-secret-token`.
+- MakePay signatures cover `timestamp + "." + exact_raw_body` using HMAC-SHA256.
+- MakePay timestamps have a five-minute tolerance.
+- `x-makepay-delivery-group-id` is the preferred deduplication key; older
+ deliveries fall back to `deliveryId`.
+- Unknown but valid MakePay events are acknowledged without changing an order.
+- A `paid` order never downgrades. A late confirmed payment may recover an
+ earlier failed or expired order.
+
+## Production checklist
+
+- Run exactly one replica while using SQLite.
+- Mount `/app/data` on persistent storage and back it up.
+- Terminate TLS at a trusted ingress and forward only to the container port.
+- Restrict secret access to the service and rotate credentials after exposure.
+- Configure MakePay settlement assets and addresses before taking live orders.
+- Test the complete payment and refund/support journey with a low-value order.
+- Replace the sample catalog and connect idempotent fulfillment to the `paid`
+ transition.
+- For multiple replicas, move orders, webhook deduplication, and the outbox to
+ Postgres and use row-level work claiming.
+
+The public HTTP endpoints are:
+
+- `GET /healthz` — process liveness;
+- `GET /readyz` — database and Telegram application readiness;
+- `POST /webhooks/telegram` — authenticated Telegram updates;
+- `POST /webhooks/makepay` — signed MakePay events.
+
+## Security
+
+Please read [SECURITY.md](SECURITY.md) before reporting a vulnerability. The bot
+deliberately avoids logging payloads, checkout URLs, user IDs, and secrets.
+
+## License
+
+[MIT](LICENSE)
+
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..42085a5
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,36 @@
+# Security policy
+
+## Supported versions
+
+Security fixes are applied to the latest release on `main`.
+
+## Report a vulnerability
+
+Please do not open a public issue for a suspected vulnerability. Email
+`security@makepay.io` with:
+
+- the affected version or commit;
+- clear reproduction steps;
+- expected and observed impact;
+- any suggested remediation.
+
+Do not include live API keys, webhook secrets, Telegram bot tokens, wallet
+credentials, or other third-party personal data. Use test credentials and
+redacted request samples.
+
+We aim to acknowledge a report within three business days. We will coordinate
+validation, remediation, release timing, and credit with the reporter.
+
+## Security boundaries
+
+This starter:
+
+- creates MakePay hosted payment links;
+- keeps Telegram identity and order mapping in the local database;
+- verifies Telegram and MakePay webhook authenticity;
+- never requests or stores seed phrases, private keys, or payer wallet secrets.
+
+The operator remains responsible for host security, TLS termination, secret
+storage, database backups, MakePay account configuration, fulfillment, support,
+and legal/compliance obligations.
+
diff --git a/catalog.json b/catalog.json
new file mode 100644
index 0000000..9d5e359
--- /dev/null
+++ b/catalog.json
@@ -0,0 +1,24 @@
+[
+ {
+ "id": "coffee",
+ "name": "Coffee voucher",
+ "description": "A demo voucher fulfilled by the merchant after payment.",
+ "amount": "5.00",
+ "fiatCurrency": "USD"
+ },
+ {
+ "id": "sticker-pack",
+ "name": "Digital sticker pack",
+ "description": "A sample digital item fulfilled by the merchant after payment.",
+ "amount": "12.00",
+ "fiatCurrency": "USD"
+ },
+ {
+ "id": "supporter",
+ "name": "Supporter package",
+ "description": "A sample supporter package fulfilled by the merchant after payment.",
+ "amount": "49.00",
+ "fiatCurrency": "USD"
+ }
+]
+
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..01b83a3
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,14 @@
+services:
+ bot:
+ build: .
+ env_file: .env
+ restart: unless-stopped
+ ports:
+ - "8000:8000"
+ volumes:
+ - bot-data:/app/data
+ - ./catalog.json:/app/catalog.json:ro
+
+volumes:
+ bot-data:
+
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..e8d5a8b
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,85 @@
+# Architecture
+
+## Scope
+
+The starter demonstrates the payment boundary of a Telegram commerce bot:
+catalog selection, MakePay checkout creation, authenticated status ingestion,
+local order state, and buyer notifications. Inventory, taxes, refunds,
+fulfillment, and merchant administration are intentionally outside its scope.
+
+## Trust boundaries
+
+1. Telegram is trusted only after the secret-token header matches.
+2. MakePay is trusted only after its timestamped HMAC signature verifies
+ against the exact raw request body.
+3. A signed MakePay event is correlated to an existing local order by payment
+ UID or the random UUID sent as `orderId`.
+4. Hosted checkout URLs come only from the authenticated MakePay API response
+ or the configured MakePay checkout origin.
+5. Buyers can inspect only orders matching both their Telegram user ID and chat
+ ID.
+
+Telegram identity is never sent to MakePay. MakePay receives the random local
+order ID and non-sensitive integration metadata.
+
+## Order state
+
+```mermaid
+stateDiagram-v2
+ [*] --> creating
+ creating --> pending: payment link stored
+ creating --> creation_failed: API error
+ creation_failed --> pending: idempotent retry
+ pending --> processing: deposit received
+ pending --> underpaid
+ processing --> underpaid
+ pending --> paid
+ processing --> paid
+ underpaid --> paid
+ pending --> failed
+ pending --> expired
+ pending --> cancelled
+ failed --> paid: late confirmation
+ expired --> paid: late confirmation
+ cancelled --> paid: late confirmation
+ paid --> paid: terminal
+```
+
+Unknown MakePay states conservatively map to `pending`. A terminal state does
+not regress, except any state may advance to `paid`.
+
+## Idempotency and delivery
+
+- Telegram callback query IDs have a unique constraint, so a retried update
+ loads the original order.
+- The MakePay request uses `telegram-{order UUID}` as its `Idempotency-Key`.
+ Retries send the same body with the same key.
+- Webhook retries use `x-makepay-delivery-group-id` as the unique key. Legacy
+ deliveries use `deliveryId`; a final body hash fallback protects very old
+ payloads.
+- Order updates and notification creation occur in one SQLite transaction.
+- Telegram notification delivery is at least once internally and effectively
+ once after `sent_at` is stored. A process crash immediately after Telegram
+ accepts a message can produce a duplicate message; order state remains
+ correct.
+
+## Recovery
+
+The reconciliation worker reads MakePay's public current-session endpoint for
+non-terminal orders. This covers a missing webhook and drives the same
+transactional update path. Manual “Check payment status” actions use the same
+mechanism.
+
+## Deployment model
+
+SQLite WAL mode is appropriate for a single process with a persistent volume.
+Do not run multiple replicas against one SQLite file on network storage.
+
+For horizontal scaling, replace `Database` with Postgres and:
+
+- preserve unique constraints on request and webhook deduplication keys;
+- claim outbox rows with `FOR UPDATE SKIP LOCKED`;
+- run migrations separately from application startup;
+- add bounded retries and dead-letter monitoring;
+- rate-limit reconciliation per MakePay account.
+
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..323b223
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,89 @@
+[build-system]
+requires = ["hatchling>=1.27,<2"]
+build-backend = "hatchling.build"
+
+[project]
+name = "makepay-telegram-bot"
+version = "1.0.0"
+description = "Production-minded Telegram shop bot starter for accepting cryptocurrency with MakePay"
+readme = "README.md"
+requires-python = ">=3.12"
+license = "MIT"
+license-files = ["LICENSE"]
+authors = [{ name = "MakePay", email = "support@makepay.io" }]
+keywords = [
+ "makepay",
+ "telegram",
+ "telegram-bot",
+ "crypto-payments",
+ "payment-links",
+]
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Framework :: FastAPI",
+ "License :: OSI Approved :: MIT License",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Office/Business :: Financial",
+]
+dependencies = [
+ "fastapi==0.140.0",
+ "httpx==0.28.1",
+ "makepay==0.3.0",
+ "python-telegram-bot==22.8",
+ "uvicorn[standard]==0.51.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "mypy==2.3.0",
+ "pip-audit==2.10.1",
+ "pytest==9.1.1",
+ "pytest-cov==7.1.0",
+ "ruff==0.16.0",
+]
+
+[project.scripts]
+makepay-telegram-bot = "makepay_telegram_bot.cli:main"
+
+[project.urls]
+Documentation = "https://github.com/makepay-io/makepay-telegram-bot#readme"
+Issues = "https://github.com/makepay-io/makepay-telegram-bot/issues"
+Repository = "https://github.com/makepay-io/makepay-telegram-bot"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/makepay_telegram_bot"]
+
+[tool.pytest.ini_options]
+addopts = "--strict-config --strict-markers"
+testpaths = ["tests"]
+
+[tool.coverage.run]
+branch = true
+source = ["makepay_telegram_bot"]
+omit = [
+ "src/makepay_telegram_bot/__main__.py",
+ "src/makepay_telegram_bot/cli.py",
+]
+
+[tool.coverage.report]
+fail_under = 85
+show_missing = true
+skip_covered = true
+
+[tool.ruff]
+line-length = 100
+target-version = "py312"
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "B", "UP", "ASYNC", "S", "RUF"]
+ignore = ["S101"]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**/*.py" = ["S105", "S106"]
+
+[tool.mypy]
+python_version = "3.12"
+strict = true
+warn_unreachable = true
+packages = ["makepay_telegram_bot"]
diff --git a/src/makepay_telegram_bot/__init__.py b/src/makepay_telegram_bot/__init__.py
new file mode 100644
index 0000000..441307a
--- /dev/null
+++ b/src/makepay_telegram_bot/__init__.py
@@ -0,0 +1,3 @@
+"""MakePay Telegram bot starter."""
+
+__version__ = "1.0.0"
diff --git a/src/makepay_telegram_bot/__main__.py b/src/makepay_telegram_bot/__main__.py
new file mode 100644
index 0000000..4e28416
--- /dev/null
+++ b/src/makepay_telegram_bot/__main__.py
@@ -0,0 +1,3 @@
+from .cli import main
+
+main()
diff --git a/src/makepay_telegram_bot/app.py b/src/makepay_telegram_bot/app.py
new file mode 100644
index 0000000..e2e1d12
--- /dev/null
+++ b/src/makepay_telegram_bot/app.py
@@ -0,0 +1,230 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import secrets
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager, suppress
+from dataclasses import dataclass
+
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.responses import JSONResponse
+from makepay import MakePayError, parse_makepay_webhook
+from telegram import Update
+
+from . import __version__
+from .bot import BOT_COMMANDS, build_telegram_application
+from .catalog import load_catalog
+from .config import Settings
+from .database import Database
+from .makepay_client import MakePayClient
+from .telegram_types import TelegramApplication
+from .webhooks import extract_makepay_event
+from .workers import notification_loop, reconciliation_loop
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(slots=True)
+class Runtime:
+ settings: Settings
+ database: Database
+ makepay: MakePayClient
+ telegram: TelegramApplication
+ notification_wakeup: asyncio.Event
+
+
+def _configure_logging(level: str) -> None:
+ logging.basicConfig(
+ level=getattr(logging, level),
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
+ )
+
+
+def _body_too_large(request: Request, limit: int) -> bool:
+ value = request.headers.get("content-length")
+ if value is None:
+ return False
+ try:
+ return int(value) > limit
+ except ValueError:
+ return True
+
+
+def _runtime(request: Request) -> Runtime:
+ runtime = getattr(request.app.state, "runtime", None)
+ if not isinstance(runtime, Runtime):
+ raise HTTPException(status_code=503, detail="Service is starting.")
+ return runtime
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI) -> AsyncIterator[None]:
+ settings = Settings.from_env()
+ _configure_logging(settings.log_level)
+ catalog = load_catalog(settings.catalog_path)
+ database = Database(settings.database_path)
+ makepay = MakePayClient(
+ key_id=settings.makepay_key_id,
+ key_secret=settings.makepay_key_secret,
+ api_base_url=settings.makepay_api_base_url,
+ checkout_base_url=settings.makepay_checkout_base_url,
+ )
+ telegram = build_telegram_application(
+ token=settings.telegram_bot_token,
+ catalog=catalog,
+ database=database,
+ makepay=makepay,
+ )
+ notification_wakeup = asyncio.Event()
+ await telegram.initialize()
+ await telegram.bot.set_my_commands(BOT_COMMANDS)
+ await telegram.bot.set_webhook(
+ url=settings.telegram_webhook_url,
+ secret_token=settings.telegram_webhook_secret,
+ allowed_updates=Update.ALL_TYPES,
+ drop_pending_updates=False,
+ )
+ await telegram.start()
+ app.state.runtime = Runtime(
+ settings=settings,
+ database=database,
+ makepay=makepay,
+ telegram=telegram,
+ notification_wakeup=notification_wakeup,
+ )
+ tasks = [
+ asyncio.create_task(
+ notification_loop(
+ application=telegram,
+ database=database,
+ wakeup=notification_wakeup,
+ interval_seconds=settings.notification_interval_seconds,
+ ),
+ name="notification-outbox",
+ ),
+ asyncio.create_task(
+ reconciliation_loop(
+ makepay=makepay,
+ database=database,
+ notification_wakeup=notification_wakeup,
+ interval_seconds=settings.reconcile_interval_seconds,
+ ),
+ name="makepay-reconciliation",
+ ),
+ ]
+ logger.info("MakePay Telegram bot started")
+ try:
+ yield
+ finally:
+ app.state.runtime = None
+ for task in tasks:
+ task.cancel()
+ for task in tasks:
+ with suppress(asyncio.CancelledError):
+ await task
+ await telegram.stop()
+ await telegram.shutdown()
+ await makepay.close()
+ database.close()
+ logger.info("MakePay Telegram bot stopped")
+
+
+def create_app() -> FastAPI:
+ app = FastAPI(
+ title="MakePay Telegram Bot",
+ version=__version__,
+ docs_url=None,
+ redoc_url=None,
+ lifespan=lifespan,
+ )
+
+ @app.get("/")
+ async def root() -> dict[str, str]:
+ return {"name": "makepay-telegram-bot", "version": __version__}
+
+ @app.get("/healthz")
+ async def health() -> dict[str, str]:
+ return {"status": "ok"}
+
+ @app.get("/readyz")
+ async def ready(request: Request) -> dict[str, str]:
+ runtime = _runtime(request)
+ if not runtime.database.ping() or not runtime.telegram.running:
+ raise HTTPException(status_code=503, detail="Service is not ready.")
+ return {"status": "ready"}
+
+ @app.post("/webhooks/telegram")
+ async def telegram_webhook(request: Request) -> JSONResponse:
+ runtime = _runtime(request)
+ provided_secret = request.headers.get("x-telegram-bot-api-secret-token", "")
+ if not secrets.compare_digest(
+ provided_secret.encode(), runtime.settings.telegram_webhook_secret.encode()
+ ):
+ raise HTTPException(status_code=401, detail="Invalid Telegram webhook secret.")
+ if _body_too_large(request, runtime.settings.max_webhook_body_bytes):
+ raise HTTPException(status_code=413, detail="Webhook body is too large.")
+ raw_body = await request.body()
+ if len(raw_body) > runtime.settings.max_webhook_body_bytes:
+ raise HTTPException(status_code=413, detail="Webhook body is too large.")
+ try:
+ payload = json.loads(raw_body)
+ update = Update.de_json(payload, runtime.telegram.bot)
+ except (json.JSONDecodeError, TypeError, ValueError) as error:
+ raise HTTPException(status_code=400, detail="Invalid Telegram update.") from error
+ if runtime.telegram.update_queue.qsize() >= 1000:
+ raise HTTPException(status_code=503, detail="Telegram update queue is full.")
+ await runtime.telegram.update_queue.put(update)
+ return JSONResponse({"ok": True})
+
+ @app.post("/webhooks/makepay")
+ async def makepay_webhook(request: Request) -> JSONResponse:
+ runtime = _runtime(request)
+ if _body_too_large(request, runtime.settings.max_webhook_body_bytes):
+ raise HTTPException(status_code=413, detail="Webhook body is too large.")
+ raw_body = await request.body()
+ if len(raw_body) > runtime.settings.max_webhook_body_bytes:
+ raise HTTPException(status_code=413, detail="Webhook body is too large.")
+ try:
+ payload = parse_makepay_webhook(
+ raw_body,
+ request.headers.get("x-makepay-signature"),
+ runtime.settings.makepay_webhook_secret,
+ )
+ except MakePayError as error:
+ raise HTTPException(
+ status_code=error.status if error.status in {400, 401} else 401,
+ detail="Invalid MakePay webhook.",
+ ) from error
+
+ event = extract_makepay_event(
+ payload,
+ {key.lower(): value for key, value in request.headers.items()},
+ raw_body,
+ )
+ if event.status is None:
+ return JSONResponse({"ok": True, "matched": False})
+ result = runtime.database.apply_payment_status(
+ dedupe_key=event.dedupe_key,
+ delivery_id=event.delivery_id,
+ event_type=event.event_type,
+ makepay_status=event.status,
+ payment_uid=event.payment_uid,
+ merchant_order_id=event.merchant_order_id,
+ session_id=event.session_id,
+ )
+ if result.changed:
+ runtime.notification_wakeup.set()
+ return JSONResponse(
+ {
+ "ok": True,
+ "matched": result.order is not None,
+ "duplicate": result.duplicate,
+ }
+ )
+
+ return app
+
+
+app = create_app()
diff --git a/src/makepay_telegram_bot/bot.py b/src/makepay_telegram_bot/bot.py
new file mode 100644
index 0000000..61b7fe7
--- /dev/null
+++ b/src/makepay_telegram_bot/bot.py
@@ -0,0 +1,279 @@
+from __future__ import annotations
+
+import html
+import logging
+
+from telegram import (
+ BotCommand,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ Message,
+ Update,
+)
+from telegram.constants import ChatType, ParseMode
+from telegram.ext import (
+ ApplicationBuilder,
+ CallbackQueryHandler,
+ CommandHandler,
+ ContextTypes,
+)
+
+from .database import Database
+from .makepay_client import MakePayAPIError, MakePayClient
+from .models import Order, Product
+from .telegram_types import TelegramApplication
+
+logger = logging.getLogger(__name__)
+
+
+def _money(amount: str, currency: str) -> str:
+ return f"{amount} {currency}"
+
+
+def _status_label(status: str) -> str:
+ return {
+ "creating": "creating checkout",
+ "creation_failed": "checkout creation failed",
+ "pending": "awaiting payment",
+ "processing": "processing",
+ "underpaid": "underpaid",
+ "paid": "paid",
+ "expired": "expired",
+ "cancelled": "cancelled",
+ "failed": "failed",
+ }.get(status, status)
+
+
+class BotHandlers:
+ def __init__(
+ self,
+ *,
+ catalog: dict[str, Product],
+ database: Database,
+ makepay: MakePayClient,
+ ) -> None:
+ self._catalog = catalog
+ self._database = database
+ self._makepay = makepay
+
+ async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ del context
+ if not await self._require_private_chat(update):
+ return
+ message = update.effective_message
+ if message is None:
+ return
+ await message.reply_text(
+ "Welcome! This demo shop creates a secure MakePay checkout inside "
+ "Telegram. Choose /shop to browse or /orders to check your payments."
+ )
+
+ async def help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ del context
+ message = update.effective_message
+ if message is None:
+ return
+ await message.reply_text(
+ "/shop — browse products\n"
+ "/orders — view your five latest orders\n"
+ "/help — show this message\n\n"
+ "Payments happen on MakePay's hosted checkout. The bot never asks "
+ "for a seed phrase, private key, or card details."
+ )
+
+ async def shop(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ del context
+ if not await self._require_private_chat(update):
+ return
+ message = update.effective_message
+ if message is None:
+ return
+ keyboard = [
+ [
+ InlineKeyboardButton(
+ f"{product.name} · {_money(product.amount, product.fiat_currency)}",
+ callback_data=f"buy:{product.id}",
+ )
+ ]
+ for product in self._catalog.values()
+ ]
+ await message.reply_text(
+ "Choose a product. You will review and pay on MakePay's hosted checkout.",
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ )
+
+ async def orders(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ del context
+ if not await self._require_private_chat(update):
+ return
+ message = update.effective_message
+ user = update.effective_user
+ chat = update.effective_chat
+ if message is None or user is None or chat is None:
+ return
+ orders = self._database.list_orders(chat.id, user.id)
+ if not orders:
+ await message.reply_text("You do not have any orders yet. Use /shop to start.")
+ return
+ lines = ["Your latest orders:"]
+ for order in orders:
+ lines.append(
+ f"• {html.escape(order.product_name)} · "
+ f"{_money(order.amount, order.fiat_currency)} · "
+ f"{_status_label(order.status)} · #{order.id[:8]}"
+ )
+ await message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML)
+
+ async def buy(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ del context
+ query = update.callback_query
+ user = update.effective_user
+ chat = update.effective_chat
+ message = update.effective_message
+ if query is None or user is None or chat is None or message is None:
+ return
+ if chat.type != ChatType.PRIVATE:
+ await query.answer("Open the bot in a private chat to buy.", show_alert=True)
+ return
+ await query.answer("Creating a secure checkout…")
+ product_id = (query.data or "").removeprefix("buy:")
+ product = self._catalog.get(product_id)
+ if product is None:
+ await message.reply_text("That product is no longer available. Use /shop again.")
+ return
+
+ order = self._database.create_or_get_order(
+ request_key=f"telegram-callback:{query.id}",
+ telegram_chat_id=chat.id,
+ telegram_user_id=user.id,
+ product=product,
+ )
+ if order.checkout_url:
+ await self._send_checkout(message, order)
+ return
+
+ try:
+ link = await self._makepay.create_payment_link(order, product)
+ order = self._database.attach_payment_link(order.id, link.uid, link.checkout_url)
+ except MakePayAPIError as error:
+ logger.warning(
+ "MakePay checkout creation failed for order %s with status %s",
+ order.id,
+ error.status_code,
+ )
+ self._database.mark_creation_failed(
+ order.id, f"makepay_http_{error.status_code or 'unknown'}"
+ )
+ await message.reply_text(
+ f"We could not create checkout for order #{order.id[:8]}. "
+ "Please tap the product again in /shop; the retry is safe."
+ )
+ return
+ await self._send_checkout(message, order)
+
+ async def status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ del context
+ query = update.callback_query
+ user = update.effective_user
+ chat = update.effective_chat
+ message = update.effective_message
+ if query is None or user is None or chat is None or message is None:
+ return
+ order_id = (query.data or "").removeprefix("status:")
+ order = self._database.get_order_for_owner(order_id, chat.id, user.id)
+ if order is None:
+ await query.answer("Order not found.", show_alert=True)
+ return
+ if order.makepay_payment_uid and order.status not in {
+ "paid",
+ "cancelled",
+ "expired",
+ "failed",
+ }:
+ try:
+ remote = await self._makepay.get_payment_status(order.makepay_payment_uid)
+ result = self._database.apply_payment_status(
+ dedupe_key=(
+ f"manual:{order.makepay_payment_uid}:{remote.status}:"
+ f"{remote.session_id or 'none'}"
+ ),
+ makepay_status=remote.status,
+ payment_uid=order.makepay_payment_uid,
+ merchant_order_id=order.id,
+ session_id=remote.session_id,
+ event_type="manual_reconciliation",
+ )
+ order = result.order or order
+ except MakePayAPIError:
+ logger.warning("Manual status refresh failed for order %s", order.id)
+ await query.answer(f"Order #{order.id[:8]} is {_status_label(order.status)}.")
+
+ async def error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
+ logger.error(
+ "Unhandled Telegram update error",
+ exc_info=context.error,
+ extra={"update_type": type(update).__name__},
+ )
+
+ async def _require_private_chat(self, update: Update) -> bool:
+ chat = update.effective_chat
+ message = update.effective_message
+ if chat is not None and chat.type == ChatType.PRIVATE:
+ return True
+ if message is not None:
+ await message.reply_text(
+ "For privacy, purchases are available only in a private chat with this bot."
+ )
+ return False
+
+ async def _send_checkout(self, message: Message, order: Order) -> None:
+ if not order.checkout_url:
+ return
+ keyboard = InlineKeyboardMarkup(
+ [
+ [InlineKeyboardButton("Pay securely with MakePay", url=order.checkout_url)],
+ [InlineKeyboardButton("Check payment status", callback_data=f"status:{order.id}")],
+ ]
+ )
+ await message.reply_text(
+ (
+ f"{html.escape(order.product_name)}\n"
+ f"Total: {_money(order.amount, order.fiat_currency)}\n"
+ f"Order: {order.id}\n\n"
+ "Use the button below. Never share a seed phrase or private key."
+ ),
+ parse_mode=ParseMode.HTML,
+ reply_markup=keyboard,
+ disable_web_page_preview=True,
+ )
+
+
+def build_telegram_application(
+ *,
+ token: str,
+ catalog: dict[str, Product],
+ database: Database,
+ makepay: MakePayClient,
+) -> TelegramApplication:
+ application = ApplicationBuilder().token(token).concurrent_updates(False).build()
+ handlers = BotHandlers(catalog=catalog, database=database, makepay=makepay)
+ application.add_handler(CommandHandler("start", handlers.start))
+ application.add_handler(CommandHandler("help", handlers.help))
+ application.add_handler(CommandHandler("shop", handlers.shop))
+ application.add_handler(CommandHandler("orders", handlers.orders))
+ application.add_handler(CallbackQueryHandler(handlers.buy, pattern=r"^buy:[a-z0-9_-]{1,32}$"))
+ application.add_handler(
+ CallbackQueryHandler(
+ handlers.status,
+ pattern=r"^status:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
+ )
+ )
+ application.add_error_handler(handlers.error)
+ return application
+
+
+BOT_COMMANDS = [
+ BotCommand("shop", "Browse products"),
+ BotCommand("orders", "View your latest orders"),
+ BotCommand("help", "Payment safety and commands"),
+]
diff --git a/src/makepay_telegram_bot/catalog.py b/src/makepay_telegram_bot/catalog.py
new file mode 100644
index 0000000..32fac5d
--- /dev/null
+++ b/src/makepay_telegram_bot/catalog.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import json
+import re
+from decimal import Decimal, InvalidOperation
+from pathlib import Path
+from typing import Any
+
+from .models import Product
+
+_PRODUCT_ID_PATTERN = re.compile(r"^[a-z0-9_-]{1,32}$")
+_FIAT_CURRENCY_PATTERN = re.compile(r"^[A-Z]{3}$")
+
+
+class CatalogError(ValueError):
+ """Raised when the product catalog is invalid."""
+
+
+def _text(item: dict[str, Any], key: str, *, maximum: int) -> str:
+ value = item.get(key)
+ if not isinstance(value, str) or not value.strip():
+ raise CatalogError(f"Catalog field {key!r} must be a non-empty string.")
+ normalized = value.strip()
+ if len(normalized) > maximum:
+ raise CatalogError(f"Catalog field {key!r} is longer than {maximum} characters.")
+ return normalized
+
+
+def _amount(item: dict[str, Any]) -> str:
+ value = item.get("amount")
+ if not isinstance(value, str):
+ raise CatalogError("Catalog field 'amount' must be a decimal string.")
+ try:
+ amount = Decimal(value)
+ except InvalidOperation as error:
+ raise CatalogError("Catalog field 'amount' must be a decimal string.") from error
+ exponent = amount.as_tuple().exponent
+ if not amount.is_finite() or amount <= 0 or not isinstance(exponent, int) or exponent < -2:
+ raise CatalogError("Catalog amount must be positive with at most two decimal places.")
+ return f"{amount:.2f}"
+
+
+def load_catalog(path: Path) -> dict[str, Product]:
+ try:
+ decoded = json.loads(path.read_text(encoding="utf-8"))
+ except OSError as error:
+ raise CatalogError(f"Unable to read catalog at {path}.") from error
+ except json.JSONDecodeError as error:
+ raise CatalogError(f"Catalog at {path} is not valid JSON.") from error
+
+ if not isinstance(decoded, list) or not decoded:
+ raise CatalogError("Catalog must be a non-empty JSON array.")
+
+ products: dict[str, Product] = {}
+ for raw in decoded:
+ if not isinstance(raw, dict):
+ raise CatalogError("Every catalog item must be an object.")
+ product_id = _text(raw, "id", maximum=32).lower()
+ if not _PRODUCT_ID_PATTERN.fullmatch(product_id):
+ raise CatalogError(
+ "Catalog product IDs may contain only lowercase letters, numbers, _ and -."
+ )
+ if product_id in products:
+ raise CatalogError(f"Duplicate catalog product ID: {product_id}.")
+ fiat_currency = str(raw.get("fiatCurrency", "USD")).strip().upper()
+ if not _FIAT_CURRENCY_PATTERN.fullmatch(fiat_currency):
+ raise CatalogError("fiatCurrency must be a three-letter ISO currency code.")
+ products[product_id] = Product(
+ id=product_id,
+ name=_text(raw, "name", maximum=80),
+ description=_text(raw, "description", maximum=240),
+ amount=_amount(raw),
+ fiat_currency=fiat_currency,
+ )
+ return products
diff --git a/src/makepay_telegram_bot/cli.py b/src/makepay_telegram_bot/cli.py
new file mode 100644
index 0000000..658f625
--- /dev/null
+++ b/src/makepay_telegram_bot/cli.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+
+import uvicorn
+from telegram import Bot
+
+from .config import Settings
+
+
+async def _set_telegram_webhook(settings: Settings) -> None:
+ async with Bot(settings.telegram_bot_token) as bot:
+ await bot.set_webhook(
+ url=settings.telegram_webhook_url,
+ secret_token=settings.telegram_webhook_secret,
+ drop_pending_updates=False,
+ )
+ print(f"Telegram webhook set to {settings.telegram_webhook_url}")
+
+
+async def _delete_telegram_webhook(settings: Settings) -> None:
+ async with Bot(settings.telegram_bot_token) as bot:
+ await bot.delete_webhook(drop_pending_updates=False)
+ print("Telegram webhook deleted; pending updates were preserved.")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="MakePay Telegram bot")
+ subparsers = parser.add_subparsers(dest="command")
+ serve = subparsers.add_parser("serve", help="Run the webhook server")
+ serve.add_argument("--host", default="0.0.0.0") # noqa: S104 - container listener
+ serve.add_argument("--port", type=int, default=8000)
+ subparsers.add_parser("set-telegram-webhook", help="Register the Telegram webhook")
+ subparsers.add_parser("delete-telegram-webhook", help="Delete the Telegram webhook")
+ arguments = parser.parse_args()
+
+ if arguments.command in {None, "serve"}:
+ uvicorn.run(
+ "makepay_telegram_bot.app:app",
+ host=getattr(arguments, "host", "0.0.0.0"), # noqa: S104 - container listener
+ port=getattr(arguments, "port", 8000),
+ )
+ return
+
+ settings = Settings.from_env()
+ if arguments.command == "set-telegram-webhook":
+ asyncio.run(_set_telegram_webhook(settings))
+ elif arguments.command == "delete-telegram-webhook":
+ asyncio.run(_delete_telegram_webhook(settings))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/makepay_telegram_bot/config.py b/src/makepay_telegram_bot/config.py
new file mode 100644
index 0000000..85c9e16
--- /dev/null
+++ b/src/makepay_telegram_bot/config.py
@@ -0,0 +1,139 @@
+from __future__ import annotations
+
+import os
+import re
+from collections.abc import Mapping
+from dataclasses import dataclass
+from pathlib import Path
+from urllib.parse import urlparse
+
+_TELEGRAM_SECRET_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,256}$")
+
+
+class ConfigurationError(ValueError):
+ """Raised when runtime configuration is missing or unsafe."""
+
+
+def _required(env: Mapping[str, str], name: str) -> str:
+ value = env.get(name, "").strip()
+ if not value:
+ raise ConfigurationError(f"{name} is required.")
+ return value
+
+
+def _positive_int(env: Mapping[str, str], name: str, default: int, minimum: int) -> int:
+ raw = env.get(name, str(default)).strip()
+ try:
+ value = int(raw)
+ except ValueError as error:
+ raise ConfigurationError(f"{name} must be an integer.") from error
+ if value < minimum:
+ raise ConfigurationError(f"{name} must be at least {minimum}.")
+ return value
+
+
+def _https_url(value: str, name: str, *, allow_localhost_http: bool = False) -> str:
+ normalized = value.rstrip("/")
+ parsed = urlparse(normalized)
+ local_http = (
+ allow_localhost_http
+ and parsed.scheme == "http"
+ and parsed.hostname in {"127.0.0.1", "localhost"}
+ )
+ if (
+ (parsed.scheme != "https" and not local_http)
+ or not parsed.netloc
+ or parsed.username
+ or parsed.password
+ or parsed.path not in {"", "/"}
+ or parsed.query
+ or parsed.fragment
+ ):
+ raise ConfigurationError(f"{name} must be a clean HTTPS origin.")
+ return normalized
+
+
+def _admin_ids(value: str) -> frozenset[int]:
+ if not value.strip():
+ return frozenset()
+ try:
+ parsed = frozenset(int(part.strip()) for part in value.split(",") if part.strip())
+ except ValueError as error:
+ raise ConfigurationError("ADMIN_TELEGRAM_USER_IDS must contain integer IDs.") from error
+ if any(user_id <= 0 for user_id in parsed):
+ raise ConfigurationError("ADMIN_TELEGRAM_USER_IDS must contain positive IDs.")
+ return parsed
+
+
+@dataclass(frozen=True, slots=True)
+class Settings:
+ telegram_bot_token: str
+ telegram_webhook_secret: str
+ public_base_url: str
+ makepay_key_id: str
+ makepay_key_secret: str
+ makepay_webhook_secret: str
+ makepay_api_base_url: str
+ makepay_checkout_base_url: str
+ database_path: Path
+ catalog_path: Path
+ admin_telegram_user_ids: frozenset[int]
+ reconcile_interval_seconds: int
+ notification_interval_seconds: int
+ max_webhook_body_bytes: int
+ log_level: str
+
+ @property
+ def telegram_webhook_url(self) -> str:
+ return f"{self.public_base_url}/webhooks/telegram"
+
+ @property
+ def makepay_webhook_url(self) -> str:
+ return f"{self.public_base_url}/webhooks/makepay"
+
+ @classmethod
+ def from_env(cls, source: Mapping[str, str] | None = None) -> Settings:
+ env = os.environ if source is None else source
+ allow_localhost = env.get("ALLOW_INSECURE_LOCALHOST", "").lower() == "true"
+ telegram_secret = _required(env, "TELEGRAM_WEBHOOK_SECRET")
+ if not _TELEGRAM_SECRET_PATTERN.fullmatch(telegram_secret):
+ raise ConfigurationError(
+ "TELEGRAM_WEBHOOK_SECRET may contain only A-Z, a-z, 0-9, _ and - "
+ "and must be 1-256 characters."
+ )
+
+ log_level = env.get("LOG_LEVEL", "INFO").strip().upper()
+ if log_level not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
+ raise ConfigurationError("LOG_LEVEL is invalid.")
+
+ return cls(
+ telegram_bot_token=_required(env, "TELEGRAM_BOT_TOKEN"),
+ telegram_webhook_secret=telegram_secret,
+ public_base_url=_https_url(
+ _required(env, "PUBLIC_BASE_URL"),
+ "PUBLIC_BASE_URL",
+ allow_localhost_http=allow_localhost,
+ ),
+ makepay_key_id=_required(env, "MAKEPAY_KEY_ID"),
+ makepay_key_secret=_required(env, "MAKEPAY_KEY_SECRET"),
+ makepay_webhook_secret=_required(env, "MAKEPAY_WEBHOOK_SECRET"),
+ makepay_api_base_url=_https_url(
+ env.get("MAKEPAY_API_BASE_URL", "https://www.makecrypto.io"),
+ "MAKEPAY_API_BASE_URL",
+ allow_localhost_http=allow_localhost,
+ ),
+ makepay_checkout_base_url=_https_url(
+ env.get("MAKEPAY_CHECKOUT_BASE_URL", "https://www.makepay.io"),
+ "MAKEPAY_CHECKOUT_BASE_URL",
+ allow_localhost_http=allow_localhost,
+ ),
+ database_path=Path(env.get("DATABASE_PATH", "./data/bot.db")).expanduser(),
+ catalog_path=Path(env.get("CATALOG_PATH", "./catalog.json")).expanduser(),
+ admin_telegram_user_ids=_admin_ids(env.get("ADMIN_TELEGRAM_USER_IDS", "")),
+ reconcile_interval_seconds=_positive_int(env, "RECONCILE_INTERVAL_SECONDS", 60, 10),
+ notification_interval_seconds=_positive_int(
+ env, "NOTIFICATION_INTERVAL_SECONDS", 10, 1
+ ),
+ max_webhook_body_bytes=_positive_int(env, "MAX_WEBHOOK_BODY_BYTES", 1_048_576, 1024),
+ log_level=log_level,
+ )
diff --git a/src/makepay_telegram_bot/database.py b/src/makepay_telegram_bot/database.py
new file mode 100644
index 0000000..46f5f3f
--- /dev/null
+++ b/src/makepay_telegram_bot/database.py
@@ -0,0 +1,416 @@
+from __future__ import annotations
+
+import sqlite3
+import threading
+from collections.abc import Iterator
+from contextlib import contextmanager
+from datetime import UTC, datetime
+from pathlib import Path
+from uuid import uuid4
+
+from .models import Notification, Order, Product, StatusUpdate
+
+_PAID_STATUSES = {"complete", "completed", "confirmed", "paid", "succeeded", "success"}
+_PROCESSING_STATUSES = {
+ "confirming",
+ "deposit_received",
+ "paying",
+ "processing",
+ "sending",
+ "swapping",
+ "verifying",
+ "waiting_confirmation",
+}
+_PENDING_STATUSES = {"awaiting_deposit", "created", "new", "pending", "quoted", "waiting"}
+_CANCELLED_STATUSES = {"cancel", "canceled", "cancelled"}
+_FAILED_STATUSES = {"chargeback", "fail", "failed"}
+_TERMINAL_ORDER_STATUSES = {"paid", "cancelled", "expired", "failed"}
+_NOTIFIABLE_STATUSES = {"processing", "underpaid", "paid", "cancelled", "expired", "failed"}
+
+
+def utc_now() -> str:
+ return datetime.now(UTC).isoformat(timespec="milliseconds")
+
+
+def normalize_order_status(makepay_status: str) -> str:
+ normalized = makepay_status.strip().lower()
+ if normalized in _PAID_STATUSES:
+ return "paid"
+ if normalized in _PROCESSING_STATUSES:
+ return "processing"
+ if normalized in _PENDING_STATUSES:
+ return "pending"
+ if normalized in _CANCELLED_STATUSES:
+ return "cancelled"
+ if normalized in _FAILED_STATUSES:
+ return "failed"
+ if normalized == "expired":
+ return "expired"
+ if normalized == "underpaid":
+ return "underpaid"
+ return "pending"
+
+
+def _next_status(current: str, incoming: str) -> str:
+ if current == "paid" or current == incoming:
+ return current
+ if incoming == "paid":
+ return "paid"
+ if current in _TERMINAL_ORDER_STATUSES:
+ return current
+ if current == "underpaid" and incoming in {"pending", "processing"}:
+ return current
+ return incoming
+
+
+def _order_from_row(row: sqlite3.Row) -> Order:
+ return Order(
+ id=str(row["id"]),
+ request_key=str(row["request_key"]),
+ telegram_chat_id=int(row["telegram_chat_id"]),
+ telegram_user_id=int(row["telegram_user_id"]),
+ product_id=str(row["product_id"]),
+ product_name=str(row["product_name"]),
+ amount=str(row["amount"]),
+ fiat_currency=str(row["fiat_currency"]),
+ status=str(row["status"]),
+ makepay_status=str(row["makepay_status"]) if row["makepay_status"] else None,
+ makepay_payment_uid=(
+ str(row["makepay_payment_uid"]) if row["makepay_payment_uid"] else None
+ ),
+ checkout_url=str(row["checkout_url"]) if row["checkout_url"] else None,
+ makepay_session_id=(str(row["makepay_session_id"]) if row["makepay_session_id"] else None),
+ last_error=str(row["last_error"]) if row["last_error"] else None,
+ created_at=str(row["created_at"]),
+ updated_at=str(row["updated_at"]),
+ )
+
+
+class Database:
+ """Small, transaction-safe SQLite order and notification store."""
+
+ def __init__(self, path: Path) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ self._connection = sqlite3.connect(path, check_same_thread=False, timeout=10)
+ self._connection.row_factory = sqlite3.Row
+ self._lock = threading.RLock()
+ with self._lock:
+ self._connection.execute("PRAGMA journal_mode = WAL")
+ self._connection.execute("PRAGMA foreign_keys = ON")
+ self._connection.execute("PRAGMA busy_timeout = 5000")
+ self._connection.executescript(
+ """
+ CREATE TABLE IF NOT EXISTS orders (
+ id TEXT PRIMARY KEY,
+ request_key TEXT NOT NULL UNIQUE,
+ telegram_chat_id INTEGER NOT NULL,
+ telegram_user_id INTEGER NOT NULL,
+ product_id TEXT NOT NULL,
+ product_name TEXT NOT NULL,
+ amount TEXT NOT NULL,
+ fiat_currency TEXT NOT NULL,
+ status TEXT NOT NULL,
+ makepay_status TEXT,
+ makepay_payment_uid TEXT UNIQUE,
+ checkout_url TEXT,
+ makepay_session_id TEXT,
+ last_error TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS orders_owner_created_idx
+ ON orders (telegram_user_id, telegram_chat_id, created_at DESC);
+ CREATE INDEX IF NOT EXISTS orders_status_updated_idx
+ ON orders (status, updated_at);
+
+ CREATE TABLE IF NOT EXISTS webhook_deliveries (
+ dedupe_key TEXT PRIMARY KEY,
+ delivery_id TEXT,
+ event_type TEXT,
+ received_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS notifications (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ order_id TEXT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
+ status TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ sent_at TEXT,
+ attempts INTEGER NOT NULL DEFAULT 0,
+ last_error TEXT,
+ UNIQUE(order_id, status)
+ );
+ """
+ )
+ self._connection.commit()
+
+ @contextmanager
+ def _transaction(self) -> Iterator[sqlite3.Connection]:
+ with self._lock:
+ try:
+ self._connection.execute("BEGIN IMMEDIATE")
+ yield self._connection
+ self._connection.commit()
+ except Exception:
+ self._connection.rollback()
+ raise
+
+ def close(self) -> None:
+ with self._lock:
+ self._connection.close()
+
+ def ping(self) -> bool:
+ with self._lock:
+ return self._connection.execute("SELECT 1").fetchone() is not None
+
+ def create_or_get_order(
+ self,
+ *,
+ request_key: str,
+ telegram_chat_id: int,
+ telegram_user_id: int,
+ product: Product,
+ ) -> Order:
+ now = utc_now()
+ order_id = str(uuid4())
+ with self._transaction() as connection:
+ connection.execute(
+ """
+ INSERT OR IGNORE INTO orders (
+ id, request_key, telegram_chat_id, telegram_user_id,
+ product_id, product_name, amount, fiat_currency, status,
+ created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'creating', ?, ?)
+ """,
+ (
+ order_id,
+ request_key,
+ telegram_chat_id,
+ telegram_user_id,
+ product.id,
+ product.name,
+ product.amount,
+ product.fiat_currency,
+ now,
+ now,
+ ),
+ )
+ row = connection.execute(
+ "SELECT * FROM orders WHERE request_key = ?", (request_key,)
+ ).fetchone()
+ if row is None:
+ raise RuntimeError("Unable to create or load order.")
+ return _order_from_row(row)
+
+ def attach_payment_link(self, order_id: str, uid: str, checkout_url: str) -> Order:
+ now = utc_now()
+ with self._transaction() as connection:
+ updated = connection.execute(
+ """
+ UPDATE orders
+ SET makepay_payment_uid = COALESCE(makepay_payment_uid, ?),
+ checkout_url = COALESCE(checkout_url, ?),
+ status = CASE WHEN status IN ('creating', 'creation_failed')
+ THEN 'pending' ELSE status END,
+ makepay_status = COALESCE(makepay_status, 'pending'),
+ last_error = NULL,
+ updated_at = ?
+ WHERE id = ?
+ AND (makepay_payment_uid IS NULL OR makepay_payment_uid = ?)
+ """,
+ (uid, checkout_url, now, order_id, uid),
+ )
+ if updated.rowcount == 0:
+ existing = connection.execute(
+ "SELECT makepay_payment_uid FROM orders WHERE id = ?", (order_id,)
+ ).fetchone()
+ if existing is None:
+ raise LookupError("Order not found.")
+ raise RuntimeError("Order is already linked to a different MakePay payment.")
+ row = connection.execute("SELECT * FROM orders WHERE id = ?", (order_id,)).fetchone()
+ if row is None: # pragma: no cover - guarded inside the transaction
+ raise RuntimeError("Unable to reload order.")
+ return _order_from_row(row)
+
+ def mark_creation_failed(self, order_id: str, error_code: str) -> None:
+ with self._transaction() as connection:
+ connection.execute(
+ """
+ UPDATE orders
+ SET status = CASE WHEN checkout_url IS NULL THEN 'creation_failed' ELSE status END,
+ last_error = ?, updated_at = ?
+ WHERE id = ?
+ """,
+ (error_code[:200], utc_now(), order_id),
+ )
+
+ def get_order_for_owner(
+ self, order_id: str, telegram_chat_id: int, telegram_user_id: int
+ ) -> Order | None:
+ with self._lock:
+ row = self._connection.execute(
+ """
+ SELECT * FROM orders
+ WHERE id = ? AND telegram_chat_id = ? AND telegram_user_id = ?
+ """,
+ (order_id, telegram_chat_id, telegram_user_id),
+ ).fetchone()
+ return _order_from_row(row) if row else None
+
+ def list_orders(
+ self, telegram_chat_id: int, telegram_user_id: int, *, limit: int = 5
+ ) -> list[Order]:
+ with self._lock:
+ rows = self._connection.execute(
+ """
+ SELECT * FROM orders
+ WHERE telegram_chat_id = ? AND telegram_user_id = ?
+ ORDER BY created_at DESC
+ LIMIT ?
+ """,
+ (telegram_chat_id, telegram_user_id, limit),
+ ).fetchall()
+ return [_order_from_row(row) for row in rows]
+
+ def list_reconcilable_orders(self, *, limit: int = 50) -> list[Order]:
+ with self._lock:
+ rows = self._connection.execute(
+ """
+ SELECT * FROM orders
+ WHERE makepay_payment_uid IS NOT NULL
+ AND status IN ('pending', 'processing', 'underpaid')
+ ORDER BY updated_at ASC
+ LIMIT ?
+ """,
+ (limit,),
+ ).fetchall()
+ return [_order_from_row(row) for row in rows]
+
+ def apply_payment_status(
+ self,
+ *,
+ dedupe_key: str,
+ makepay_status: str,
+ payment_uid: str | None,
+ merchant_order_id: str | None,
+ session_id: str | None,
+ delivery_id: str | None = None,
+ event_type: str | None = None,
+ ) -> StatusUpdate:
+ normalized_makepay_status = makepay_status.strip().lower()
+ incoming_status = normalize_order_status(normalized_makepay_status)
+ now = utc_now()
+ with self._transaction() as connection:
+ inserted = connection.execute(
+ """
+ INSERT OR IGNORE INTO webhook_deliveries
+ (dedupe_key, delivery_id, event_type, received_at)
+ VALUES (?, ?, ?, ?)
+ """,
+ (dedupe_key, delivery_id, event_type, now),
+ ).rowcount
+ if inserted == 0:
+ return StatusUpdate(order=None, changed=False, duplicate=True)
+
+ row: sqlite3.Row | None = None
+ if payment_uid:
+ row = connection.execute(
+ "SELECT * FROM orders WHERE makepay_payment_uid = ?", (payment_uid,)
+ ).fetchone()
+ if row is None and merchant_order_id:
+ row = connection.execute(
+ "SELECT * FROM orders WHERE id = ?", (merchant_order_id,)
+ ).fetchone()
+ if row is None:
+ return StatusUpdate(order=None, changed=False, duplicate=False)
+
+ current = _order_from_row(row)
+ next_status = _next_status(current.status, incoming_status)
+ changed = next_status != current.status
+ connection.execute(
+ """
+ UPDATE orders
+ SET status = ?,
+ makepay_status = ?,
+ makepay_payment_uid = COALESCE(makepay_payment_uid, ?),
+ makepay_session_id = COALESCE(?, makepay_session_id),
+ updated_at = ?
+ WHERE id = ?
+ """,
+ (
+ next_status,
+ normalized_makepay_status,
+ payment_uid,
+ session_id,
+ now,
+ current.id,
+ ),
+ )
+ if changed and next_status in _NOTIFIABLE_STATUSES:
+ connection.execute(
+ """
+ INSERT OR IGNORE INTO notifications
+ (order_id, status, created_at)
+ VALUES (?, ?, ?)
+ """,
+ (current.id, next_status, now),
+ )
+ updated_row = connection.execute(
+ "SELECT * FROM orders WHERE id = ?", (current.id,)
+ ).fetchone()
+ return StatusUpdate(
+ order=_order_from_row(updated_row) if updated_row else None,
+ changed=changed,
+ duplicate=False,
+ )
+
+ def pending_notifications(self, *, limit: int = 20) -> list[Notification]:
+ with self._lock:
+ rows = self._connection.execute(
+ """
+ SELECT n.id, n.order_id, n.status, o.telegram_chat_id,
+ o.product_name, o.amount, o.fiat_currency, o.checkout_url
+ FROM notifications AS n
+ JOIN orders AS o ON o.id = n.order_id
+ WHERE n.sent_at IS NULL
+ ORDER BY n.created_at ASC
+ LIMIT ?
+ """,
+ (limit,),
+ ).fetchall()
+ return [
+ Notification(
+ id=int(row["id"]),
+ order_id=str(row["order_id"]),
+ telegram_chat_id=int(row["telegram_chat_id"]),
+ product_name=str(row["product_name"]),
+ amount=str(row["amount"]),
+ fiat_currency=str(row["fiat_currency"]),
+ status=str(row["status"]),
+ checkout_url=str(row["checkout_url"]) if row["checkout_url"] else None,
+ )
+ for row in rows
+ ]
+
+ def mark_notification_sent(self, notification_id: int) -> None:
+ with self._transaction() as connection:
+ connection.execute(
+ """
+ UPDATE notifications
+ SET sent_at = ?, attempts = attempts + 1, last_error = NULL
+ WHERE id = ?
+ """,
+ (utc_now(), notification_id),
+ )
+
+ def mark_notification_failed(self, notification_id: int, error_code: str) -> None:
+ with self._transaction() as connection:
+ connection.execute(
+ """
+ UPDATE notifications
+ SET attempts = attempts + 1, last_error = ?
+ WHERE id = ?
+ """,
+ (error_code[:200], notification_id),
+ )
diff --git a/src/makepay_telegram_bot/makepay_client.py b/src/makepay_telegram_bot/makepay_client.py
new file mode 100644
index 0000000..d6e2373
--- /dev/null
+++ b/src/makepay_telegram_bot/makepay_client.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+from typing import Any
+from urllib.parse import quote
+
+import httpx
+
+from .models import Order, PaymentLink, PaymentStatus, Product
+
+
+class MakePayAPIError(RuntimeError):
+ def __init__(self, message: str, *, status_code: int = 0) -> None:
+ super().__init__(message)
+ self.status_code = status_code
+
+
+def _record(value: object) -> dict[str, Any]:
+ return value if isinstance(value, dict) else {}
+
+
+def _text(value: object) -> str | None:
+ return value.strip() if isinstance(value, str) and value.strip() else None
+
+
+class MakePayClient:
+ """Minimal async MakePay API client for this integration."""
+
+ def __init__(
+ self,
+ *,
+ key_id: str,
+ key_secret: str,
+ api_base_url: str,
+ checkout_base_url: str,
+ transport: httpx.AsyncBaseTransport | None = None,
+ ) -> None:
+ self._checkout_base_url = checkout_base_url.rstrip("/")
+ self._api_client = httpx.AsyncClient(
+ base_url=api_base_url.rstrip("/"),
+ follow_redirects=False,
+ timeout=httpx.Timeout(20),
+ transport=transport,
+ headers={
+ "Accept": "application/json",
+ "User-Agent": "MakePayTelegramBot/1.0.0",
+ "X-MakeCrypto-Key-Id": key_id,
+ "X-MakeCrypto-Key-Secret": key_secret,
+ },
+ )
+ self._public_client = httpx.AsyncClient(
+ follow_redirects=False,
+ timeout=httpx.Timeout(20),
+ transport=transport,
+ headers={
+ "Accept": "application/json",
+ "User-Agent": "MakePayTelegramBot/1.0.0",
+ },
+ )
+
+ async def close(self) -> None:
+ await self._api_client.aclose()
+ await self._public_client.aclose()
+
+ async def create_payment_link(self, order: Order, product: Product) -> PaymentLink:
+ response = await self._api_client.post(
+ "/api/partner/v1/makepay/payment-links",
+ headers={"Idempotency-Key": f"telegram-{order.id}"},
+ json={
+ "status": "active",
+ "sendPaymentRequestEmail": False,
+ "payload": {
+ "title": product.name,
+ "description": product.description,
+ "amount": product.amount,
+ "fiatCurrency": product.fiat_currency,
+ "orderId": order.id,
+ "expirationTime": "1h",
+ "metadata": {
+ "integration": "telegram-bot",
+ "telegramOrderId": order.id,
+ },
+ },
+ },
+ )
+ payload = self._decode(response, "create payment link")
+ payment_link = _record(payload.get("paymentLink"))
+ uid = _text(payment_link.get("uid"))
+ if not uid:
+ raise MakePayAPIError("MakePay response did not include a payment-link UID.")
+ checkout_url = _text(payment_link.get("publicUrl")) or _text(
+ payment_link.get("checkoutUrl")
+ )
+ if not checkout_url:
+ checkout_url = f"{self._checkout_base_url}/payment/{quote(uid, safe='')}"
+ if not checkout_url.startswith("https://") and not checkout_url.startswith(
+ "http://localhost"
+ ):
+ raise MakePayAPIError("MakePay response included an unsafe checkout URL.")
+ return PaymentLink(uid=uid, checkout_url=checkout_url)
+
+ async def get_payment_status(self, payment_uid: str) -> PaymentStatus:
+ response = await self._public_client.get(
+ f"{self._checkout_base_url}/api/public/payment-links/"
+ f"{quote(payment_uid, safe='')}/current-session"
+ )
+ payload = self._decode(response, "read payment status")
+ session = _record(payload.get("session"))
+ status = _text(session.get("status"))
+ if not status:
+ return PaymentStatus(status="pending", session_id=None)
+ return PaymentStatus(status=status, session_id=_text(session.get("id")))
+
+ @staticmethod
+ def _decode(response: httpx.Response, action: str) -> dict[str, Any]:
+ try:
+ decoded: object = response.json()
+ except ValueError as error:
+ raise MakePayAPIError(
+ f"MakePay returned invalid JSON while trying to {action}.",
+ status_code=response.status_code,
+ ) from error
+ body = _record(decoded)
+ if not response.is_success:
+ message = _text(body.get("error")) or f"MakePay could not {action}."
+ raise MakePayAPIError(message, status_code=response.status_code)
+ return body
diff --git a/src/makepay_telegram_bot/models.py b/src/makepay_telegram_bot/models.py
new file mode 100644
index 0000000..04c78ed
--- /dev/null
+++ b/src/makepay_telegram_bot/models.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True, slots=True)
+class Product:
+ id: str
+ name: str
+ description: str
+ amount: str
+ fiat_currency: str
+
+
+@dataclass(frozen=True, slots=True)
+class Order:
+ id: str
+ request_key: str
+ telegram_chat_id: int
+ telegram_user_id: int
+ product_id: str
+ product_name: str
+ amount: str
+ fiat_currency: str
+ status: str
+ makepay_status: str | None
+ makepay_payment_uid: str | None
+ checkout_url: str | None
+ makepay_session_id: str | None
+ last_error: str | None
+ created_at: str
+ updated_at: str
+
+
+@dataclass(frozen=True, slots=True)
+class PaymentLink:
+ uid: str
+ checkout_url: str
+
+
+@dataclass(frozen=True, slots=True)
+class PaymentStatus:
+ status: str
+ session_id: str | None
+
+
+@dataclass(frozen=True, slots=True)
+class StatusUpdate:
+ order: Order | None
+ changed: bool
+ duplicate: bool
+
+
+@dataclass(frozen=True, slots=True)
+class Notification:
+ id: int
+ order_id: str
+ telegram_chat_id: int
+ product_name: str
+ amount: str
+ fiat_currency: str
+ status: str
+ checkout_url: str | None
diff --git a/src/makepay_telegram_bot/py.typed b/src/makepay_telegram_bot/py.typed
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/makepay_telegram_bot/py.typed
@@ -0,0 +1 @@
+
diff --git a/src/makepay_telegram_bot/telegram_types.py b/src/makepay_telegram_bot/telegram_types.py
new file mode 100644
index 0000000..45e7ae1
--- /dev/null
+++ b/src/makepay_telegram_bot/telegram_types.py
@@ -0,0 +1,16 @@
+from typing import Any
+
+from telegram.ext import Application, CallbackContext, ExtBot, JobQueue
+
+type UserData = dict[Any, Any]
+type ChatData = dict[Any, Any]
+type BotData = dict[Any, Any]
+type TelegramContext = CallbackContext[ExtBot[None], UserData, ChatData, BotData]
+type TelegramApplication = Application[
+ ExtBot[None],
+ TelegramContext,
+ UserData,
+ ChatData,
+ BotData,
+ JobQueue[TelegramContext],
+]
diff --git a/src/makepay_telegram_bot/webhooks.py b/src/makepay_telegram_bot/webhooks.py
new file mode 100644
index 0000000..4c65c29
--- /dev/null
+++ b/src/makepay_telegram_bot/webhooks.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from hashlib import sha256
+from typing import Any
+
+
+def _record(value: object) -> dict[str, Any]:
+ return value if isinstance(value, dict) else {}
+
+
+def _text(value: object, *, maximum: int = 300) -> str | None:
+ if not isinstance(value, str) or not value.strip():
+ return None
+ return value.strip()[:maximum]
+
+
+@dataclass(frozen=True, slots=True)
+class MakePayWebhookEvent:
+ dedupe_key: str
+ delivery_id: str | None
+ event_type: str | None
+ payment_uid: str | None
+ merchant_order_id: str | None
+ session_id: str | None
+ status: str | None
+
+
+def extract_makepay_event(
+ payload: dict[str, Any], headers: dict[str, str], raw_body: bytes
+) -> MakePayWebhookEvent:
+ payment_link = _record(payload.get("paymentLink"))
+ session = _record(payload.get("session"))
+ event = _record(payload.get("event"))
+ delivery_id = _text(payload.get("deliveryId"))
+ delivery_group_id = _text(headers.get("x-makepay-delivery-group-id"))
+ event_type = (
+ _text(headers.get("x-makepay-event"))
+ or _text(payload.get("type"))
+ or _text(event.get("type"))
+ )
+ body_hash = sha256(raw_body).hexdigest()
+ return MakePayWebhookEvent(
+ dedupe_key=delivery_group_id or delivery_id or f"body:{body_hash}",
+ delivery_id=delivery_id,
+ event_type=event_type,
+ payment_uid=_text(payment_link.get("uid")),
+ merchant_order_id=_text(payment_link.get("merchantOrderId")),
+ session_id=_text(session.get("id")),
+ status=_text(session.get("status")) or _text(payload.get("status")),
+ )
diff --git a/src/makepay_telegram_bot/workers.py b/src/makepay_telegram_bot/workers.py
new file mode 100644
index 0000000..7326ef2
--- /dev/null
+++ b/src/makepay_telegram_bot/workers.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+import asyncio
+import html
+import logging
+
+from telegram import InlineKeyboardButton, InlineKeyboardMarkup
+from telegram.constants import ParseMode
+from telegram.error import TelegramError
+
+from .database import Database
+from .makepay_client import MakePayAPIError, MakePayClient
+from .models import Notification
+from .telegram_types import TelegramApplication
+
+logger = logging.getLogger(__name__)
+
+
+def _notification_text(notification: Notification) -> str:
+ product = html.escape(notification.product_name)
+ order = f"{notification.order_id}"
+ messages = {
+ "processing": f"Payment received for {product} and is processing.\nOrder: {order}",
+ "underpaid": (f"Order {order} is underpaid. Open checkout to review the remaining amount."),
+ "paid": f"✅ Payment complete for {product}.\nOrder: {order}",
+ "expired": f"Payment window expired for {product}.\nOrder: {order}",
+ "cancelled": f"Payment was cancelled for {product}.\nOrder: {order}",
+ "failed": f"Payment failed for {product}.\nOrder: {order}",
+ }
+ return messages.get(notification.status, f"Order {order}: {notification.status}.")
+
+
+async def notification_loop(
+ *,
+ application: TelegramApplication,
+ database: Database,
+ wakeup: asyncio.Event,
+ interval_seconds: int,
+) -> None:
+ while True:
+ for notification in database.pending_notifications():
+ keyboard = None
+ if notification.checkout_url and notification.status in {"underpaid", "processing"}:
+ keyboard = InlineKeyboardMarkup(
+ [[InlineKeyboardButton("Open MakePay checkout", url=notification.checkout_url)]]
+ )
+ try:
+ await application.bot.send_message(
+ notification.telegram_chat_id,
+ _notification_text(notification),
+ parse_mode=ParseMode.HTML,
+ reply_markup=keyboard,
+ disable_web_page_preview=True,
+ )
+ database.mark_notification_sent(notification.id)
+ except TelegramError as error:
+ logger.warning(
+ "Telegram notification failed for order %s: %s",
+ notification.order_id,
+ type(error).__name__,
+ )
+ database.mark_notification_failed(
+ notification.id, f"telegram_{type(error).__name__}"
+ )
+ wakeup.clear()
+ try:
+ await asyncio.wait_for(wakeup.wait(), timeout=interval_seconds)
+ except TimeoutError:
+ pass
+
+
+async def reconciliation_loop(
+ *,
+ makepay: MakePayClient,
+ database: Database,
+ notification_wakeup: asyncio.Event,
+ interval_seconds: int,
+) -> None:
+ while True:
+ for order in database.list_reconcilable_orders():
+ if not order.makepay_payment_uid:
+ continue
+ try:
+ remote = await makepay.get_payment_status(order.makepay_payment_uid)
+ result = database.apply_payment_status(
+ dedupe_key=(
+ f"reconcile:{order.makepay_payment_uid}:{remote.status}:"
+ f"{remote.session_id or 'none'}"
+ ),
+ makepay_status=remote.status,
+ payment_uid=order.makepay_payment_uid,
+ merchant_order_id=order.id,
+ session_id=remote.session_id,
+ event_type="scheduled_reconciliation",
+ )
+ if result.changed:
+ notification_wakeup.set()
+ except MakePayAPIError as error:
+ logger.warning(
+ "MakePay reconciliation failed for order %s with status %s",
+ order.id,
+ error.status_code,
+ )
+ await asyncio.sleep(interval_seconds)
diff --git a/tests/test_app.py b/tests/test_app.py
new file mode 100644
index 0000000..e5cb948
--- /dev/null
+++ b/tests/test_app.py
@@ -0,0 +1,198 @@
+import asyncio
+import hashlib
+import hmac
+import json
+import time
+from pathlib import Path
+from types import SimpleNamespace
+
+import httpx
+from telegram import Bot
+
+from makepay_telegram_bot.app import Runtime, create_app
+from makepay_telegram_bot.config import Settings
+from makepay_telegram_bot.database import Database
+from makepay_telegram_bot.models import Product
+
+PRODUCT = Product(
+ id="coffee",
+ name="Coffee voucher",
+ description="A demo product",
+ amount="5.00",
+ fiat_currency="USD",
+)
+
+
+def settings(tmp_path: Path) -> Settings:
+ return Settings(
+ telegram_bot_token="123456:ABCDEF",
+ telegram_webhook_secret="telegram_secret",
+ public_base_url="https://bot.example.com",
+ makepay_key_id="key-id",
+ makepay_key_secret="key-secret",
+ makepay_webhook_secret="makepay-secret",
+ makepay_api_base_url="https://www.makecrypto.io",
+ makepay_checkout_base_url="https://www.makepay.io",
+ database_path=tmp_path / "orders.db",
+ catalog_path=tmp_path / "catalog.json",
+ admin_telegram_user_ids=frozenset(),
+ reconcile_interval_seconds=60,
+ notification_interval_seconds=10,
+ max_webhook_body_bytes=4096,
+ log_level="INFO",
+ )
+
+
+def signature(body: bytes, secret: str) -> str:
+ timestamp = int(time.time())
+ digest = hmac.new(
+ secret.encode(), str(timestamp).encode() + b"." + body, hashlib.sha256
+ ).hexdigest()
+ return f"t={timestamp},v1={digest}"
+
+
+def test_http_endpoints_authenticate_and_apply_webhooks(tmp_path: Path) -> None:
+ async def run() -> None:
+ app = create_app()
+ configuration = settings(tmp_path)
+ database = Database(configuration.database_path)
+ order = database.create_or_get_order(
+ request_key="callback:1",
+ telegram_chat_id=100,
+ telegram_user_id=200,
+ product=PRODUCT,
+ )
+ database.attach_payment_link(order.id, "pay_123", "https://www.makepay.io/payment/pay_123")
+ telegram = SimpleNamespace(
+ bot=Bot(configuration.telegram_bot_token),
+ update_queue=asyncio.Queue(),
+ running=True,
+ )
+ app.state.runtime = Runtime(
+ settings=configuration,
+ database=database,
+ makepay=SimpleNamespace(), # type: ignore[arg-type]
+ telegram=telegram, # type: ignore[arg-type]
+ notification_wakeup=asyncio.Event(),
+ )
+ transport = httpx.ASGITransport(app=app)
+ async with httpx.AsyncClient(
+ transport=transport, base_url="https://bot.example.com"
+ ) as client:
+ assert (await client.get("/")).status_code == 200
+ assert (await client.get("/healthz")).json() == {"status": "ok"}
+ assert (await client.get("/readyz")).status_code == 200
+
+ invalid_telegram = await client.post(
+ "/webhooks/telegram",
+ headers={"x-telegram-bot-api-secret-token": "wrong"},
+ json={"update_id": 1},
+ )
+ assert invalid_telegram.status_code == 401
+
+ valid_telegram = await client.post(
+ "/webhooks/telegram",
+ headers={"x-telegram-bot-api-secret-token": configuration.telegram_webhook_secret},
+ json={"update_id": 1},
+ )
+ assert valid_telegram.status_code == 200
+ assert telegram.update_queue.qsize() == 1
+
+ payload = {
+ "deliveryId": "delivery_1",
+ "paymentLink": {
+ "uid": "pay_123",
+ "merchantOrderId": order.id,
+ },
+ "session": {"id": "session_1", "status": "complete"},
+ }
+ body = json.dumps(payload, separators=(",", ":")).encode()
+ invalid_makepay = await client.post(
+ "/webhooks/makepay",
+ content=body,
+ headers={"x-makepay-signature": "t=1,v1=bad"},
+ )
+ assert invalid_makepay.status_code == 401
+
+ valid_makepay = await client.post(
+ "/webhooks/makepay",
+ content=body,
+ headers={
+ "content-type": "application/json",
+ "x-makepay-signature": signature(body, configuration.makepay_webhook_secret),
+ "x-makepay-delivery-group-id": "group_1",
+ },
+ )
+ assert valid_makepay.json() == {
+ "ok": True,
+ "matched": True,
+ "duplicate": False,
+ }
+ updated = database.get_order_for_owner(order.id, 100, 200)
+ assert updated is not None and updated.status == "paid"
+
+ duplicate = await client.post(
+ "/webhooks/makepay",
+ content=body,
+ headers={
+ "content-type": "application/json",
+ "x-makepay-signature": signature(body, configuration.makepay_webhook_secret),
+ "x-makepay-delivery-group-id": "group_1",
+ },
+ )
+ assert duplicate.json()["duplicate"] is True
+ database.close()
+
+ asyncio.run(run())
+
+
+def test_webhook_size_and_json_validation(tmp_path: Path) -> None:
+ async def run() -> None:
+ app = create_app()
+ configuration = settings(tmp_path)
+ database = Database(configuration.database_path)
+ telegram = SimpleNamespace(
+ bot=Bot(configuration.telegram_bot_token),
+ update_queue=asyncio.Queue(),
+ running=True,
+ )
+ app.state.runtime = Runtime(
+ settings=configuration,
+ database=database,
+ makepay=SimpleNamespace(), # type: ignore[arg-type]
+ telegram=telegram, # type: ignore[arg-type]
+ notification_wakeup=asyncio.Event(),
+ )
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app),
+ base_url="https://bot.example.com",
+ ) as client:
+ headers = {"x-telegram-bot-api-secret-token": configuration.telegram_webhook_secret}
+ invalid = await client.post(
+ "/webhooks/telegram",
+ headers={**headers, "content-type": "application/json"},
+ content=b"{",
+ )
+ assert invalid.status_code == 400
+ oversized = await client.post(
+ "/webhooks/telegram",
+ headers={**headers, "content-length": "5000"},
+ content=b"{}",
+ )
+ assert oversized.status_code == 413
+ database.close()
+
+ asyncio.run(run())
+
+
+def test_readiness_is_unavailable_before_runtime_initializes() -> None:
+ async def run() -> None:
+ app = create_app()
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app),
+ base_url="https://bot.example.com",
+ ) as client:
+ response = await client.get("/readyz")
+ assert response.status_code == 503
+
+ asyncio.run(run())
diff --git a/tests/test_bot.py b/tests/test_bot.py
new file mode 100644
index 0000000..d17eedc
--- /dev/null
+++ b/tests/test_bot.py
@@ -0,0 +1,159 @@
+import asyncio
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+from makepay_telegram_bot.bot import BotHandlers
+from makepay_telegram_bot.database import Database
+from makepay_telegram_bot.models import PaymentLink, PaymentStatus, Product
+
+PRODUCT = Product(
+ id="coffee",
+ name="Coffee voucher",
+ description="A demo product",
+ amount="5.00",
+ fiat_currency="USD",
+)
+
+
+class FakeMakePay:
+ async def create_payment_link(self, order: object, product: object) -> PaymentLink:
+ return PaymentLink(uid="pay_123", checkout_url="https://www.makepay.io/payment/pay_123")
+
+ async def get_payment_status(self, payment_uid: str) -> PaymentStatus:
+ return PaymentStatus(status="complete", session_id="session_123")
+
+
+def private_update(
+ *,
+ data: str | None = None,
+ query_id: str = "query_1",
+ chat_type: str = "private",
+) -> SimpleNamespace:
+ message = SimpleNamespace(reply_text=AsyncMock())
+ query = (
+ SimpleNamespace(
+ id=query_id,
+ data=data,
+ answer=AsyncMock(),
+ )
+ if data is not None
+ else None
+ )
+ return SimpleNamespace(
+ callback_query=query,
+ effective_user=SimpleNamespace(id=200),
+ effective_chat=SimpleNamespace(id=100, type=chat_type),
+ effective_message=message,
+ )
+
+
+def handlers(tmp_path: Path) -> tuple[BotHandlers, Database]:
+ database = Database(tmp_path / "orders.db")
+ instance = BotHandlers(
+ catalog={PRODUCT.id: PRODUCT},
+ database=database,
+ makepay=FakeMakePay(), # type: ignore[arg-type]
+ )
+ return instance, database
+
+
+def test_start_help_and_shop(tmp_path: Path) -> None:
+ async def run() -> None:
+ instance, database = handlers(tmp_path)
+ start = private_update()
+ await instance.start(start, None) # type: ignore[arg-type]
+ await instance.help(start, None) # type: ignore[arg-type]
+ await instance.shop(start, None) # type: ignore[arg-type]
+ assert start.effective_message.reply_text.await_count == 3
+ shop_call = start.effective_message.reply_text.await_args_list[-1]
+ assert shop_call.kwargs["reply_markup"].inline_keyboard[0][0].callback_data == "buy:coffee"
+ database.close()
+
+ asyncio.run(run())
+
+
+def test_group_purchase_is_refused(tmp_path: Path) -> None:
+ async def run() -> None:
+ instance, database = handlers(tmp_path)
+ update = private_update(data="buy:coffee", chat_type="group")
+ await instance.buy(update, None) # type: ignore[arg-type]
+ update.callback_query.answer.assert_awaited_once()
+ assert database.list_orders(100, 200) == []
+ database.close()
+
+ asyncio.run(run())
+
+
+def test_buy_is_idempotent_and_sends_checkout(tmp_path: Path) -> None:
+ async def run() -> None:
+ instance, database = handlers(tmp_path)
+ first = private_update(data="buy:coffee")
+ await instance.buy(first, None) # type: ignore[arg-type]
+ second = private_update(data="buy:coffee")
+ await instance.buy(second, None) # type: ignore[arg-type]
+
+ orders = database.list_orders(100, 200)
+ assert len(orders) == 1
+ assert orders[0].makepay_payment_uid == "pay_123"
+ assert (
+ first.effective_message.reply_text.await_args.kwargs["reply_markup"]
+ .inline_keyboard[0][0]
+ .url
+ == "https://www.makepay.io/payment/pay_123"
+ )
+ assert second.effective_message.reply_text.await_count == 1
+ database.close()
+
+ asyncio.run(run())
+
+
+def test_unknown_product_and_empty_orders(tmp_path: Path) -> None:
+ async def run() -> None:
+ instance, database = handlers(tmp_path)
+ update = private_update(data="buy:missing")
+ await instance.buy(update, None) # type: ignore[arg-type]
+ assert "no longer available" in update.effective_message.reply_text.await_args.args[0]
+
+ empty = private_update()
+ await instance.orders(empty, None) # type: ignore[arg-type]
+ assert "do not have any orders" in empty.effective_message.reply_text.await_args.args[0]
+ database.close()
+
+ asyncio.run(run())
+
+
+def test_status_refresh_marks_order_paid(tmp_path: Path) -> None:
+ async def run() -> None:
+ instance, database = handlers(tmp_path)
+ buy = private_update(data="buy:coffee")
+ await instance.buy(buy, None) # type: ignore[arg-type]
+ order = database.list_orders(100, 200)[0]
+
+ status = private_update(data=f"status:{order.id}", query_id="query_2")
+ await instance.status(status, None) # type: ignore[arg-type]
+
+ refreshed = database.get_order_for_owner(order.id, 100, 200)
+ assert refreshed is not None and refreshed.status == "paid"
+ assert "is paid" in status.callback_query.answer.await_args.args[0]
+ database.close()
+
+ asyncio.run(run())
+
+
+def test_orders_render_and_private_chat_guard(tmp_path: Path) -> None:
+ async def run() -> None:
+ instance, database = handlers(tmp_path)
+ buy = private_update(data="buy:coffee")
+ await instance.buy(buy, None) # type: ignore[arg-type]
+
+ listing = private_update()
+ await instance.orders(listing, None) # type: ignore[arg-type]
+ assert "Coffee voucher" in listing.effective_message.reply_text.await_args.args[0]
+
+ group = private_update(chat_type="group")
+ await instance.shop(group, None) # type: ignore[arg-type]
+ assert "private chat" in group.effective_message.reply_text.await_args.args[0]
+ database.close()
+
+ asyncio.run(run())
diff --git a/tests/test_catalog.py b/tests/test_catalog.py
new file mode 100644
index 0000000..319552b
--- /dev/null
+++ b/tests/test_catalog.py
@@ -0,0 +1,59 @@
+import json
+from pathlib import Path
+
+import pytest
+
+from makepay_telegram_bot.catalog import CatalogError, load_catalog
+
+
+def write_catalog(path: Path, value: object) -> None:
+ path.write_text(json.dumps(value), encoding="utf-8")
+
+
+def test_loads_and_normalizes_catalog(tmp_path: Path) -> None:
+ path = tmp_path / "catalog.json"
+ write_catalog(
+ path,
+ [
+ {
+ "id": "coffee",
+ "name": "Coffee voucher",
+ "description": "A demo product",
+ "amount": "5",
+ "fiatCurrency": "usd",
+ }
+ ],
+ )
+
+ product = load_catalog(path)["coffee"]
+
+ assert product.amount == "5.00"
+ assert product.fiat_currency == "USD"
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ [],
+ [{"id": "a", "name": "", "description": "Desc", "amount": "1"}],
+ [{"id": "a", "name": "Name", "description": "Desc", "amount": 1}],
+ [{"id": "UPPER CASE", "name": "Name", "description": "Desc", "amount": "5"}],
+ [{"id": "a", "name": "Name", "description": "Desc", "amount": "-1"}],
+ [{"id": "a", "name": "Name", "description": "Desc", "amount": "1.001"}],
+ [
+ {"id": "a", "name": "Name", "description": "Desc", "amount": "1"},
+ {"id": "a", "name": "Again", "description": "Desc", "amount": "2"},
+ ],
+ ],
+)
+def test_rejects_invalid_catalog(tmp_path: Path, value: object) -> None:
+ path = tmp_path / "catalog.json"
+ write_catalog(path, value)
+
+ with pytest.raises(CatalogError):
+ load_catalog(path)
+
+
+def test_rejects_missing_file(tmp_path: Path) -> None:
+ with pytest.raises(CatalogError, match="Unable to read"):
+ load_catalog(tmp_path / "missing.json")
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..8ee1ebb
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,74 @@
+from pathlib import Path
+
+import pytest
+
+from makepay_telegram_bot.config import ConfigurationError, Settings
+
+
+def valid_environment() -> dict[str, str]:
+ return {
+ "TELEGRAM_BOT_TOKEN": "123456:telegram-token",
+ "TELEGRAM_WEBHOOK_SECRET": "telegram_secret-123",
+ "PUBLIC_BASE_URL": "https://bot.example.com",
+ "MAKEPAY_KEY_ID": "key-id",
+ "MAKEPAY_KEY_SECRET": "key-secret",
+ "MAKEPAY_WEBHOOK_SECRET": "mkwhsec_example",
+ "DATABASE_PATH": "./state/orders.db",
+ "CATALOG_PATH": "./catalog.json",
+ }
+
+
+def test_loads_settings_and_derived_webhook_urls() -> None:
+ settings = Settings.from_env(valid_environment())
+
+ assert settings.telegram_webhook_url == "https://bot.example.com/webhooks/telegram"
+ assert settings.makepay_webhook_url == "https://bot.example.com/webhooks/makepay"
+ assert settings.database_path == Path("./state/orders.db")
+ assert settings.makepay_api_base_url == "https://www.makecrypto.io"
+ assert settings.reconcile_interval_seconds == 60
+
+
+@pytest.mark.parametrize(
+ ("name", "value", "message"),
+ [
+ ("PUBLIC_BASE_URL", "http://bot.example.com", "HTTPS origin"),
+ ("PUBLIC_BASE_URL", "https://user:pass@bot.example.com", "HTTPS origin"),
+ ("PUBLIC_BASE_URL", "https://bot.example.com/hidden-path", "HTTPS origin"),
+ ("PUBLIC_BASE_URL", "https://bot.example.com?token=bad", "HTTPS origin"),
+ ("TELEGRAM_WEBHOOK_SECRET", "spaces are unsafe", "may contain only"),
+ ("RECONCILE_INTERVAL_SECONDS", "zero", "integer"),
+ ("RECONCILE_INTERVAL_SECONDS", "2", "at least 10"),
+ ("LOG_LEVEL", "LOUD", "invalid"),
+ ],
+)
+def test_rejects_unsafe_or_invalid_settings(name: str, value: str, message: str) -> None:
+ environment = valid_environment()
+ environment[name] = value
+ with pytest.raises(ConfigurationError, match=message):
+ Settings.from_env(environment)
+
+
+def test_allows_explicit_local_http_for_development() -> None:
+ environment = valid_environment()
+ environment["PUBLIC_BASE_URL"] = "http://localhost:8000"
+ environment["MAKEPAY_API_BASE_URL"] = "http://127.0.0.1:8001"
+ environment["ALLOW_INSECURE_LOCALHOST"] = "true"
+
+ settings = Settings.from_env(environment)
+
+ assert settings.public_base_url == "http://localhost:8000"
+
+
+def test_parses_admin_ids() -> None:
+ environment = valid_environment()
+ environment["ADMIN_TELEGRAM_USER_IDS"] = "123, 456"
+
+ assert Settings.from_env(environment).admin_telegram_user_ids == {123, 456}
+
+
+def test_rejects_non_positive_admin_ids() -> None:
+ environment = valid_environment()
+ environment["ADMIN_TELEGRAM_USER_IDS"] = "-1"
+
+ with pytest.raises(ConfigurationError, match="positive"):
+ Settings.from_env(environment)
diff --git a/tests/test_database.py b/tests/test_database.py
new file mode 100644
index 0000000..b164ca8
--- /dev/null
+++ b/tests/test_database.py
@@ -0,0 +1,196 @@
+from pathlib import Path
+
+import pytest
+
+from makepay_telegram_bot.database import Database, normalize_order_status
+from makepay_telegram_bot.models import Product
+
+PRODUCT = Product(
+ id="coffee",
+ name="Coffee voucher",
+ description="A demo product",
+ amount="5.00",
+ fiat_currency="USD",
+)
+
+
+def make_order(database: Database) -> str:
+ order = database.create_or_get_order(
+ request_key="callback:1",
+ telegram_chat_id=100,
+ telegram_user_id=200,
+ product=PRODUCT,
+ )
+ database.attach_payment_link(order.id, "pay_123", "https://www.makepay.io/payment/pay_123")
+ return order.id
+
+
+def test_order_creation_is_idempotent_and_owner_scoped(tmp_path: Path) -> None:
+ database = Database(tmp_path / "orders.db")
+ first = database.create_or_get_order(
+ request_key="callback:1",
+ telegram_chat_id=100,
+ telegram_user_id=200,
+ product=PRODUCT,
+ )
+ second = database.create_or_get_order(
+ request_key="callback:1",
+ telegram_chat_id=100,
+ telegram_user_id=200,
+ product=PRODUCT,
+ )
+
+ assert first.id == second.id
+ assert database.get_order_for_owner(first.id, 100, 200) == first
+ assert database.get_order_for_owner(first.id, 100, 999) is None
+ database.close()
+
+
+def test_status_updates_are_deduplicated_and_create_outbox_notifications(
+ tmp_path: Path,
+) -> None:
+ database = Database(tmp_path / "orders.db")
+ order_id = make_order(database)
+
+ processing = database.apply_payment_status(
+ dedupe_key="group:processing",
+ delivery_id="delivery-1",
+ event_type="makepay.payment.status_changed",
+ makepay_status="deposit_received",
+ payment_uid="pay_123",
+ merchant_order_id=order_id,
+ session_id="session_123",
+ )
+ duplicate = database.apply_payment_status(
+ dedupe_key="group:processing",
+ delivery_id="delivery-2",
+ event_type="makepay.payment.status_changed",
+ makepay_status="deposit_received",
+ payment_uid="pay_123",
+ merchant_order_id=order_id,
+ session_id="session_123",
+ )
+
+ assert processing.changed is True
+ assert processing.order is not None and processing.order.status == "processing"
+ assert duplicate.duplicate is True
+ notification = database.pending_notifications()[0]
+ assert notification.status == "processing"
+ database.mark_notification_failed(notification.id, "telegram_timeout")
+ database.mark_notification_sent(notification.id)
+ assert database.pending_notifications() == []
+ database.close()
+
+
+def test_paid_is_final_but_late_payment_can_recover_failed_order(tmp_path: Path) -> None:
+ database = Database(tmp_path / "orders.db")
+ order_id = make_order(database)
+ database.apply_payment_status(
+ dedupe_key="failed",
+ makepay_status="failed",
+ payment_uid="pay_123",
+ merchant_order_id=order_id,
+ session_id="session_123",
+ )
+ recovered = database.apply_payment_status(
+ dedupe_key="paid",
+ makepay_status="complete",
+ payment_uid="pay_123",
+ merchant_order_id=order_id,
+ session_id="session_123",
+ )
+ stale = database.apply_payment_status(
+ dedupe_key="stale",
+ makepay_status="expired",
+ payment_uid="pay_123",
+ merchant_order_id=order_id,
+ session_id="session_123",
+ )
+
+ assert recovered.order is not None and recovered.order.status == "paid"
+ assert stale.order is not None and stale.order.status == "paid"
+ database.close()
+
+
+def test_webhook_can_correlate_by_merchant_order_id_before_uid_is_attached(
+ tmp_path: Path,
+) -> None:
+ database = Database(tmp_path / "orders.db")
+ order = database.create_or_get_order(
+ request_key="callback:1",
+ telegram_chat_id=100,
+ telegram_user_id=200,
+ product=PRODUCT,
+ )
+
+ result = database.apply_payment_status(
+ dedupe_key="early",
+ makepay_status="complete",
+ payment_uid="pay_early",
+ merchant_order_id=order.id,
+ session_id="session_early",
+ )
+
+ assert result.order is not None
+ assert result.order.makepay_payment_uid == "pay_early"
+ assert result.order.status == "paid"
+ database.close()
+
+
+def test_unknown_webhook_is_acknowledged_without_order(tmp_path: Path) -> None:
+ database = Database(tmp_path / "orders.db")
+ result = database.apply_payment_status(
+ dedupe_key="unknown",
+ makepay_status="pending",
+ payment_uid="missing",
+ merchant_order_id=None,
+ session_id=None,
+ )
+
+ assert result.order is None
+ assert result.duplicate is False
+ database.close()
+
+
+def test_failed_creation_can_be_retried_and_pending_orders_are_reconcilable(
+ tmp_path: Path,
+) -> None:
+ database = Database(tmp_path / "orders.db")
+ order = database.create_or_get_order(
+ request_key="callback:1",
+ telegram_chat_id=100,
+ telegram_user_id=200,
+ product=PRODUCT,
+ )
+ database.mark_creation_failed(order.id, "makepay_http_503")
+ failed = database.get_order_for_owner(order.id, 100, 200)
+ assert failed is not None and failed.status == "creation_failed"
+ assert failed.last_error == "makepay_http_503"
+
+ database.attach_payment_link(order.id, "pay_retry", "https://www.makepay.io/payment/pay_retry")
+ assert database.list_reconcilable_orders()[0].id == order.id
+ assert database.list_orders(100, 200, limit=1)[0].id == order.id
+ database.close()
+
+
+def test_payment_link_uid_cannot_change_after_early_webhook(tmp_path: Path) -> None:
+ database = Database(tmp_path / "orders.db")
+ order_id = make_order(database)
+
+ with pytest.raises(RuntimeError, match="different MakePay payment"):
+ database.attach_payment_link(
+ order_id, "pay_different", "https://www.makepay.io/payment/pay_different"
+ )
+ with pytest.raises(LookupError, match="Order not found"):
+ database.attach_payment_link(
+ "missing", "pay_missing", "https://www.makepay.io/payment/pay_missing"
+ )
+ database.close()
+
+
+def test_status_normalization() -> None:
+ assert normalize_order_status("COMPLETE") == "paid"
+ assert normalize_order_status("swapping") == "processing"
+ assert normalize_order_status("underpaid") == "underpaid"
+ assert normalize_order_status("cancelled") == "cancelled"
+ assert normalize_order_status("unknown_future_status") == "pending"
diff --git a/tests/test_makepay_client.py b/tests/test_makepay_client.py
new file mode 100644
index 0000000..b49470c
--- /dev/null
+++ b/tests/test_makepay_client.py
@@ -0,0 +1,193 @@
+import asyncio
+import json
+
+import httpx
+import pytest
+
+from makepay_telegram_bot.makepay_client import MakePayAPIError, MakePayClient
+from makepay_telegram_bot.models import Order, Product
+
+ORDER = Order(
+ id="05d0ab75-fc70-4058-b876-d9bb386734c7",
+ request_key="callback:1",
+ telegram_chat_id=100,
+ telegram_user_id=200,
+ product_id="coffee",
+ product_name="Coffee voucher",
+ amount="5.00",
+ fiat_currency="USD",
+ status="creating",
+ makepay_status=None,
+ makepay_payment_uid=None,
+ checkout_url=None,
+ makepay_session_id=None,
+ last_error=None,
+ created_at="2026-07-25T00:00:00Z",
+ updated_at="2026-07-25T00:00:00Z",
+)
+PRODUCT = Product(
+ id="coffee",
+ name="Coffee voucher",
+ description="A demo product",
+ amount="5.00",
+ fiat_currency="USD",
+)
+
+
+def test_creates_payment_link_with_auth_and_idempotency() -> None:
+ requests: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ requests.append(request)
+ return httpx.Response(
+ 201,
+ json={
+ "paymentLink": {
+ "uid": "pay_123",
+ "publicUrl": "https://www.makepay.io/payment/pay_123",
+ }
+ },
+ )
+
+ async def run() -> None:
+ client = MakePayClient(
+ key_id="key-id",
+ key_secret="key-secret",
+ api_base_url="https://www.makecrypto.io",
+ checkout_base_url="https://www.makepay.io",
+ transport=httpx.MockTransport(handler),
+ )
+ link = await client.create_payment_link(ORDER, PRODUCT)
+ await client.close()
+ assert link.uid == "pay_123"
+
+ asyncio.run(run())
+ request = requests[0]
+ body = json.loads(request.content)
+ assert request.headers["idempotency-key"] == f"telegram-{ORDER.id}"
+ assert request.headers["x-makecrypto-key-id"] == "key-id"
+ assert body["payload"]["orderId"] == ORDER.id
+ assert body["payload"]["amount"] == "5.00"
+ assert "telegram_chat_id" not in request.content.decode()
+
+
+def test_public_status_request_does_not_leak_api_credentials() -> None:
+ requests: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ requests.append(request)
+ return httpx.Response(200, json={"session": {"id": "session_1", "status": "complete"}})
+
+ async def run() -> None:
+ client = MakePayClient(
+ key_id="secret-key-id",
+ key_secret="secret-key-value",
+ api_base_url="https://www.makecrypto.io",
+ checkout_base_url="https://www.makepay.io",
+ transport=httpx.MockTransport(handler),
+ )
+ status = await client.get_payment_status("pay_123")
+ await client.close()
+ assert status.status == "complete"
+
+ asyncio.run(run())
+ assert requests[0].url.host == "www.makepay.io"
+ assert "x-makecrypto-key-id" not in requests[0].headers
+ assert "x-makecrypto-key-secret" not in requests[0].headers
+
+
+def test_falls_back_to_canonical_checkout_url() -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, json={"paymentLink": {"uid": "pay/unsafe"}})
+
+ async def run() -> None:
+ client = MakePayClient(
+ key_id="id",
+ key_secret="secret",
+ api_base_url="https://api.example.com",
+ checkout_base_url="https://pay.example.com",
+ transport=httpx.MockTransport(handler),
+ )
+ link = await client.create_payment_link(ORDER, PRODUCT)
+ await client.close()
+ assert link.checkout_url == "https://pay.example.com/payment/pay%2Funsafe"
+
+ asyncio.run(run())
+
+
+def test_raises_sanitized_api_error() -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(401, json={"error": "Invalid API credentials."})
+
+ async def run() -> None:
+ client = MakePayClient(
+ key_id="id",
+ key_secret="secret",
+ api_base_url="https://api.example.com",
+ checkout_base_url="https://pay.example.com",
+ transport=httpx.MockTransport(handler),
+ )
+ with pytest.raises(MakePayAPIError, match="Invalid API credentials") as caught:
+ await client.create_payment_link(ORDER, PRODUCT)
+ assert caught.value.status_code == 401
+ await client.close()
+
+ asyncio.run(run())
+
+
+def test_rejects_missing_uid_unsafe_url_and_invalid_json() -> None:
+ responses = iter(
+ [
+ httpx.Response(200, json={"paymentLink": {}}),
+ httpx.Response(
+ 200,
+ json={
+ "paymentLink": {
+ "uid": "pay_123",
+ "publicUrl": "javascript:alert(1)",
+ }
+ },
+ ),
+ httpx.Response(200, content=b"not-json"),
+ ]
+ )
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ return next(responses)
+
+ async def run() -> None:
+ client = MakePayClient(
+ key_id="id",
+ key_secret="secret",
+ api_base_url="https://api.example.com",
+ checkout_base_url="https://pay.example.com",
+ transport=httpx.MockTransport(handler),
+ )
+ with pytest.raises(MakePayAPIError, match="UID"):
+ await client.create_payment_link(ORDER, PRODUCT)
+ with pytest.raises(MakePayAPIError, match="unsafe"):
+ await client.create_payment_link(ORDER, PRODUCT)
+ with pytest.raises(MakePayAPIError, match="invalid JSON"):
+ await client.get_payment_status("pay_123")
+ await client.close()
+
+ asyncio.run(run())
+
+
+def test_missing_current_session_is_pending() -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, json={"session": None})
+
+ async def run() -> None:
+ client = MakePayClient(
+ key_id="id",
+ key_secret="secret",
+ api_base_url="https://api.example.com",
+ checkout_base_url="https://pay.example.com",
+ transport=httpx.MockTransport(handler),
+ )
+ status = await client.get_payment_status("pay_123")
+ assert status.status == "pending"
+ await client.close()
+
+ asyncio.run(run())
diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py
new file mode 100644
index 0000000..f11832e
--- /dev/null
+++ b/tests/test_webhooks.py
@@ -0,0 +1,58 @@
+import hashlib
+import hmac
+import json
+import time
+
+import pytest
+from makepay import MakePayError, parse_makepay_webhook
+
+from makepay_telegram_bot.webhooks import extract_makepay_event
+
+
+def sign(raw_body: bytes, secret: str, timestamp: int | None = None) -> str:
+ issued_at = int(time.time()) if timestamp is None else timestamp
+ signature = hmac.new(
+ secret.encode(), str(issued_at).encode() + b"." + raw_body, hashlib.sha256
+ ).hexdigest()
+ return f"t={issued_at},v1={signature}"
+
+
+def test_verifies_and_extracts_makepay_webhook() -> None:
+ payload = {
+ "deliveryId": "delivery_1",
+ "type": "makepay.payment.status_changed",
+ "paymentLink": {"uid": "pay_123", "merchantOrderId": "order_123"},
+ "session": {"id": "session_123", "status": "complete"},
+ }
+ raw_body = json.dumps(payload, separators=(",", ":")).encode()
+ parsed = parse_makepay_webhook(raw_body, sign(raw_body, "whsec"), "whsec")
+
+ event = extract_makepay_event(
+ parsed,
+ {
+ "x-makepay-delivery-group-id": "group_1",
+ "x-makepay-event": "makepay.payment.status_changed",
+ },
+ raw_body,
+ )
+
+ assert event.dedupe_key == "group_1"
+ assert event.payment_uid == "pay_123"
+ assert event.merchant_order_id == "order_123"
+ assert event.status == "complete"
+
+
+def test_rejects_invalid_or_stale_signature() -> None:
+ body = b'{"ok":true}'
+ with pytest.raises(MakePayError):
+ parse_makepay_webhook(body, sign(body, "wrong"), "correct")
+ with pytest.raises(MakePayError):
+ parse_makepay_webhook(body, sign(body, "secret", 1), "secret")
+
+
+def test_falls_back_to_stable_body_hash_without_delivery_headers() -> None:
+ body = b'{"status":"pending"}'
+ event = extract_makepay_event({"status": "pending"}, {}, body)
+
+ assert event.dedupe_key == f"body:{hashlib.sha256(body).hexdigest()}"
+ assert event.status == "pending"
diff --git a/tests/test_workers.py b/tests/test_workers.py
new file mode 100644
index 0000000..cf2af49
--- /dev/null
+++ b/tests/test_workers.py
@@ -0,0 +1,83 @@
+import asyncio
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+from makepay_telegram_bot.database import Database
+from makepay_telegram_bot.models import PaymentStatus, Product
+from makepay_telegram_bot.workers import notification_loop, reconciliation_loop
+
+PRODUCT = Product(
+ id="coffee",
+ name="Coffee voucher",
+ description="A demo product",
+ amount="5.00",
+ fiat_currency="USD",
+)
+
+
+class FakeMakePay:
+ async def get_payment_status(self, payment_uid: str) -> PaymentStatus:
+ return PaymentStatus(status="complete", session_id="session_123")
+
+
+def prepared_database(path: Path) -> tuple[Database, str]:
+ database = Database(path)
+ order = database.create_or_get_order(
+ request_key="callback:1",
+ telegram_chat_id=100,
+ telegram_user_id=200,
+ product=PRODUCT,
+ )
+ database.attach_payment_link(order.id, "pay_123", "https://www.makepay.io/payment/pay_123")
+ return database, order.id
+
+
+def test_reconciliation_and_notification_workers(tmp_path: Path) -> None:
+ async def run() -> None:
+ database, order_id = prepared_database(tmp_path / "orders.db")
+ wakeup = asyncio.Event()
+ reconcile = asyncio.create_task(
+ reconciliation_loop(
+ makepay=FakeMakePay(), # type: ignore[arg-type]
+ database=database,
+ notification_wakeup=wakeup,
+ interval_seconds=3600,
+ )
+ )
+ for _ in range(20):
+ order = database.get_order_for_owner(order_id, 100, 200)
+ if order is not None and order.status == "paid":
+ break
+ await asyncio.sleep(0)
+ reconcile.cancel()
+ try:
+ await reconcile
+ except asyncio.CancelledError:
+ pass
+ assert database.pending_notifications()[0].status == "paid"
+
+ bot = SimpleNamespace(send_message=AsyncMock())
+ application = SimpleNamespace(bot=bot)
+ notify = asyncio.create_task(
+ notification_loop(
+ application=application, # type: ignore[arg-type]
+ database=database,
+ wakeup=wakeup,
+ interval_seconds=3600,
+ )
+ )
+ for _ in range(20):
+ if not database.pending_notifications():
+ break
+ await asyncio.sleep(0)
+ notify.cancel()
+ try:
+ await notify
+ except asyncio.CancelledError:
+ pass
+ bot.send_message.assert_awaited_once()
+ assert "Payment complete" in bot.send_message.await_args.args[1]
+ database.close()
+
+ asyncio.run(run())