feat: настоящая оплата Telegram Stars вместо имитации
- payments.py: sendInvoice (XTR, 100 звёзд) -> pre_checkout_query -> successful_payment - /subscribe больше не активирует подписку сам — только отправляет счёт - handle_pre_checkout подтверждает только свой payload в XTR на 100 звёзд - таблица payments по telegram_payment_charge_id защищает от двойного начисления - 16 новых тестов платежей, старый тест заглушки переписан; 75 тестов OK - Dockerfile копирует payments.py, VERSION 0.1.0+25
This commit is contained in:
1 parent
b586d3aa36
commit
3c8917c20a
8 files changed
+424
-29
No files matched your search
+1
-1
@@ -6,5 +6,5 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY bot.py apinex_client.py user_store.py help_message.py VERSION ./
|
||||
COPY bot.py apinex_client.py user_store.py help_message.py payments.py VERSION ./
|
||||
CMD ["python", "bot.py"]
|
||||
@@ -44,7 +44,7 @@ Telegram-бот, который по запросу пользователя г
|
||||
- Coolify приложение: `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`.
|
||||
- SQLite: `/app/data/social_writer.sqlite3`; persistent volume `d32u5zcq2g4odmdpq9hw91yn-social-post-writer-dev-data` монтируется в `/app/data`.
|
||||
- Секреты хранятся в переменных окружения приложения Coolify; не сохраняются в репозитории.
|
||||
- `DEPLOY_ENV=development`, `DEV_CHAT_ID` — для уведомления о новой версии; версия в файле `VERSION` (сейчас `0.1.0+24`).
|
||||
- `DEPLOY_ENV=development`, `DEV_CHAT_ID` — для уведомления о новой версии; версия в файле `VERSION` (сейчас `0.1.0+25`).
|
||||
- Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`.
|
||||
|
||||
## Статус
|
||||
@@ -72,6 +72,7 @@ Telegram-бот, который по запросу пользователя г
|
||||
- [x] Development deployment/version notice and user-visible error details.
|
||||
- [x] Онбординг после `/start`: welcome Rich Message (`WELCOME_RICH_HTML`) → пауза → вызов `help_command`; правки справки автоматически меняют онбординг
|
||||
- [x] Подписка: триал 3 дня, `/subscribe` за 100 звёзд (30 дней), `/balance`, блокировка генерации без активной подписки
|
||||
- [x] Реальная оплата Stars: `sendInvoice` (XTR) → `pre_checkout_query` → `successful_payment`; подписка активируется только после оплаты, `telegram_payment_charge_id` защищает от двойного начисления
|
||||
- [x] Справка бота (`/help`) описывает `/balance` и `/subscribe`
|
||||
- [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog)
|
||||
|
||||
@@ -81,7 +82,7 @@ Telegram-бот, который по запросу пользователя г
|
||||
**Выполнено (Готово):** MVP генерации и STT · классификатор намерений и ToV · SQLite-настройки и reply-правки · деплой в Coolify + volume · API-логи · `/clear` · `/idea` · онбординг · подписка · документация.
|
||||
|
||||
**В планах (Backlog):**
|
||||
1. **Реальная оплата звёздами** (Telegram Payments: `sendInvoice`/`createInvoiceLink`, `pre_checkout_query`, `successful_payment`) — сейчас имитация.
|
||||
1. **Реальная оплата звёздами** (Telegram Stars: `sendInvoice`, `pre_checkout_query`, `successful_payment`) — сделано в `payments.py`; осталось проверить возврат (`refundStarPayment`) и автопродление.
|
||||
2. Автопродление подписки и уведомления об окончании.
|
||||
3. Админ-панель: статистика пользователей, подписок, расходов APInex.
|
||||
4. Лимиты на генерации (защита расходов на API).
|
||||
|
||||
@@ -56,10 +56,11 @@ Telegram-бот для генерации постов для соцсетей.
|
||||
|
||||
```
|
||||
bot.py — роутинг, команды, обработка сообщений (aiogram 3.x)
|
||||
payments.py — оплата Stars: sendInvoice, pre_checkout_query, successful_payment, защита от двойного списания
|
||||
apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов
|
||||
user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки
|
||||
help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback
|
||||
VERSION — версия релиза (сейчас 0.1.0+24)
|
||||
VERSION — версия релиза (сейчас 0.1.0+25)
|
||||
```
|
||||
|
||||
### Основные потоки
|
||||
@@ -100,7 +101,9 @@ update_tone_from_image → _handle_image_message
|
||||
| `_check_subscription` | Проверяет активную подписку; если нет — показывает статус и блокирует |
|
||||
| `_show_subscription_info` | Формирует сообщение о статусе (триал / подписка / истёк) |
|
||||
| `balance_command` | `/balance` — статус подписки |
|
||||
| `subscribe_command` | `/subscribe` — активация подписки (оплата 100 звёзд) |
|
||||
| `subscribe_command` | `/subscribe` — отправляет Stars-инвойс на 30 дней (подписка активируется только после оплаты) |
|
||||
| `handle_pre_checkout` | Подтверждает только свой инвойс в XTR на 100 звёзд, всё остальное отклоняет |
|
||||
| `handle_successful_payment` | Активирует подписку после реальной оплаты; `telegram_payment_charge_id` пишется в `payments` для защиты от повторного начисления |
|
||||
| `clear_command` | `/clear` — сброс данных + обнуление подписки |
|
||||
| `help_command` | `/help` — меню справки (Rich Message с кнопками) + fallback на plain text |
|
||||
| `help_navigation` | Callback `help:<section>` → `edit_message_text(rich_message=...)` — переключение разделов |
|
||||
@@ -145,10 +148,10 @@ SUBSCRIPTION_PRICE_STARS = 100
|
||||
1. `/start` → `ensure_subscription` → триал 3 дня, `is_active = 1`.
|
||||
2. Генерация доступна, пока `_days_until > 0`.
|
||||
3. Триал кончился → `_check_subscription` блокирует, предлагает `/subscribe`.
|
||||
4. `/subscribe` → `activate_subscription(months=1)` → +30 дней от момента оплаты, `is_active = 1`.
|
||||
4. `/subscribe` → `sendInvoice` (XTR, 100 звёзд) → `pre_checkout_query` (проверка суммы/валюты/payload) → `successful_payment` → `activate_subscription(months=1)` → +30 дней.
|
||||
5. `/clear` → `deactivate_subscription` → `is_active = 0`; нужен `/subscribe`.
|
||||
|
||||
**Важно про оплату:** приём звёзд — **имитация**. Бот пишет «пришли 100 звёзд» и сразу активирует подписку, не проводя реальный платёж. Для настоящих платежей нужен отдельный invoicing-флоу (Telegram Payments / `sendInvoice` для Stars), что в этой версии не реализовано.
|
||||
**Оплата реальная:** `payments.py` проводит настоящий платёж Telegram Stars. Подписка активируется **только** в `successful_payment` — сам `/subscribe` её не продлевает. `telegram_payment_charge_id` каждого платежа пишется в таблицу `payments`, поэтому один платёж не может активировать подписку дважды; по этому же id делается возврат через `refundStarPayment`. Платится валюта `XTR`, `provider_token` пустой (цифровой товар).
|
||||
|
||||
**Часовые пояса:** SQLite `CURRENT_TIMESTAMP` возвращает UTC без tz. Код аккуратно приводит все значения к naive UTC перед сравнением, чтобы не было сдвигов.
|
||||
|
||||
@@ -170,7 +173,7 @@ SUBSCRIPTION_PRICE_STARS = 100
|
||||
| `/start` | Онбординг (welcome Rich Message + справка) + создание триала | ✅ |
|
||||
| `/help` | Меню справки: полноширинные кнопки разделов, переход в любой раздел + plain fallback | ✅ |
|
||||
| `/balance` | Статус подписки / триала | ✅ |
|
||||
| `/subscribe` | Активация подписки (100 звёзд, 30 дней) | ✅ |
|
||||
| `/subscribe` | Счёт на оплату: 100 звёзд (XTR) за 30 дней; активация — после оплаты | ✅ |
|
||||
| `/idea` | Одна идея для поста | ❌ (нужна подписка) |
|
||||
| `/clear` | Полный сброс данных + обнуление подписки | ✅ |
|
||||
|
||||
@@ -195,4 +198,4 @@ SUBSCRIPTION_PRICE_STARS = 100
|
||||
python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`).
|
||||
Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **платежи** (инвойс, pre_checkout, successful_payment, защита от двойного списания), **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`).
|
||||
@@ -14,10 +14,12 @@ from aiogram.client.telegram import TEST
|
||||
from aiogram.filters import CommandStart
|
||||
from aiogram.types import CallbackQuery, InputRichMessage, Message
|
||||
|
||||
import payments
|
||||
import user_store
|
||||
|
||||
from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, create_tone_profile, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, transcribe_audio
|
||||
from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain
|
||||
from payments import send_subscription_invoice
|
||||
from user_store import (
|
||||
add_post_samples,
|
||||
clear_user_data,
|
||||
@@ -36,7 +38,6 @@ from user_store import (
|
||||
SUBSCRIPTION_PRICE_STARS,
|
||||
ensure_subscription,
|
||||
subscription_row,
|
||||
activate_subscription,
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -210,16 +211,22 @@ async def subscribe_command(message: Message) -> None:
|
||||
await message.answer("Не удалось определить пользователя.")
|
||||
return
|
||||
await asyncio.to_thread(ensure_subscription, message.from_user.id)
|
||||
try:
|
||||
await send_subscription_invoice(
|
||||
message.bot, message.chat.id, message.message_thread_id
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Invoice send failed (%s): %s", type(exc).__name__, str(exc)[:180])
|
||||
if DEPLOY_ENV == "development":
|
||||
await report_error(message, "отправка счёта на оплату", exc)
|
||||
await message.answer(
|
||||
"Не получилось открыть счёт на оплату. Попробуй ещё раз через минуту "
|
||||
"или напиши поддержке."
|
||||
)
|
||||
return
|
||||
await message.answer(
|
||||
f"Активирую подписку на 30 дней. "
|
||||
f"Для оплаты пришли {SUBSCRIPTION_PRICE_STARS} звёзд в этом чате.\n\n"
|
||||
f"Жду оплаты..."
|
||||
)
|
||||
days = await asyncio.to_thread(activate_subscription, message.from_user.id, months=1)
|
||||
await asyncio.sleep(0.6)
|
||||
await message.answer(
|
||||
f"Подписка активирована на {days} дн. Спасибо за оплату {SUBSCRIPTION_PRICE_STARS} звёзд!\n\n"
|
||||
f"Теперь доступны генерация постов, идей и голосовые."
|
||||
f"Счёт на {SUBSCRIPTION_PRICE_STARS} звёзд на 30 дней выше.\n\n"
|
||||
f"Подписка активируется сразу после оплаты — отдельного подтверждения не нужно."
|
||||
)
|
||||
|
||||
|
||||
@@ -517,6 +524,7 @@ async def handle_text(message: Message) -> None:
|
||||
|
||||
async def main() -> None:
|
||||
init_db()
|
||||
payments.init_payments_table()
|
||||
token = os.environ.get("BOT_TOKEN", "").strip()
|
||||
if not token:
|
||||
raise RuntimeError("BOT_TOKEN is required")
|
||||
@@ -526,6 +534,7 @@ async def main() -> None:
|
||||
bot = Bot(token=token, session=session)
|
||||
await notify_development_deploy(bot)
|
||||
dp = Dispatcher()
|
||||
dp.include_router(payments.router)
|
||||
dp.include_router(router)
|
||||
|
||||
try:
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
"""Реальная оплата Telegram Stars (XTR) за подписку.
|
||||
|
||||
Цикл: sendInvoice -> pre_checkout_query -> answerPreCheckoutQuery ->
|
||||
successful_payment -> activate_subscription.
|
||||
`telegram_payment_charge_id` каждого платежа пишется в таблицу payments,
|
||||
поэтому один платёж не может активировать подписку дважды, а по нему потом
|
||||
можно сделать возврат через refundStarPayment.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
from contextlib import closing
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import LabeledPrice, Message, PreCheckoutQuery, SuccessfulPayment
|
||||
|
||||
from user_store import (
|
||||
SUBSCRIPTION_PRICE_STARS,
|
||||
activate_subscription,
|
||||
connect,
|
||||
ensure_subscription,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router(name="payments")
|
||||
|
||||
STARS_CURRENCY = "XTR"
|
||||
INVOICE_PREFIX = "sub30:"
|
||||
|
||||
|
||||
def _db_path(db_path: str | None = None) -> str | None:
|
||||
return db_path if db_path is not None else os.getenv("BOT_DB_PATH")
|
||||
|
||||
|
||||
def init_payments_table(db_path: str | None = None) -> None:
|
||||
with closing(connect(db_path)) as connection, connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
telegram_payment_charge_id TEXT PRIMARY KEY,
|
||||
telegram_user_id INTEGER NOT NULL,
|
||||
invoice_payload TEXT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
total_amount INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_payments_user ON payments(telegram_user_id)"
|
||||
)
|
||||
|
||||
|
||||
def payment_recorded(charge_id: str, db_path: str | None = None) -> bool:
|
||||
with closing(connect(db_path)) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT 1 FROM payments WHERE telegram_payment_charge_id = ?", (charge_id,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def save_payment(
|
||||
charge_id: str,
|
||||
user_id: int,
|
||||
invoice_payload: str,
|
||||
currency: str,
|
||||
total_amount: int,
|
||||
db_path: str | None = None,
|
||||
) -> None:
|
||||
with closing(connect(db_path)) as connection, connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO payments
|
||||
(telegram_payment_charge_id, telegram_user_id, invoice_payload, currency, total_amount)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(charge_id, user_id, invoice_payload, currency, total_amount),
|
||||
)
|
||||
|
||||
|
||||
async def send_subscription_invoice(bot, chat_id: int, thread_id: int | None = None) -> None:
|
||||
"""Отправляет инвойс на 30 дней подписки за Stars."""
|
||||
payload = f"{INVOICE_PREFIX}{datetime.datetime.now(datetime.timezone.utc):%Y%m%d%H%M%S}"
|
||||
kwargs: dict[str, object] = {
|
||||
"chat_id": chat_id,
|
||||
"title": "Подписка на 30 дней",
|
||||
"description": "Доступ к генерации постов, идей и голосовым на 30 дней.",
|
||||
"payload": payload,
|
||||
"currency": STARS_CURRENCY,
|
||||
"prices": [LabeledPrice(label="30 дней", amount=SUBSCRIPTION_PRICE_STARS)],
|
||||
"provider_token": "",
|
||||
}
|
||||
if thread_id is not None:
|
||||
kwargs["message_thread_id"] = thread_id
|
||||
await bot.send_invoice(**kwargs)
|
||||
|
||||
|
||||
@router.pre_checkout_query()
|
||||
async def handle_pre_checkout(query: PreCheckoutQuery) -> None:
|
||||
"""Подтверждаем только свой инвойс, в правильной валюте и по правильной цене."""
|
||||
if query.currency != STARS_CURRENCY or query.total_amount != SUBSCRIPTION_PRICE_STARS:
|
||||
await query.answer(ok=False, error_message="Некорректная сумма или валюта платежа.")
|
||||
return
|
||||
if not query.invoice_payload.startswith(INVOICE_PREFIX):
|
||||
await query.answer(ok=False, error_message="Этот счёт не принадлежит подписке.")
|
||||
return
|
||||
await query.answer(ok=True)
|
||||
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
async def handle_successful_payment(message: Message) -> None:
|
||||
payment = message.successful_payment
|
||||
if payment is None or message.from_user is None:
|
||||
return
|
||||
|
||||
charge_id = payment.telegram_payment_charge_id
|
||||
if payment.currency != STARS_CURRENCY or payment.total_amount != SUBSCRIPTION_PRICE_STARS:
|
||||
await _reject(message, charge_id, "неверная сумма или валюта")
|
||||
return
|
||||
if not payment.invoice_payload.startswith(INVOICE_PREFIX):
|
||||
await _reject(message, charge_id, "неизвестный invoice_payload")
|
||||
return
|
||||
|
||||
db_path = _db_path()
|
||||
if await asyncio.to_thread(payment_recorded, charge_id, db_path):
|
||||
await message.answer("Этот платёж уже учтён — подписка не продлена повторно.")
|
||||
return
|
||||
|
||||
await asyncio.to_thread(ensure_subscription, message.from_user.id)
|
||||
days = await asyncio.to_thread(activate_subscription, message.from_user.id, months=1)
|
||||
await asyncio.to_thread(
|
||||
save_payment,
|
||||
charge_id,
|
||||
message.from_user.id,
|
||||
payment.invoice_payload,
|
||||
payment.currency,
|
||||
payment.total_amount,
|
||||
db_path,
|
||||
)
|
||||
await message.answer(
|
||||
f"Оплата получена — подписка активирована на {days} дн.\n\n"
|
||||
f"Списано {payment.total_amount} звёзд. Теперь доступны генерация постов, "
|
||||
f"идей и голосовые. Команда /balance покажет статус."
|
||||
)
|
||||
|
||||
|
||||
async def _reject(message: Message, charge_id: str, reason: str) -> None:
|
||||
logger.error(
|
||||
"Rejected payment charge=%s user=%s reason=%s",
|
||||
charge_id,
|
||||
message.from_user.id if message.from_user else None,
|
||||
reason,
|
||||
)
|
||||
await message.answer(
|
||||
"Платёж получен, но активировать подписку не получилось. Напиши, пожалуйста, поддержке."
|
||||
)
|
||||
@@ -0,0 +1,220 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from aiogram.types import LabeledPrice, SuccessfulPayment
|
||||
|
||||
import bot
|
||||
import payments
|
||||
import user_store
|
||||
|
||||
|
||||
class PaymentStoreTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.NamedTemporaryFile(suffix=".sqlite3", delete=False)
|
||||
self.tmp.close()
|
||||
self.db = self.tmp.name
|
||||
user_store.init_db(self.db)
|
||||
payments.init_payments_table(self.db)
|
||||
|
||||
def tearDown(self):
|
||||
os.unlink(self.db)
|
||||
|
||||
def test_payment_is_recorded_once(self):
|
||||
payments.save_payment("charge1", 42, "sub30:1", "XTR", 100, self.db)
|
||||
self.assertTrue(payments.payment_recorded("charge1", self.db))
|
||||
self.assertFalse(payments.payment_recorded("charge2", self.db))
|
||||
|
||||
def test_save_payment_is_idempotent(self):
|
||||
payments.save_payment("charge1", 42, "sub30:1", "XTR", 100, self.db)
|
||||
payments.save_payment("charge1", 42, "sub30:1", "XTR", 100, self.db)
|
||||
with payments.closing(payments.connect(self.db)) as connection:
|
||||
rows = connection.execute("SELECT COUNT(*) FROM payments").fetchone()
|
||||
self.assertEqual(rows[0], 1)
|
||||
|
||||
|
||||
class InvoiceTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_invoice_uses_stars_and_correct_price(self):
|
||||
fake_bot = AsyncMock()
|
||||
await payments.send_subscription_invoice(fake_bot, 555)
|
||||
kwargs = fake_bot.send_invoice.await_args.kwargs
|
||||
self.assertEqual(kwargs["currency"], "XTR")
|
||||
self.assertEqual(kwargs["provider_token"], "")
|
||||
self.assertEqual(kwargs["chat_id"], 555)
|
||||
self.assertTrue(kwargs["payload"].startswith(payments.INVOICE_PREFIX))
|
||||
prices = kwargs["prices"]
|
||||
self.assertIsInstance(prices[0], LabeledPrice)
|
||||
self.assertEqual(prices[0].amount, user_store.SUBSCRIPTION_PRICE_STARS)
|
||||
|
||||
async def test_invoice_includes_thread_when_given(self):
|
||||
fake_bot = AsyncMock()
|
||||
await payments.send_subscription_invoice(fake_bot, 555, thread_id=77)
|
||||
self.assertEqual(fake_bot.send_invoice.await_args.kwargs["message_thread_id"], 77)
|
||||
|
||||
async def test_invoice_omits_thread_when_absent(self):
|
||||
fake_bot = AsyncMock()
|
||||
await payments.send_subscription_invoice(fake_bot, 555)
|
||||
self.assertNotIn("message_thread_id", fake_bot.send_invoice.await_args.kwargs)
|
||||
|
||||
|
||||
class PreCheckoutTests(unittest.IsolatedAsyncioTestCase):
|
||||
def _query(self, currency="XTR", amount=100, payload="sub30:1"):
|
||||
query = AsyncMock()
|
||||
query.currency = currency
|
||||
query.total_amount = amount
|
||||
query.invoice_payload = payload
|
||||
return query
|
||||
|
||||
async def test_accepts_valid_subscription_invoice(self):
|
||||
query = self._query()
|
||||
await payments.handle_pre_checkout(query)
|
||||
query.answer.assert_awaited_once_with(ok=True)
|
||||
|
||||
async def test_rejects_wrong_amount(self):
|
||||
query = self._query(amount=1)
|
||||
await payments.handle_pre_checkout(query)
|
||||
self.assertFalse(query.answer.await_args.kwargs["ok"])
|
||||
|
||||
async def test_rejects_wrong_currency(self):
|
||||
query = self._query(currency="USD")
|
||||
await payments.handle_pre_checkout(query)
|
||||
self.assertFalse(query.answer.await_args.kwargs["ok"])
|
||||
|
||||
async def test_rejects_foreign_payload(self):
|
||||
query = self._query(payload="someone-elses-invoice")
|
||||
await payments.handle_pre_checkout(query)
|
||||
self.assertFalse(query.answer.await_args.kwargs["ok"])
|
||||
|
||||
|
||||
def _direct_thread(*args, **kwargs):
|
||||
"""Вызывает синхронную функцию прямо, без пула потоков."""
|
||||
func = args[0]
|
||||
return func(*args[1:], **kwargs)
|
||||
|
||||
|
||||
def _direct_thread_mock():
|
||||
return AsyncMock(side_effect=_direct_thread)
|
||||
|
||||
class _StubMessage:
|
||||
"""Минимальный Message: нам нужны только successful_payment, from_user и answer."""
|
||||
|
||||
def __init__(self, payment=None, user_id=42, chat_id=555):
|
||||
self.successful_payment = payment
|
||||
self.from_user = SimpleNamespace(id=user_id)
|
||||
self.chat = SimpleNamespace(id=chat_id)
|
||||
self.answers: list[str] = []
|
||||
|
||||
async def answer(self, text: str, **kwargs):
|
||||
self.answers.append(text)
|
||||
return self
|
||||
|
||||
|
||||
def _paid_message(charge_id="charge1", amount=100, currency="XTR", payload="sub30:1"):
|
||||
payment = SuccessfulPayment(
|
||||
currency=currency,
|
||||
total_amount=amount,
|
||||
invoice_payload=payload,
|
||||
telegram_payment_charge_id=charge_id,
|
||||
provider_payment_charge_id="",
|
||||
)
|
||||
return _StubMessage(payment=payment)
|
||||
|
||||
|
||||
def _db() -> str:
|
||||
handle = tempfile.NamedTemporaryFile(suffix=".sqlite3", delete=False)
|
||||
handle.close()
|
||||
user_store.init_db(handle.name)
|
||||
payments.init_payments_table(handle.name)
|
||||
return handle.name
|
||||
|
||||
|
||||
class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase):
|
||||
def tearDown(self):
|
||||
if getattr(self, "db", None):
|
||||
os.unlink(self.db)
|
||||
|
||||
async def test_payment_activates_subscription(self):
|
||||
message = _paid_message()
|
||||
self.db = _db()
|
||||
user_store.ensure_subscription(42, self.db)
|
||||
with patch.dict(os.environ, {"BOT_DB_PATH": self.db}):
|
||||
await payments.handle_successful_payment(message)
|
||||
self.assertEqual(len(message.answers), 1)
|
||||
self.assertIn("активирована", message.answers[0])
|
||||
self.assertTrue(payments.payment_recorded("charge1", self.db))
|
||||
row = user_store.subscription_row(42, self.db)
|
||||
self.assertIsNotNone(row["last_paid_at"])
|
||||
|
||||
async def test_duplicate_payment_does_not_extend_twice(self):
|
||||
message = _paid_message(charge_id="charge-dup")
|
||||
with (
|
||||
patch.object(payments, "payment_recorded", new=AsyncMock(return_value=True)),
|
||||
patch.object(payments, "save_payment", new=AsyncMock()) as save,
|
||||
patch.object(payments.asyncio, "to_thread", new=_direct_thread_mock()),
|
||||
):
|
||||
await payments.handle_successful_payment(message)
|
||||
save.assert_not_awaited()
|
||||
self.assertIn("уже учтён", message.answers[0])
|
||||
|
||||
async def test_wrong_amount_is_rejected_without_activation(self):
|
||||
message = _paid_message(amount=5)
|
||||
with (
|
||||
patch.object(payments, "save_payment", new=AsyncMock()) as save,
|
||||
patch.object(payments, "activate_subscription") as activate,
|
||||
):
|
||||
await payments.handle_successful_payment(message)
|
||||
activate.assert_not_called()
|
||||
save.assert_not_awaited()
|
||||
self.assertIn("поддержке", message.answers[0])
|
||||
|
||||
async def test_wrong_currency_is_rejected(self):
|
||||
message = _paid_message(currency="USD")
|
||||
with patch.object(payments, "activate_subscription") as activate:
|
||||
await payments.handle_successful_payment(message)
|
||||
activate.assert_not_called()
|
||||
|
||||
async def test_foreign_payload_is_rejected_without_activation(self):
|
||||
message = _paid_message(payload="other")
|
||||
with patch.object(payments, "activate_subscription") as activate:
|
||||
await payments.handle_successful_payment(message)
|
||||
activate.assert_not_called()
|
||||
|
||||
|
||||
class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase):
|
||||
def _message(self):
|
||||
message = _StubMessage()
|
||||
message.bot = AsyncMock()
|
||||
message.message_thread_id = None
|
||||
return message
|
||||
|
||||
async def test_subscribe_sends_invoice_and_never_activates_directly(self):
|
||||
from pathlib import Path
|
||||
|
||||
source = Path(bot.__file__).read_text(encoding="utf-8")
|
||||
body = source.split("async def subscribe_command")[1].split("\nasync def ")[0]
|
||||
self.assertNotIn("activate_subscription", body)
|
||||
|
||||
message = self._message()
|
||||
with (
|
||||
patch.object(bot.asyncio, "to_thread", new=AsyncMock()),
|
||||
patch.object(bot, "send_subscription_invoice", new=AsyncMock()) as invoice,
|
||||
):
|
||||
await bot.subscribe_command(message)
|
||||
invoice.assert_awaited_once_with(message.bot, 555, None)
|
||||
self.assertIn("Счёт", message.answers[0])
|
||||
|
||||
async def test_subscribe_reports_failure_when_invoice_fails(self):
|
||||
message = self._message()
|
||||
with (
|
||||
patch.object(bot.asyncio, "to_thread", new=AsyncMock()),
|
||||
patch.object(bot, "send_subscription_invoice", new=AsyncMock(side_effect=RuntimeError("boom"))),
|
||||
):
|
||||
await bot.subscribe_command(message)
|
||||
self.assertIn("поддержке", message.answers[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -523,10 +523,13 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(sub["is_active"], 1)
|
||||
self.assertGreater(user_store._days_until(sub), 0)
|
||||
|
||||
async def test_subscribe_command_activates_30_days(self):
|
||||
async def test_subscribe_command_sends_invoice_without_activating(self):
|
||||
user_store.ensure_subscription(102, self.db_path)
|
||||
message = SimpleNamespace(
|
||||
from_user=SimpleNamespace(id=102),
|
||||
chat=SimpleNamespace(id=555),
|
||||
message_thread_id=None,
|
||||
bot=AsyncMock(),
|
||||
answer=AsyncMock(),
|
||||
)
|
||||
|
||||
@@ -534,16 +537,16 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase):
|
||||
return function(*args, **kwargs)
|
||||
|
||||
with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread):
|
||||
with patch.object(bot, "notify_development_deploy", new=AsyncMock()):
|
||||
with patch.object(bot, "send_subscription_invoice", new=AsyncMock()) as invoice:
|
||||
await bot.subscribe_command(message)
|
||||
|
||||
msg_calls = [c.args[0] for c in message.answer.call_args_list]
|
||||
self.assertTrue(any("Активирую подписку на 30 дней" in m for m in msg_calls))
|
||||
self.assertTrue(any("Подписка активирована на 30 дн." in m for m in msg_calls))
|
||||
invoice.assert_awaited_once()
|
||||
# Подписка не активируется до реальной оплаты.
|
||||
sub = user_store.subscription_row(102, self.db_path)
|
||||
self.assertIsNotNone(sub["last_paid_at"])
|
||||
self.assertEqual(sub["is_active"], 1)
|
||||
self.assertTrue(user_store._days_until(sub) >= 29)
|
||||
self.assertIsNone(sub["last_paid_at"])
|
||||
msg_calls = [c.args[0] for c in message.answer.call_args_list]
|
||||
self.assertTrue(any("Счёт" in m for m in msg_calls))
|
||||
|
||||
|
||||
async def test_balance_command_shows_subscription_info(self):
|
||||
user_store.ensure_subscription(103, self.db_path)
|
||||
@@ -601,4 +604,4 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user