Files
admin 9400050ade feat(subscription): return to main menu after payment/renewal
- handle_successful_payment: after status, reset FSM to lobby and send main
  menu keyboard (state injected by dispatcher)
- confirm_development_payment (dev path): same — edit status then reply with
  main menu; add state param
- tests: assert second message carries main-menu markup; _StubState clear/set_state
2026-10-30 13:24:53 +02:00

196 lines
7.1 KiB
Python

"""Реальная оплата 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.fsm.context import FSMContext
from aiogram.types import LabeledPrice, Message, PreCheckoutQuery, SuccessfulPayment
from keyboards import main_menu_keyboard
from user_store import (
SUBSCRIPTION_PLANS,
activate_subscription_days,
activate_subscription,
connect,
ensure_subscription,
subscription_row,
subscription_status_text,
)
logger = logging.getLogger(__name__)
router = Router(name="payments")
STARS_CURRENCY = "XTR"
INVOICE_PREFIX = "sub:"
def plan_for_payload(payload: str) -> tuple[int, int] | None:
if payload.startswith("sub30:"):
try:
timestamp = payload.split(":", 1)[1]
datetime.datetime.strptime(timestamp, "%Y%m%d%H%M%S")
except (ValueError, IndexError):
return None
return (30, SUBSCRIPTION_PLANS[30])
try:
prefix, days_part, timestamp = payload.split(":", 2)
if prefix != "sub" or not timestamp.isdigit():
return None
days = int(days_part)
except (ValueError, IndexError):
return None
price = SUBSCRIPTION_PLANS.get(days)
if price is None or (days == 0 and price != 1):
return None
return days, price
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, days: int = 30
) -> None:
"""Отправляет выбранный инвойс на разовую подписку в Stars."""
price = SUBSCRIPTION_PLANS[days]
payload = f"{INVOICE_PREFIX}{days}:{datetime.datetime.now(datetime.timezone.utc):%Y%m%d%H%M%S}"
kwargs: dict[str, object] = {
"chat_id": chat_id,
"title": f"Подписка на {days} дней",
"description": "Доступ к боту по генерации текста постов",
"payload": payload,
"currency": STARS_CURRENCY,
"prices": [LabeledPrice(label=f"{days} дней", amount=price)],
"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:
"""Подтверждаем только свой инвойс, в правильной валюте и по правильной цене."""
plan = plan_for_payload(query.invoice_payload)
if plan is None or query.currency != STARS_CURRENCY or query.total_amount != plan[1]:
await query.answer(ok=False, error_message="Некорректная сумма или валюта платежа.")
return
await query.answer(ok=True)
async def _return_to_main_menu(message: Message, state: FSMContext | None) -> None:
"""После успешной оплаты/продления возвращает пользователя в главное меню."""
if state is not None:
await state.clear()
await state.set_state("BotMode:lobby")
await message.answer("Оплата принята.", reply_markup=main_menu_keyboard())
@router.message(F.successful_payment)
async def handle_successful_payment(message: Message, state: FSMContext | None = None) -> None:
payment = message.successful_payment
if payment is None or message.from_user is None:
return
charge_id = payment.telegram_payment_charge_id
plan = plan_for_payload(payment.invoice_payload)
if plan is None or payment.currency != STARS_CURRENCY or payment.total_amount != plan[1]:
await _reject(message, charge_id, "неверная сумма или валюта")
return
days, _price = plan
db_path = _db_path()
if await asyncio.to_thread(payment_recorded, charge_id, db_path):
await message.answer("Этот платёж уже учтён — подписка не продлена повторно.")
return
if days == 0:
days = 1
await asyncio.to_thread(ensure_subscription, message.from_user.id)
await asyncio.to_thread(activate_subscription_days, message.from_user.id, days)
sub = await asyncio.to_thread(subscription_row, message.from_user.id, db_path)
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(subscription_status_text(sub))
await _return_to_main_menu(message, state)
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(
"Платёж получен, но активировать подписку не получилось. Напиши, пожалуйста, поддержке."
)