- Интеграция рекурентов от Юкассы
- Багфикс личного кабинета
This commit is contained in:
@@ -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
|
||||
# Отключить пополнение баланса через поддержку
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "⬅️ منوی اصلی",
|
||||
|
||||
@@ -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": "⬅️ В главное меню",
|
||||
|
||||
@@ -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": "🏠 В головне меню",
|
||||
|
||||
@@ -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": "🏠返回主菜单",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 обновил платеж до статуса',
|
||||
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1744
File diff suppressed because it is too large
Load Diff
+1747
File diff suppressed because it is too large
Load Diff
+1613
File diff suppressed because it is too large
Load Diff
+1611
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')
|
||||
Reference in New Issue
Block a user