Files
social-post-writer-bot/payments.py
T
admin 3c8917c20a 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
2026-10-07 10:35:51 +02:00

160 lines
6.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.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(
"Платёж получен, но активировать подписку не получилось. Напиши, пожалуйста, поддержке."
)