- Интеграция рекурентов от Юкассы

- Багфикс личного кабинета
This commit is contained in:
firewookie
2026-03-06 09:47:58 +05:00
parent b31a893b13
commit aaffc26a90
25 changed files with 9462 additions and 32 deletions
+7
View File
@@ -369,6 +369,8 @@ REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# Макс. кол-во платежей реферала, с которых начисляется комиссия (0 = без лимита)
REFERRAL_MAX_COMMISSION_PAYMENTS=0
# Показывать раздел партнёрки в кабинете
REFERRAL_PARTNER_SECTION_VISIBLE=true
@@ -492,6 +494,11 @@ YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Рекуррентные платежи YooKassa (автосохранение карты для автоплатежей)
YOOKASSA_RECURRENT_ENABLED=false
# true = карта сохраняется обязательно, false = пользователь решает (чекбокс на стороне YooKassa)
YOOKASSA_RECURRENT_REQUIRED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# Отключить пополнение баланса через поддержку
+29
View File
@@ -1025,6 +1025,35 @@ async def update_user_subscription(
subscription=await _build_subscription_info_async(db, subscription),
)
if request.action == 'shorten':
if not request.days:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Days parameter is required for shorten action',
)
await extend_subscription(db, subscription, -request.days)
await db.refresh(subscription)
# Check if subscription expired after shortening
if subscription.end_date <= datetime.now(UTC):
subscription.status = SubscriptionStatus.EXPIRED.value
await db.commit()
await db.refresh(subscription)
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(
'Admin shortened subscription for user by days', admin_id=admin.id, user_id=user_id, days=request.days
)
return UpdateSubscriptionResponse(
success=True,
message=f'Subscription shortened by {request.days} days',
subscription=await _build_subscription_info_async(db, subscription),
)
if request.action == 'set_end_date':
if not request.end_date:
raise HTTPException(
+58
View File
@@ -32,6 +32,8 @@ from ..schemas.balance import (
PaymentMethodResponse,
PendingPaymentListResponse,
PendingPaymentResponse,
SavedCardResponse,
SavedCardsListResponse,
StarsInvoiceRequest,
StarsInvoiceResponse,
TopUpRequest,
@@ -1068,3 +1070,59 @@ async def check_payment_status(
old_status=old_status,
new_status=updated.status,
)
@router.get('/saved-cards', response_model=SavedCardsListResponse)
async def get_saved_cards(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's saved payment methods (cards) for recurrent payments."""
recurrent_enabled = settings.YOOKASSA_RECURRENT_ENABLED
if not recurrent_enabled:
return SavedCardsListResponse(cards=[], recurrent_enabled=False)
from app.database.crud.saved_payment_method import get_active_payment_methods_by_user
methods = await get_active_payment_methods_by_user(db, user.id)
cards = [
SavedCardResponse(
id=m.id,
method_type=m.method_type,
card_last4=m.card_last4,
card_type=m.card_type,
title=m.title,
created_at=m.created_at,
)
for m in methods
]
return SavedCardsListResponse(cards=cards, recurrent_enabled=True)
@router.delete('/saved-cards/{card_id}', status_code=status.HTTP_200_OK)
async def delete_saved_card(
card_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unlink (deactivate) a saved payment method."""
if not settings.YOOKASSA_RECURRENT_ENABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Recurrent payments are not enabled',
)
from app.database.crud.saved_payment_method import deactivate_payment_method
success = await deactivate_payment_method(db, card_id, user.id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Saved card not found',
)
return {'success': True, 'message': 'Card unlinked successfully'}
+21
View File
@@ -137,3 +137,24 @@ class ManualCheckResponse(BaseModel):
status_changed: bool = False
old_status: str | None = None
new_status: str | None = None
class SavedCardResponse(BaseModel):
"""Saved payment method (card) for recurrent payments."""
id: int
method_type: str
card_last4: str | None = None
card_type: str | None = None
title: str | None = None
created_at: datetime
class Config:
from_attributes = True
class SavedCardsListResponse(BaseModel):
"""List of saved payment methods."""
cards: list[SavedCardResponse]
recurrent_enabled: bool = False
+1 -1
View File
@@ -281,7 +281,7 @@ class UpdateSubscriptionRequest(BaseModel):
"""Request to update user subscription."""
action: str = Field(
..., description='Action: extend, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
..., description='Action: extend, shorten, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
)
# For extend action
+3
View File
@@ -219,6 +219,7 @@ class Settings(BaseSettings):
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
REFERRAL_INVITER_BONUS_KOPEKS: int = 10000
REFERRAL_COMMISSION_PERCENT: int = 25
REFERRAL_MAX_COMMISSION_PAYMENTS: int = 0 # Макс. кол-во платежей реферала с комиссией (0 = без лимита)
REFERRAL_PROGRAM_ENABLED: bool = True
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
@@ -342,6 +343,8 @@ class Settings(BaseSettings):
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
YOOKASSA_RECURRENT_ENABLED: bool = False
YOOKASSA_RECURRENT_REQUIRED: bool = True
DISABLE_TOPUP_BUTTONS: bool = False
SUPPORT_TOPUP_ENABLED: bool = True
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
+14
View File
@@ -50,6 +50,20 @@ async def create_referral_earning(
return earning
async def get_commission_payment_count(db: AsyncSession, referrer_id: int, referral_id: int) -> int:
"""Подсчитать количество комиссионных начислений реферера за платежи конкретного реферала."""
result = await db.execute(
select(func.count(ReferralEarning.id)).where(
and_(
ReferralEarning.user_id == referrer_id,
ReferralEarning.referral_id == referral_id,
ReferralEarning.reason.in_(['referral_commission_topup', 'referral_first_topup']),
)
)
)
return result.scalar() or 0
async def get_referral_earnings_by_user(
db: AsyncSession, user_id: int, limit: int = 50, offset: int = 0
) -> list[ReferralEarning]:
+167
View File
@@ -0,0 +1,167 @@
from datetime import UTC, datetime
import structlog
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SavedPaymentMethod
logger = structlog.get_logger(__name__)
async def create_saved_payment_method(
db: AsyncSession,
user_id: int,
yookassa_payment_method_id: str,
method_type: str = 'bank_card',
card_first6: str | None = None,
card_last4: str | None = None,
card_type: str | None = None,
card_expiry_month: str | None = None,
card_expiry_year: str | None = None,
title: str | None = None,
) -> SavedPaymentMethod | None:
"""Создаёт или реактивирует сохранённый метод оплаты."""
# Проверяем, есть ли уже такой метод (включая деактивированные)
existing = await get_payment_method_by_yookassa_id(db, yookassa_payment_method_id, include_inactive=True)
if existing:
# Реактивируем и обновляем данные
existing.is_active = True
existing.method_type = method_type
existing.card_first6 = card_first6
existing.card_last4 = card_last4
existing.card_type = card_type
existing.card_expiry_month = card_expiry_month
existing.card_expiry_year = card_expiry_year
existing.title = title
existing.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(existing)
logger.info(
'Реактивирован сохранённый метод оплаты',
saved_method_id=existing.id,
user_id=user_id,
method_type=method_type,
card_last4=card_last4,
)
return existing
method = SavedPaymentMethod(
user_id=user_id,
yookassa_payment_method_id=yookassa_payment_method_id,
method_type=method_type,
card_first6=card_first6,
card_last4=card_last4,
card_type=card_type,
card_expiry_month=card_expiry_month,
card_expiry_year=card_expiry_year,
title=title,
)
db.add(method)
try:
await db.commit()
except IntegrityError as e:
await db.rollback()
logger.error(
'Ошибка создания сохранённого метода оплаты',
yookassa_payment_method_id=yookassa_payment_method_id,
user_id=user_id,
e=e,
)
return None
await db.refresh(method)
logger.info(
'Создан сохранённый метод оплаты',
saved_method_id=method.id,
user_id=user_id,
method_type=method_type,
card_last4=card_last4,
)
return method
async def get_active_payment_methods_by_user(
db: AsyncSession,
user_id: int,
) -> list[SavedPaymentMethod]:
"""Получить все активные сохранённые методы оплаты пользователя."""
result = await db.execute(
select(SavedPaymentMethod)
.where(
SavedPaymentMethod.user_id == user_id,
SavedPaymentMethod.is_active == True,
)
.order_by(SavedPaymentMethod.created_at.desc())
)
return list(result.scalars().all())
async def get_payment_method_by_yookassa_id(
db: AsyncSession,
yookassa_payment_method_id: str,
include_inactive: bool = False,
) -> SavedPaymentMethod | None:
"""Найти сохранённый метод по YooKassa payment_method.id."""
query = select(SavedPaymentMethod).where(
SavedPaymentMethod.yookassa_payment_method_id == yookassa_payment_method_id,
)
if not include_inactive:
query = query.where(SavedPaymentMethod.is_active == True)
result = await db.execute(query)
return result.scalar_one_or_none()
async def deactivate_payment_method(
db: AsyncSession,
saved_method_id: int,
user_id: int,
) -> bool:
"""Деактивировать (soft-delete) сохранённый метод оплаты."""
result = await db.execute(
update(SavedPaymentMethod)
.where(
SavedPaymentMethod.id == saved_method_id,
SavedPaymentMethod.user_id == user_id,
SavedPaymentMethod.is_active == True,
)
.values(is_active=False, updated_at=datetime.now(UTC))
)
await db.commit()
if result.rowcount > 0:
logger.info(
'Метод оплаты деактивирован',
saved_method_id=saved_method_id,
user_id=user_id,
)
return True
return False
async def deactivate_all_user_payment_methods(
db: AsyncSession,
user_id: int,
) -> int:
"""Деактивировать все методы оплаты пользователя. Возвращает количество деактивированных."""
result = await db.execute(
update(SavedPaymentMethod)
.where(
SavedPaymentMethod.user_id == user_id,
SavedPaymentMethod.is_active == True,
)
.values(is_active=False, updated_at=datetime.now(UTC))
)
await db.commit()
if result.rowcount > 0:
logger.info(
'Все методы оплаты пользователя деактивированы',
user_id=user_id,
count=result.rowcount,
)
return result.rowcount
+31
View File
@@ -236,6 +236,37 @@ class YooKassaPayment(Base):
return f'<YooKassaPayment(id={self.id}, yookassa_id={self.yookassa_payment_id}, amount={self.amount_rubles}₽, status={self.status})>'
class SavedPaymentMethod(Base):
__tablename__ = 'saved_payment_methods'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
# YooKassa payment_method.id — ключ для рекуррентных списаний
yookassa_payment_method_id = Column(String(255), unique=True, nullable=False, index=True)
# Тип метода: bank_card, yoo_money, sberbank, tinkoff_bank, sbp, mir_pay
method_type = Column(String(50), nullable=False, default='bank_card')
# Отображаемые данные карты (маскированные)
card_first6 = Column(String(6), nullable=True)
card_last4 = Column(String(4), nullable=True)
card_type = Column(String(50), nullable=True) # Visa, MasterCard, Mir
card_expiry_month = Column(String(2), nullable=True)
card_expiry_year = Column(String(4), nullable=True)
title = Column(String(255), nullable=True) # "Bank card *4444"
is_active = Column(Boolean, default=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
user = relationship('User', backref='saved_payment_methods')
def __repr__(self):
return f'<SavedPaymentMethod(id={self.id}, user_id={self.user_id}, type={self.method_type}, last4={self.card_last4})>'
class CryptoBotPayment(Base):
__tablename__ = 'cryptobot_payments'
+2
View File
@@ -891,6 +891,8 @@
"AUTOPAY_STATUS_ENABLED": "enabled",
"AUTOPAY_SUCCESS": "\n✅ <b>Autopay completed</b>\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Auto-payment completed</b>\n\nBalance topped up by {amount} for subscription renewal.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Auto-payment failed</b>\n\nCould not charge {amount} from any saved card for subscription renewal.\n\nPlease top up your balance manually to avoid service interruption.",
"BACK": "⬅️ Back",
"BACK_BUTTON": "◀️ Back",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
+2
View File
@@ -911,6 +911,8 @@
"AUTOPAY_STATUS_ENABLED": "فعال",
"AUTOPAY_SUCCESS": "\n✅ <b>پرداخت خودکار انجام شد</b>\n\nاشتراک {days} روز تمدید شد.\nکسر: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ پرداخت خودکار {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>پرداخت خودکار انجام شد</b>\n\nموجودی به مبلغ {amount} برای تمدید اشتراک شارژ شد.",
"RECURRENT_TOPUP_FAILED": "❌ <b>پرداخت خودکار ناموفق بود</b>\n\nامکان کسر {amount} از هیچ کارت ذخیره شده‌ای برای تمدید اشتراک وجود نداشت.\n\nلطفاً موجودی را به صورت دستی شارژ کنید.",
"BACK": "⬅️ قبلی",
"BACK_BUTTON": "◀️ بازگشت",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ منوی اصلی",
+2
View File
@@ -911,6 +911,8 @@
"AUTOPAY_STATUS_ENABLED": "включен",
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатёж выполнен</b>\n\nБаланс пополнен на {amount} для продления подписки.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Автоплатёж не удался</b>\n\nНе удалось списать {amount} ни с одной сохранённой карты для продления подписки.\n\nПополните баланс вручную, чтобы подписка не прервалась.",
"BACK": "⬅️ Назад",
"BACK_BUTTON": "◀️ Назад",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
+2
View File
@@ -833,6 +833,8 @@
"AUTOPAY_STATUS_ENABLED": "увімкнено",
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатіж виконано</b>\n\nВашу підписку автоматично продовжено на {days} днів.\nСписано з балансу: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатіж {status}!",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>Автоплатіж виконано</b>\n\nБаланс поповнено на {amount} для продовження підписки.",
"RECURRENT_TOPUP_FAILED": "❌ <b>Автоплатіж не вдався</b>\n\nНе вдалося списати {amount} з жодної збереженої картки для продовження підписки.\n\nПоповніть баланс вручну, щоб підписка не перервалася.",
"BACK": "⬅️ Назад",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В головне меню",
"BACK_TO_MENU": "🏠 В головне меню",
+2
View File
@@ -831,6 +831,8 @@
"AUTOPAY_STATUS_ENABLED": "已启用",
"AUTOPAY_SUCCESS": "\n✅<b>自动支付成功</b>\n\n您的订阅已自动延长{days}天。\n已从余额扣除:{amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅自动支付{status}",
"RECURRENT_TOPUP_SUCCESS": "✅ <b>自动扣款成功</b>\n\n余额已充值{amount},用于续订订阅。",
"RECURRENT_TOPUP_FAILED": "❌ <b>自动扣款失败</b>\n\n无法从任何已保存的银行卡中扣除{amount}以续订订阅。\n\n请手动充值余额以避免服务中断。",
"BACK": "⬅️返回",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️返回主菜单",
"BACK_TO_MENU": "🏠返回主菜单",
+30 -1
View File
@@ -220,6 +220,18 @@ class MonitoringService:
# экспайрятся до того, как autopay успеет их продлить
if settings.ENABLE_AUTOPAY:
await self._process_autopayments(db)
# Рекуррентные автоплатежи: пополнение баланса с сохранённой карты
if settings.YOOKASSA_RECURRENT_ENABLED:
try:
from app.services.recurrent_payment_service import process_recurrent_payments
await process_recurrent_payments(bot=self.bot)
except Exception as recurrent_error:
logger.error(
'Ошибка рекуррентных автоплатежей',
error=recurrent_error,
exc_info=True,
)
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db)
@@ -421,6 +433,23 @@ class MonitoringService:
)
continue
# Пропускаем уведомление если autopay + рекуррентные платежи с карты настроены
if (
subscription.autopay_enabled
and settings.ENABLE_AUTOPAY
and settings.YOOKASSA_RECURRENT_ENABLED
):
from app.database.crud.saved_payment_method import get_active_payment_methods_by_user
saved_methods = await get_active_payment_methods_by_user(db, user.id)
if saved_methods:
logger.debug(
'Пропускаем уведомление об истечении: autopay + сохранённая карта',
user_identifier=user_identifier,
days=days,
)
continue
should_send = True
for other_days in warning_days:
if other_days < days:
@@ -947,7 +976,7 @@ class MonitoringService:
# Берём ACTIVE + недавно EXPIRED (middleware или check_and_update могли
# экспайрить до того, как monitoring успел запустить autopay)
recently_expired_threshold = current_time - timedelta(hours=2)
recently_expired_threshold = current_time - timedelta(hours=48)
result = await db.execute(
select(Subscription)
.options(
+112 -29
View File
@@ -382,6 +382,7 @@ class YooKassaPaymentMixin:
self,
db: AsyncSession,
payment: YooKassaPayment,
event_object: dict[str, Any] | None = None,
) -> bool:
"""Переносит успешный платёж YooKassa в транзакции и начисляет баланс пользователю."""
try:
@@ -568,6 +569,7 @@ class YooKassaPaymentMixin:
payment_type = payment_metadata.get('type', '')
is_simple_subscription = payment_purpose == 'simple_subscription_purchase'
is_trial_payment = payment_type == 'trial'
is_recurrent_topup = payment_metadata.get('purpose') == 'recurrent_topup'
transaction_type = (
TransactionType.SUBSCRIPTION_PAYMENT
@@ -803,36 +805,38 @@ class YooKassaPaymentMixin:
'Ошибка отправки уведомления админам о YooKassa пополнении', error=error, exc_info=True
)
# Отправляем уведомление пользователю (только Telegram-пользователям)
if getattr(self, 'bot', None) and user.telegram_id:
# Для рекуррентных автоплатежей уведомления отправляет recurrent_payment_service
if not is_recurrent_topup:
# Отправляем уведомление пользователю (только Telegram-пользователям)
if getattr(self, 'bot', None) and user.telegram_id:
try:
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
await self._send_payment_success_notification(
user.telegram_id,
payment.amount_kopeks,
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
db=db,
payment_method_title='Банковская карта (YooKassa)',
)
logger.info('Уведомление пользователю о платеже отправлено успешно')
except Exception as error:
logger.error('Ошибка отправки уведомления о платеже', error=error, exc_info=True)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
try:
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
await self._send_payment_success_notification(
user.telegram_id,
payment.amount_kopeks,
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
db=db,
payment_method_title='Банковская карта (YooKassa)',
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(
user, payment.amount_kopeks, db, getattr(self, 'bot', None)
)
except Exception as e:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя',
user_id=user.id,
error=e,
exc_info=True,
)
logger.info('Уведомление пользователю о платеже отправлено успешно')
except Exception as error:
logger.error('Ошибка отправки уведомления о платеже', error=error, exc_info=True)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(
user, payment.amount_kopeks, db, getattr(self, 'bot', None)
)
except Exception as e:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя',
user_id=user.id,
error=e,
exc_info=True,
)
if is_simple_subscription:
logger.info('Обнаружен платеж простой покупки подписки для пользователя', user_id=user.id)
@@ -1004,6 +1008,10 @@ class YooKassaPaymentMixin:
amount_rubles=payment.amount_kopeks / 100,
)
# Сохраняем привязанный метод оплаты для рекуррентных платежей
if settings.YOOKASSA_RECURRENT_ENABLED and event_object:
await self._save_payment_method_if_available(db, payment, event_object)
# Создаем чек через NaloGO (если NALOGO_ENABLED=true)
if hasattr(self, 'nalogo_service') and self.nalogo_service:
await self._create_nalogo_receipt(
@@ -1064,6 +1072,81 @@ class YooKassaPaymentMixin:
return updated_metadata
async def _save_payment_method_if_available(
self,
db: AsyncSession,
payment: YooKassaPayment,
event_object: dict[str, Any],
) -> None:
"""Сохраняет привязанный метод оплаты из ответа YooKassa, если карта была сохранена."""
try:
pm = event_object.get('payment_method') or {}
pm_id = pm.get('id')
pm_saved = pm.get('saved', False)
if not pm_id or not pm_saved:
return
from app.database.crud.saved_payment_method import (
create_saved_payment_method,
get_payment_method_by_yookassa_id,
)
# Проверяем, не сохранён ли уже
existing = await get_payment_method_by_yookassa_id(db, pm_id)
if existing:
logger.debug(
'Метод оплаты уже сохранён',
yookassa_payment_method_id=pm_id,
user_id=payment.user_id,
)
return
# Извлекаем данные карты
card = pm.get('card') or {}
card_first6 = card.get('first6')
card_last4 = card.get('last4')
card_type = card.get('card_type')
expiry_month = card.get('expiry_month')
expiry_year = card.get('expiry_year')
method_type = pm.get('type', 'bank_card')
# Формируем название
title = None
if card_last4:
type_label = card_type or 'Card'
title = f'{type_label} *{card_last4}'
saved = await create_saved_payment_method(
db=db,
user_id=payment.user_id,
yookassa_payment_method_id=pm_id,
method_type=method_type,
card_first6=card_first6,
card_last4=card_last4,
card_type=card_type,
card_expiry_month=expiry_month,
card_expiry_year=expiry_year,
title=title,
)
if saved:
logger.info(
'Метод оплаты сохранён для рекуррентных платежей',
saved_method_id=saved.id,
user_id=payment.user_id,
card_last4=card_last4,
method_type=method_type,
)
except Exception as save_error:
logger.error(
'Ошибка сохранения метода оплаты',
yookassa_payment_id=payment.yookassa_payment_id,
save_error=save_error,
exc_info=True,
)
async def _create_nalogo_receipt(
self,
db: AsyncSession,
@@ -1201,7 +1284,7 @@ class YooKassaPaymentMixin:
await db.refresh(payment)
if payment.status == 'succeeded' and payment.is_paid:
return await self._process_successful_yookassa_payment(db, payment)
return await self._process_successful_yookassa_payment(db, payment, event_object=event_object)
logger.info(
'Webhook YooKassa обновил платеж до статуса',
+342
View File
@@ -0,0 +1,342 @@
"""Сервис рекуррентных автоплатежей через YooKassa.
Находит подписки с autopay, у которых недостаточно баланса для продления,
и пополняет баланс с сохранённой карты. Существующий autopay в
monitoring_service затем спишет баланс и продлит подписку.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import (
SavedPaymentMethod,
Subscription,
SubscriptionStatus,
User,
UserPromoGroup,
)
logger = structlog.get_logger(__name__)
# Redis-like in-memory защита от дублей (сбрасывается при рестарте)
_processed_today: set[str] = set()
_processed_date: str = ''
def _reset_daily_guard() -> None:
global _processed_today, _processed_date
today = datetime.now(UTC).strftime('%Y-%m-%d')
if today != _processed_date:
_processed_today = set()
_processed_date = today
async def process_recurrent_payments(bot: Bot | None = None) -> dict:
"""
Основная функция: находит подписки, которым скоро нужно продление,
у которых недостаточно баланса, и пополняет баланс с сохранённой карты.
Returns:
dict: Статистика обработки
"""
if not settings.YOOKASSA_RECURRENT_ENABLED:
return {'skipped': True, 'reason': 'recurrent_disabled'}
if not settings.YOOKASSA_ENABLED:
return {'skipped': True, 'reason': 'yookassa_disabled'}
if not settings.ENABLE_AUTOPAY:
return {'skipped': True, 'reason': 'autopay_disabled'}
_reset_daily_guard()
stats = {
'checked': 0,
'payments_created': 0,
'insufficient_no_card': 0,
'already_processed': 0,
'errors': 0,
}
try:
async with AsyncSessionLocal() as db:
try:
subscriptions = await _find_subscriptions_needing_topup(db)
stats['checked'] = len(subscriptions)
for subscription in subscriptions:
user = subscription.user
if not user:
continue
guard_key = f'{user.id}_{subscription.id}'
if guard_key in _processed_today:
stats['already_processed'] += 1
continue
try:
result = await _process_single_subscription(db, subscription, user, bot)
if result == 'created':
stats['payments_created'] += 1
_processed_today.add(guard_key)
elif result == 'no_card':
stats['insufficient_no_card'] += 1
elif result == 'skipped':
stats['already_processed'] += 1
except Exception as e:
stats['errors'] += 1
logger.error(
'Ошибка обработки рекуррентного платежа',
subscription_id=subscription.id,
user_id=user.id,
error=e,
exc_info=True,
)
except Exception as e:
logger.error('Ошибка получения подписок для рекуррентных платежей', error=e, exc_info=True)
stats['errors'] += 1
except Exception as e:
logger.error('Критическая ошибка рекуррентных платежей', error=e, exc_info=True)
stats['errors'] += 1
if stats['payments_created'] > 0 or stats['errors'] > 0:
logger.info('Рекуррентные платежи: итоги', **stats)
return stats
async def _find_subscriptions_needing_topup(db: AsyncSession) -> list:
"""Находит подписки с autopay, которым скоро нужно продление."""
current_time = datetime.now(UTC)
max_days_before = settings.DEFAULT_AUTOPAY_DAYS_BEFORE
# Максимальный горизонт проверки
check_horizon = current_time + timedelta(days=max_days_before + 1)
recently_expired_threshold = current_time - timedelta(hours=48)
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user).options(
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
),
selectinload(Subscription.tariff),
)
.where(
and_(
or_(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date <= check_horizon,
),
and_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date >= recently_expired_threshold,
),
),
Subscription.autopay_enabled == True,
Subscription.is_trial == False,
)
)
)
return list(result.scalars().all())
async def _process_single_subscription(
db: AsyncSession,
subscription: Subscription,
user: User,
bot: Bot | None,
) -> str:
"""
Обрабатывает одну подписку: проверяет баланс, находит карту, создаёт автоплатёж.
Returns:
'created' автоплатёж создан
'no_card' нет сохранённой карты
'skipped' баланс достаточен или другая причина пропуска
"""
from app.database.crud.saved_payment_method import get_active_payment_methods_by_user
from app.services.payment_service import PaymentService
from app.services.subscription_service import SubscriptionService
# Рассчитываем стоимость продления
tariff = getattr(subscription, 'tariff', None)
if tariff:
autopay_period = tariff.get_shortest_period() or 30
else:
autopay_period = 30
subscription_service = SubscriptionService()
try:
renewal_cost = await subscription_service.calculate_renewal_price(
subscription,
autopay_period,
db,
user=user,
)
except Exception as e:
logger.error(
'Ошибка расчёта стоимости для рекуррентного платежа',
subscription_id=subscription.id,
user_id=user.id,
error=e,
)
return 'skipped'
if renewal_cost <= 0:
return 'skipped'
# Проверяем, хватает ли баланса
shortage = renewal_cost - user.balance_kopeks
if shortage <= 0:
# Баланса достаточно, обычный autopay справится
return 'skipped'
# Нужно пополнить баланс — ищем сохранённую карту
saved_methods = await get_active_payment_methods_by_user(db, user.id)
if not saved_methods:
return 'no_card'
# Сумма пополнения = нехватка (минимум YOOKASSA_MIN_AMOUNT_KOPEKS)
min_amount = settings.YOOKASSA_MIN_AMOUNT_KOPEKS
topup_amount_kopeks = max(shortage, min_amount)
topup_amount_rubles = topup_amount_kopeks / 100
# Создаём автоплатёж
payment_service = PaymentService()
yookassa_service = payment_service.yookassa_service
if not yookassa_service or not yookassa_service.configured:
logger.warning('YooKassa сервис не сконфигурирован для рекуррентных платежей')
return 'skipped'
description = settings.get_balance_payment_description(topup_amount_kopeks)
metadata = {
'user_id': str(user.id),
'user_telegram_id': str(user.telegram_id) if user.telegram_id else '',
'purpose': 'recurrent_topup',
'subscription_id': str(subscription.id),
'source': 'recurrent_payment_service',
}
# Перебираем все сохранённые карты пока не найдём рабочую
for saved_method in saved_methods:
result = await yookassa_service.create_autopayment(
amount=topup_amount_rubles,
currency='RUB',
description=description,
payment_method_id=saved_method.yookassa_payment_method_id,
metadata=metadata,
)
if not result:
card_display = f'*{saved_method.card_last4}' if saved_method.card_last4 else ''
logger.warning(
'Не удалось списать с карты, пробуем следующую',
user_id=user.id,
subscription_id=subscription.id,
payment_method_id=saved_method.yookassa_payment_method_id,
card_display=card_display,
)
continue
# Успешно — создаём локальную запись платежа
try:
result_payment = await payment_service.create_yookassa_payment(
db=db,
user_id=user.id,
amount_kopeks=topup_amount_kopeks,
description=description,
metadata=metadata,
)
if result_payment:
logger.info(
'Рекуррентный автоплатёж создан',
user_id=user.id,
subscription_id=subscription.id,
amount_kopeks=topup_amount_kopeks,
yookassa_payment_id=result.get('id'),
)
except Exception as e:
logger.warning('Ошибка создания локальной записи рекуррентного платежа', error=e)
# Уведомляем пользователя
if bot and user.telegram_id:
try:
from app.localization.texts import get_texts
texts = get_texts(user.language)
status = result.get('status', '')
if result.get('paid'):
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'),
callback_data='subscription_extend',
)],
]
)
msg = texts.t(
'RECURRENT_TOPUP_SUCCESS',
'✅ <b>Автоплатёж выполнен</b>\n\nБаланс пополнен на {amount} для продления подписки.',
).format(amount=settings.format_price(topup_amount_kopeks))
await bot.send_message(
chat_id=user.telegram_id,
text=msg,
parse_mode='HTML',
reply_markup=keyboard,
)
elif status == 'pending':
logger.info(
'Рекуррентный платёж в обработке',
user_id=user.id,
yookassa_payment_id=result.get('id'),
)
except Exception as notify_error:
logger.warning('Ошибка уведомления об автоплатеже', notify_error=notify_error)
return 'created'
# Все карты не сработали — уведомляем пользователя
if bot and user.telegram_id:
try:
from app.localization.texts import get_texts
texts = get_texts(user.language)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'),
callback_data='subscription_extend',
)],
]
)
msg = texts.t(
'RECURRENT_TOPUP_FAILED',
'❌ <b>Автоплатёж не удался</b>\n\nНе удалось списать {amount} ни с одной сохранённой карты для продления подписки.\n\nПополните баланс вручную, чтобы подписка не прервалась.',
).format(amount=settings.format_price(topup_amount_kopeks))
await bot.send_message(
chat_id=user.telegram_id,
text=msg,
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as notify_error:
logger.warning('Ошибка уведомления о неудачном автоплатеже', notify_error=notify_error)
return 'skipped'
+25 -1
View File
@@ -4,7 +4,7 @@ from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.referral import create_referral_earning, get_user_campaign_id
from app.database.crud.referral import create_referral_earning, get_commission_payment_count, get_user_campaign_id
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.models import ReferralEarning, TransactionType, User
from app.services.notification_delivery_service import (
@@ -169,6 +169,18 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
topup_amount_kopeks=topup_amount_kopeks / 100,
)
if commission_amount > 0 and settings.REFERRAL_MAX_COMMISSION_PAYMENTS > 0:
paid_count = await get_commission_payment_count(db, referrer.id, user.id)
if paid_count >= settings.REFERRAL_MAX_COMMISSION_PAYMENTS:
logger.info(
'Лимит комиссионных платежей исчерпан',
referrer_id=referrer.id,
referral_id=user.id,
paid_count=paid_count,
max_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
)
return True
if commission_amount > 0:
balance_ok = await add_user_balance(
db,
@@ -325,6 +337,18 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
)
elif commission_amount > 0:
if settings.REFERRAL_MAX_COMMISSION_PAYMENTS > 0:
paid_count = await get_commission_payment_count(db, referrer.id, user.id)
if paid_count >= settings.REFERRAL_MAX_COMMISSION_PAYMENTS:
logger.info(
'Лимит комиссионных платежей исчерпан',
referrer_id=referrer.id,
referral_id=user.id,
paid_count=paid_count,
max_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
)
return True
balance_ok = await add_user_balance(
db,
referrer,
+121
View File
@@ -112,6 +112,12 @@ class YooKassaService:
builder.set_receipt(receipt_data_dict)
# Рекуррентные платежи: сохранение карты
if settings.YOOKASSA_RECURRENT_ENABLED:
if settings.YOOKASSA_RECURRENT_REQUIRED:
builder.set_save_payment_method(True)
# Если не required — не устанавливаем, YooKassa покажет чекбокс
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
@@ -306,6 +312,23 @@ class YooKassaService:
'payment_method_type': payment_info_yk.payment_method.type
if payment_info_yk.payment_method
else None,
'payment_method_id': payment_info_yk.payment_method.id
if payment_info_yk.payment_method
else None,
'payment_method_saved': payment_info_yk.payment_method.saved
if payment_info_yk.payment_method and hasattr(payment_info_yk.payment_method, 'saved')
else False,
'payment_method_card': {
'first6': payment_info_yk.payment_method.card.first6,
'last4': payment_info_yk.payment_method.card.last4,
'card_type': payment_info_yk.payment_method.card.card_type,
'expiry_month': payment_info_yk.payment_method.card.expiry_month,
'expiry_year': payment_info_yk.payment_method.card.expiry_year,
}
if payment_info_yk.payment_method
and hasattr(payment_info_yk.payment_method, 'card')
and payment_info_yk.payment_method.card
else None,
'test_mode': payment_info_yk.test if hasattr(payment_info_yk, 'test') else None,
}
logger.warning('Платеж не найден в YooKassa ID', payment_id_in_yookassa=payment_id_in_yookassa)
@@ -324,3 +347,101 @@ class YooKassaService:
exc_info=True,
)
return None
async def create_autopayment(
self,
amount: float,
currency: str,
description: str,
payment_method_id: str,
metadata: dict[str, Any],
receipt_email: str | None = None,
receipt_phone: str | None = None,
) -> dict[str, Any] | None:
"""Создаёт рекуррентный автоплатёж через сохранённый payment_method_id (без confirmation)."""
if not self.configured:
logger.error('YooKassa не сконфигурирован. Невозможно создать автоплатёж.')
return None
customer_contact_for_receipt = {}
if receipt_email:
customer_contact_for_receipt['email'] = receipt_email
elif receipt_phone:
customer_contact_for_receipt['phone'] = receipt_phone
elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
customer_contact_for_receipt['email'] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
else:
logger.error(
'КРИТИЧНО: Не предоставлен email/телефон для чека автоплатежа и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.'
)
return None
try:
builder = PaymentRequestBuilder()
builder.set_amount({'value': str(round(amount, 2)), 'currency': currency.upper()})
builder.set_capture(True)
builder.set_payment_method_id(payment_method_id)
builder.set_description(description)
builder.set_metadata(metadata)
receipt_items_list: list[dict[str, Any]] = [
{
'description': description[:128],
'quantity': '1.00',
'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()},
'vat_code': str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
'payment_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
}
]
receipt_data_dict: dict[str, Any] = {'customer': customer_contact_for_receipt, 'items': receipt_items_list}
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logger.info(
'Создание автоплатежа YooKassa. Сумма: . payment_method_id: . Метаданные: ',
amount=amount,
currency=currency,
payment_method_id=payment_method_id,
metadata=metadata,
idempotence_key=idempotence_key,
)
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request, idempotence_key)
)
logger.info(
'Ответ YooKassa автоплатёж: ID=, Status=, Paid',
response_id=response.id,
status=response.status,
paid=response.paid,
)
return {
'id': response.id,
'status': response.status,
'paid': response.paid,
'metadata': response.metadata,
'amount_value': float(response.amount.value),
'amount_currency': response.amount.currency,
'idempotence_key_used': idempotence_key,
'refundable': response.refundable,
'created_at': response.created_at.isoformat()
if hasattr(response.created_at, 'isoformat')
else str(response.created_at),
'description_from_yk': response.description,
'test_mode': response.test if hasattr(response, 'test') else None,
}
except Exception as e:
logger.error(
'Ошибка создания автоплатежа YooKassa',
payment_method_id=payment_method_id,
error=e,
exc_info=True,
)
return None
+1726
View File
File diff suppressed because it is too large Load Diff
+1744
View File
File diff suppressed because it is too large Load Diff
+1747
View File
File diff suppressed because it is too large Load Diff
+1613
View File
File diff suppressed because it is too large Load Diff
+1611
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
"""add saved_payment_methods table for recurrent payments
Revision ID: 0015
Revises: 0014
Create Date: 2026-03-05
Adds saved_payment_methods table for storing YooKassa saved payment methods
(bank cards) that can be used for recurring automatic balance top-ups.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0015'
down_revision: Union[str, None] = '0014'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'saved_payment_methods' not in inspector.get_table_names():
op.create_table(
'saved_payment_methods',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True),
sa.Column('yookassa_payment_method_id', sa.String(255), nullable=False, unique=True, index=True),
sa.Column('method_type', sa.String(50), nullable=False, server_default='bank_card'),
sa.Column('card_first6', sa.String(6), nullable=True),
sa.Column('card_last4', sa.String(4), nullable=True),
sa.Column('card_type', sa.String(50), nullable=True),
sa.Column('card_expiry_month', sa.String(2), nullable=True),
sa.Column('card_expiry_year', sa.String(4), nullable=True),
sa.Column('title', sa.String(255), nullable=True),
sa.Column('is_active', sa.Boolean(), server_default=sa.text('1'), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now()),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'saved_payment_methods' in inspector.get_table_names():
op.drop_table('saved_payment_methods')