diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 2658bad9..c57cf9ab 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -1018,10 +1018,10 @@ async def update_user_subscription( ) if request.action == 'extend': - if not request.days: + if not request.days or request.days <= 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail='Days parameter is required for extend action', + detail='Days must be a positive integer', ) await extend_subscription(db, subscription, request.days) @@ -1041,10 +1041,10 @@ async def update_user_subscription( ) if request.action == 'shorten': - if not request.days: + if not request.days or request.days <= 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail='Days parameter is required for shorten action', + detail='Days must be a positive integer', ) await extend_subscription(db, subscription, -request.days) diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 4512c952..a20333b8 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -14,6 +14,10 @@ from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.database.crud.saved_payment_method import ( + deactivate_payment_method, + get_active_payment_methods_by_user, +) from app.database.crud.user import get_user_by_id from app.database.models import PaymentMethod, Transaction, User from app.services.payment_method_config_service import get_enabled_methods_for_user @@ -1090,8 +1094,6 @@ async def get_saved_cards( 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 = [ @@ -1122,8 +1124,6 @@ async def delete_saved_card( 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: diff --git a/app/cabinet/schemas/balance.py b/app/cabinet/schemas/balance.py index e43d1aa3..a72ad1da 100644 --- a/app/cabinet/schemas/balance.py +++ b/app/cabinet/schemas/balance.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class BalanceResponse(BaseModel): @@ -26,8 +26,7 @@ class TransactionResponse(BaseModel): created_at: datetime completed_at: datetime | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class TransactionListResponse(BaseModel): @@ -114,8 +113,7 @@ class PendingPaymentResponse(BaseModel): user_telegram_id: int | None = None user_username: str | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class PendingPaymentListResponse(BaseModel): @@ -149,8 +147,7 @@ class SavedCardResponse(BaseModel): title: str | None = None created_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class SavedCardsListResponse(BaseModel): diff --git a/app/config.py b/app/config.py index c386f3a8..02b0c9f2 100644 --- a/app/config.py +++ b/app/config.py @@ -356,7 +356,7 @@ class Settings(BaseSettings): YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000 YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False YOOKASSA_RECURRENT_ENABLED: bool = False - YOOKASSA_RECURRENT_REQUIRED: bool = True + YOOKASSA_RECURRENT_REQUIRED: bool = False DISABLE_TOPUP_BUTTONS: bool = False SUPPORT_TOPUP_ENABLED: bool = True PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False diff --git a/app/database/crud/referral.py b/app/database/crud/referral.py index 77629b1a..fd4a9315 100644 --- a/app/database/crud/referral.py +++ b/app/database/crud/referral.py @@ -57,7 +57,7 @@ async def get_commission_payment_count(db: AsyncSession, referrer_id: int, refer and_( ReferralEarning.user_id == referrer_id, ReferralEarning.referral_id == referral_id, - ReferralEarning.reason.in_(['referral_commission_topup', 'referral_first_topup']), + ReferralEarning.reason == 'referral_commission_topup', ) ) ) diff --git a/app/database/crud/saved_payment_method.py b/app/database/crud/saved_payment_method.py index 84320413..d3e8254a 100644 --- a/app/database/crud/saved_payment_method.py +++ b/app/database/crud/saved_payment_method.py @@ -26,28 +26,36 @@ async def create_saved_payment_method( """Создаёт или реактивирует сохранённый метод оплаты.""" # Проверяем, есть ли уже такой метод (включая деактивированные) - 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) + result = await db.execute( + update(SavedPaymentMethod) + .where( + SavedPaymentMethod.yookassa_payment_method_id == yookassa_payment_method_id, + SavedPaymentMethod.user_id == user_id, + ) + .values( + is_active=True, + 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, + updated_at=datetime.now(UTC), + ) + .returning(SavedPaymentMethod) + ) + await db.commit() + reactivated = result.scalar_one_or_none() + if reactivated: logger.info( 'Реактивирован сохранённый метод оплаты', - saved_method_id=existing.id, + saved_method_id=reactivated.id, user_id=user_id, method_type=method_type, card_last4=card_last4, ) - return existing + return reactivated method = SavedPaymentMethod( user_id=user_id, @@ -101,6 +109,24 @@ async def get_active_payment_methods_by_user( return list(result.scalars().all()) +async def get_user_ids_with_active_payment_methods( + db: AsyncSession, + user_ids: list[int], +) -> set[int]: + """Вернуть подмножество user_ids, у которых есть хотя бы один активный метод оплаты.""" + if not user_ids: + return set() + result = await db.execute( + select(SavedPaymentMethod.user_id) + .where( + SavedPaymentMethod.user_id.in_(user_ids), + SavedPaymentMethod.is_active == True, # noqa: E712 + ) + .distinct() + ) + return set(result.scalars().all()) + + async def get_payment_method_by_yookassa_id( db: AsyncSession, yookassa_payment_method_id: str, diff --git a/app/database/models.py b/app/database/models.py index bdfbd1ce..5e3f56fc 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -259,8 +259,8 @@ class SavedPaymentMethod(Base): is_active = Column(Boolean, default=True) - created_at = Column(AwareDateTime(), default=func.now()) - updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now()) + created_at = Column(AwareDateTime(), nullable=False, server_default=func.now()) + updated_at = Column(AwareDateTime(), nullable=False, server_default=func.now(), onupdate=func.now()) user = relationship('User', backref='saved_payment_methods') diff --git a/app/handlers/referral.py b/app/handlers/referral.py index fb0ada5e..3b491e7b 100644 --- a/app/handlers/referral.py +++ b/app/handlers/referral.py @@ -98,8 +98,11 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A if settings.REFERRAL_MAX_COMMISSION_PAYMENTS > 0: commission_line = texts.t( 'REFERRAL_REWARD_COMMISSION_LIMITED', - '• Комиссия с пополнений реферала: {percent}%', - ).format(percent=get_effective_referral_commission_percent(db_user)) + '• Комиссия с первых {max_payments} пополнений реферала: {percent}%', + ).format( + percent=get_effective_referral_commission_percent(db_user), + max_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS, + ) else: commission_line = texts.t( 'REFERRAL_REWARD_COMMISSION', diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index fab7f85e..c0b1d5d7 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -889,6 +889,12 @@ "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days", "AUTOPAY_STATUS_DISABLED": "disabled", "AUTOPAY_STATUS_ENABLED": "enabled", + "AUTOPAY_STATUS_CARD_ACTIVE": "✅ Enabled — automatic card charge scheduled", + "AUTOPAY_STATUS_NO_CARD": "✅ Enabled — subscription will renew automatically", + "AUTOPAY_STATUS_OFF": "❌ Disabled — don't forget to renew manually!", + "AUTOPAY_ACTION_CHECK_BALANCE": "💰 Make sure you have enough balance: {balance}", + "AUTOPAY_ACTION_ENABLE": "💡 Enable autopay or renew your subscription manually", + "AUTOPAY_ACTION_RENEW": "💡 Renew your subscription manually", "AUTOPAY_SUCCESS": "\n✅ Autopay completed\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n", "AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!", "RECURRENT_TOPUP_SUCCESS": "✅ Auto-payment completed\n\nBalance topped up by {amount} for subscription renewal.", @@ -1310,7 +1316,7 @@ "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} from {referral_name}", "REFERRAL_REWARDS_HEADER": "🎁 How rewards work:", "REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: {percent}%", - "REFERRAL_REWARD_COMMISSION_LIMITED": "• Commission from referral top-ups: {percent}%", + "REFERRAL_REWARD_COMMISSION_LIMITED": "• Commission from the first {max_payments} referral top-ups: {percent}%", "REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: {bonus}", "REFERRAL_REWARD_NEW_USER": "• New user receives: {bonus} on the first top-up from {minimum}", "REFERRAL_SHARE_BUTTON": "📤 Share", diff --git a/app/localization/locales/fa.json b/app/localization/locales/fa.json index d6ab0604..965bb24b 100644 --- a/app/localization/locales/fa.json +++ b/app/localization/locales/fa.json @@ -909,6 +909,12 @@ "AUTOPAY_SET_DAYS_BUTTON": "⚙️ تنظیم روزها", "AUTOPAY_STATUS_DISABLED": "غیرفعال", "AUTOPAY_STATUS_ENABLED": "فعال", + "AUTOPAY_STATUS_CARD_ACTIVE": "✅ فعال — کارت به صورت خودکار شارژ می‌شود", + "AUTOPAY_STATUS_NO_CARD": "✅ فعال — اشتراک به صورت خودکار تمدید می‌شود", + "AUTOPAY_STATUS_OFF": "❌ غیرفعال — فراموش نکنید دستی تمدید کنید!", + "AUTOPAY_ACTION_CHECK_BALANCE": "💰 مطمئن شوید موجودی کافی دارید: {balance}", + "AUTOPAY_ACTION_ENABLE": "💡 پرداخت خودکار را فعال کنید یا اشتراک را دستی تمدید کنید", + "AUTOPAY_ACTION_RENEW": "💡 اشتراک را دستی تمدید کنید", "AUTOPAY_SUCCESS": "\n✅ پرداخت خودکار انجام شد\n\nاشتراک {days} روز تمدید شد.\nکسر: {amount}\n", "AUTOPAY_TOGGLE_SUCCESS": "✅ پرداخت خودکار {status}!", "RECURRENT_TOPUP_SUCCESS": "✅ پرداخت خودکار انجام شد\n\nموجودی به مبلغ {amount} برای تمدید اشتراک شارژ شد.", @@ -1331,7 +1337,7 @@ "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} از {referral_name}", "REFERRAL_REWARDS_HEADER": "🎁 نحوه عملکرد پاداش‌ها:", "REFERRAL_REWARD_COMMISSION": "• کمیسیون از هر شارژ دعوت‌شده: {percent}%", - "REFERRAL_REWARD_COMMISSION_LIMITED": "• کمیسیون از شارژ دعوت‌شده: {percent}%", + "REFERRAL_REWARD_COMMISSION_LIMITED": "• کمیسیون از {max_payments} شارژ اول دعوت‌شده: {percent}%", "REFERRAL_REWARD_INVITER": "• پاداش اولین شارژ دعوت‌شده: {bonus}", "REFERRAL_REWARD_NEW_USER": "• کاربر جدید دریافت می‌کند: {bonus} با اولین شارژ از {minimum}", "REFERRAL_SHARE_BUTTON": "📤 اشتراک‌گذاری", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 4c4f3c50..b8bd127a 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -909,6 +909,12 @@ "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни", "AUTOPAY_STATUS_DISABLED": "выключен", "AUTOPAY_STATUS_ENABLED": "включен", + "AUTOPAY_STATUS_CARD_ACTIVE": "✅ Включен — будет автоматическое списание с карты", + "AUTOPAY_STATUS_NO_CARD": "✅ Включен — подписка продлится автоматически", + "AUTOPAY_STATUS_OFF": "❌ Отключен — не забудьте продлить вручную!", + "AUTOPAY_ACTION_CHECK_BALANCE": "💰 Убедитесь, что на балансе достаточно средств: {balance}", + "AUTOPAY_ACTION_ENABLE": "💡 Включите автоплатеж или продлите подписку вручную", + "AUTOPAY_ACTION_RENEW": "💡 Продлите подписку вручную", "AUTOPAY_SUCCESS": "\n✅ Автоплатеж выполнен\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n", "AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!", "RECURRENT_TOPUP_SUCCESS": "✅ Автоплатёж выполнен\n\nБаланс пополнен на {amount} для продления подписки.", @@ -1331,7 +1337,7 @@ "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} от {referral_name}", "REFERRAL_REWARDS_HEADER": "🎁 Как работают награды:", "REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: {percent}%", - "REFERRAL_REWARD_COMMISSION_LIMITED": "• Комиссия с пополнений реферала: {percent}%", + "REFERRAL_REWARD_COMMISSION_LIMITED": "• Комиссия с первых {max_payments} пополнений реферала: {percent}%", "REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: {bonus}", "REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: {bonus} при первом пополнении от {minimum}", "REFERRAL_SHARE_BUTTON": "📤 Поделиться", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 674827d7..aa7b4a5e 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -831,6 +831,12 @@ "AUTOPAY_SET_DAYS_BUTTON": "⚙️ Налаштувати дні", "AUTOPAY_STATUS_DISABLED": "вимкнено", "AUTOPAY_STATUS_ENABLED": "увімкнено", + "AUTOPAY_STATUS_CARD_ACTIVE": "✅ Увімкнено — буде автоматичне списання з картки", + "AUTOPAY_STATUS_NO_CARD": "✅ Увімкнено — підписка продовжиться автоматично", + "AUTOPAY_STATUS_OFF": "❌ Вимкнено — не забудьте продовжити вручну!", + "AUTOPAY_ACTION_CHECK_BALANCE": "💰 Переконайтеся, що на балансі достатньо коштів: {balance}", + "AUTOPAY_ACTION_ENABLE": "💡 Увімкніть автоплатіж або продовжіть підписку вручну", + "AUTOPAY_ACTION_RENEW": "💡 Продовжіть підписку вручну", "AUTOPAY_SUCCESS": "\n✅ Автоплатіж виконано\n\nВашу підписку автоматично продовжено на {days} днів.\nСписано з балансу: {amount}\n", "AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатіж {status}!", "RECURRENT_TOPUP_SUCCESS": "✅ Автоплатіж виконано\n\nБаланс поповнено на {amount} для продовження підписки.", @@ -1247,7 +1253,7 @@ "REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: {amount} від {referral_name}", "REFERRAL_REWARDS_HEADER": "🎁 Як працюють нагороди:", "REFERRAL_REWARD_COMMISSION": "• Комісія з кожного поповнення реферала: {percent}%", - "REFERRAL_REWARD_COMMISSION_LIMITED": "• Комісія з поповнень реферала: {percent}%", + "REFERRAL_REWARD_COMMISSION_LIMITED": "• Комісія з перших {max_payments} поповнень реферала: {percent}%", "REFERRAL_REWARD_INVITER": "• Ви отримуєте при першому поповненні реферала: {bonus}", "REFERRAL_REWARD_NEW_USER": "• Новий користувач отримує: {bonus} при першому поповненні від {minimum}", "REFERRAL_SHARE_BUTTON": "📤 Поділитися", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index e5bc1957..961f323c 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -829,6 +829,12 @@ "AUTOPAY_SET_DAYS_BUTTON": "⚙️设置天数", "AUTOPAY_STATUS_DISABLED": "已禁用", "AUTOPAY_STATUS_ENABLED": "已启用", +"AUTOPAY_STATUS_CARD_ACTIVE": "✅ 已启用 — 将自动从银行卡扣款", +"AUTOPAY_STATUS_NO_CARD": "✅ 已启用 — 订阅将自动续订", +"AUTOPAY_STATUS_OFF": "❌ 已禁用 — 请别忘了手动续订!", +"AUTOPAY_ACTION_CHECK_BALANCE": "💰 请确保余额充足:{balance}", +"AUTOPAY_ACTION_ENABLE": "💡 请启用自动支付或手动续订", +"AUTOPAY_ACTION_RENEW": "💡 请手动续订", "AUTOPAY_SUCCESS": "\n✅自动支付成功\n\n您的订阅已自动延长{days}天。\n已从余额扣除:{amount}\n", "AUTOPAY_TOGGLE_SUCCESS": "✅自动支付{status}!", "RECURRENT_TOPUP_SUCCESS": "✅ 自动扣款成功\n\n余额已充值{amount},用于续订订阅。", @@ -1245,7 +1251,7 @@ "REFERRAL_RECENT_EARNINGS_ITEM": "•{reason}:{amount}(来自{referral_name})", "REFERRAL_REWARDS_HEADER": "🎁奖励如何运作:", "REFERRAL_REWARD_COMMISSION": "•每次推荐充值的佣金:{percent}%", -"REFERRAL_REWARD_COMMISSION_LIMITED": "•推荐充值佣金:{percent}%", +"REFERRAL_REWARD_COMMISSION_LIMITED": "•前{max_payments}次推荐充值的佣金:{percent}%", "REFERRAL_REWARD_INVITER": "•推荐首次充值时您将获得:{bonus}", "REFERRAL_REWARD_NEW_USER": "•新用户首次充值{minimum}起将获得:{bonus}", "REFERRAL_SHARE_BUTTON": "📤分享", diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index c76fdf34..e10b0210 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -420,6 +420,15 @@ class MonitoringService: expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days) sent_count = 0 + # Batch-запрос: собираем user_id с autopay и проверяем наличие карт одним запросом + users_with_cards: set[int] = set() + if settings.ENABLE_AUTOPAY and settings.YOOKASSA_RECURRENT_ENABLED: + autopay_user_ids = [s.user_id for s in expiring_subscriptions if s.autopay_enabled] + if autopay_user_ids: + from app.database.crud.saved_payment_method import get_user_ids_with_active_payment_methods + + users_with_cards = await get_user_ids_with_active_payment_methods(db, autopay_user_ids) + for subscription in expiring_subscriptions: user = await get_user_by_id(db, subscription.user_id) if not user: @@ -440,18 +449,7 @@ 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 + has_saved_card = subscription.autopay_enabled and user.id in users_with_cards should_send = True for other_days in warning_days: @@ -489,7 +487,9 @@ class MonitoringService: continue if self.bot: - success = await self._send_subscription_expiring_notification(user, subscription, days) + success = await self._send_subscription_expiring_notification( + user, subscription, days, has_saved_card=has_saved_card + ) if success: await record_notification(db, user.id, subscription.id, 'expiring', days) all_processed_users.add(user_key) @@ -979,7 +979,7 @@ class MonitoringService: # Берём ACTIVE + недавно EXPIRED (middleware или check_and_update могли # экспайрить до того, как monitoring успел запустить autopay) - recently_expired_threshold = current_time - timedelta(hours=48) + recently_expired_threshold = current_time - timedelta(hours=2) result = await db.execute( select(Subscription) .options( @@ -1283,7 +1283,9 @@ class MonitoringService: ) return False - async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool: + async def _send_subscription_expiring_notification( + self, user: User, subscription: Subscription, days: int, *, has_saved_card: bool = False + ) -> bool: try: from app.utils.formatters import format_days_declension @@ -1291,27 +1293,56 @@ class MonitoringService: days_text = format_days_declension(days, user.language) if settings.ENABLE_AUTOPAY: - if subscription.autopay_enabled: - autopay_status = '✅ Включен - подписка продлится автоматически' - action_text = ( - f'💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}' + if subscription.autopay_enabled and has_saved_card: + autopay_status = texts.t( + 'AUTOPAY_STATUS_CARD_ACTIVE', + '✅ Включен — будет автоматическое списание с карты', ) + action_text = texts.t( + 'AUTOPAY_ACTION_CHECK_BALANCE', + '💰 Убедитесь, что на балансе достаточно средств: {balance}', + ).format(balance=texts.format_price(user.balance_kopeks)) + elif subscription.autopay_enabled: + autopay_status = texts.t( + 'AUTOPAY_STATUS_NO_CARD', + '✅ Включен — подписка продлится автоматически', + ) + action_text = texts.t( + 'AUTOPAY_ACTION_CHECK_BALANCE', + '💰 Убедитесь, что на балансе достаточно средств: {balance}', + ).format(balance=texts.format_price(user.balance_kopeks)) else: - autopay_status = '❌ Отключен - не забудьте продлить вручную!' - action_text = '💡 Включите автоплатеж или продлите подписку вручную' + autopay_status = texts.t( + 'AUTOPAY_STATUS_OFF', + '❌ Отключен — не забудьте продлить вручную!', + ) + action_text = texts.t( + 'AUTOPAY_ACTION_ENABLE', + '💡 Включите автоплатеж или продлите подписку вручную', + ) else: - autopay_status = '❌ Отключен - не забудьте продлить вручную!' - action_text = '💡 Продлите подписку вручную' + autopay_status = texts.t( + 'AUTOPAY_STATUS_OFF', + '❌ Отключен — не забудьте продлить вручную!', + ) + action_text = texts.t( + 'AUTOPAY_ACTION_RENEW', + '💡 Продлите подписку вручную', + ) - message = f""" -⚠️ Подписка истекает через {days_text}! - -Ваша платная подписка истекает {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}. - -💳 Автоплатеж: {autopay_status} - -{action_text} -""" + end_date = format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M') + message = texts.t( + 'SUBSCRIPTION_EXPIRING_PAID', + '\n⚠️ Подписка истекает через {days_text}!\n\n' + 'Ваша платная подписка истекает {end_date}.\n\n' + '💳 Автоплатеж: {autopay_status}\n\n' + '{action_text}\n', + ).format( + days_text=days_text, + end_date=end_date, + autopay_status=autopay_status, + action_text=action_text, + ) from aiogram.types import InlineKeyboardMarkup diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 3f9cb384..39c3de87 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -1114,13 +1114,15 @@ class YooKassaPaymentMixin: get_payment_method_by_yookassa_id, ) - # Проверяем, не сохранён ли уже - existing = await get_payment_method_by_yookassa_id(db, pm_id) + # Проверяем, не сохранён ли уже (включая деактивированные — + # если пользователь удалил карту, не реактивируем её) + existing = await get_payment_method_by_yookassa_id(db, pm_id, include_inactive=True) if existing: logger.debug( 'Метод оплаты уже сохранён', yookassa_payment_method_id=pm_id, user_id=payment.user_id, + is_active=existing.is_active, ) return diff --git a/app/services/recurrent_payment_service.py b/app/services/recurrent_payment_service.py index e06580ca..06343989 100644 --- a/app/services/recurrent_payment_service.py +++ b/app/services/recurrent_payment_service.py @@ -64,6 +64,7 @@ async def process_recurrent_payments(bot: Bot | None = None) -> dict: 'checked': 0, 'payments_created': 0, 'insufficient_no_card': 0, + 'all_cards_failed': 0, 'already_processed': 0, 'errors': 0, } @@ -91,6 +92,8 @@ async def process_recurrent_payments(bot: Bot | None = None) -> dict: _processed_today.add(guard_key) elif result == 'no_card': stats['insufficient_no_card'] += 1 + elif result == 'all_cards_failed': + stats['all_cards_failed'] += 1 elif result == 'skipped': stats['already_processed'] += 1 except Exception as e: @@ -234,13 +237,17 @@ async def _process_single_subscription( } # Перебираем все сохранённые карты пока не найдём рабочую + today = datetime.now(UTC).strftime('%Y-%m-%d') for saved_method in saved_methods: + # Детерминированный ключ: при рестарте/повторе YooKassa вернёт тот же платёж + idem_key = f'recurrent_{subscription.id}_{saved_method.id}_{today}' 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, + idempotence_key=idem_key, ) if not result: @@ -254,14 +261,28 @@ async def _process_single_subscription( ) continue - # Успешно — создаём локальную запись платежа + # Успешно — сохраняем локальную запись с привязкой к YooKassa ID try: - result_payment = await payment_service.create_yookassa_payment( + from app.database.crud.yookassa import create_yookassa_payment + + yookassa_created_at = None + if result.get('created_at'): + try: + yookassa_created_at = datetime.fromisoformat(result['created_at'].replace('Z', '+00:00')) + except Exception: + pass + + result_payment = await create_yookassa_payment( db=db, user_id=user.id, + yookassa_payment_id=result['id'], amount_kopeks=topup_amount_kopeks, + currency='RUB', description=description, - metadata=metadata, + status=result.get('status', 'pending'), + metadata_json=metadata, + yookassa_created_at=yookassa_created_at, + test_mode=result.get('test_mode', False), ) if result_payment: logger.info( @@ -269,7 +290,7 @@ async def _process_single_subscription( user_id=user.id, subscription_id=subscription.id, amount_kopeks=topup_amount_kopeks, - yookassa_payment_id=result.get('id'), + yookassa_payment_id=result['id'], ) except Exception as e: logger.warning('Ошибка создания локальной записи рекуррентного платежа', error=e) @@ -342,4 +363,4 @@ async def _process_single_subscription( except Exception as notify_error: logger.warning('Ошибка уведомления о неудачном автоплатеже', notify_error=notify_error) - return 'skipped' + return 'all_cards_failed' diff --git a/app/services/referral_service.py b/app/services/referral_service.py index 0affc299..7b816658 100644 --- a/app/services/referral_service.py +++ b/app/services/referral_service.py @@ -16,6 +16,23 @@ from app.utils.user_utils import get_effective_referral_commission_percent logger = structlog.get_logger(__name__) +async def _is_commission_limit_reached(db: AsyncSession, referrer_id: int, referral_id: int) -> bool: + """Проверяет, исчерпан ли лимит комиссионных платежей для пары реферер-реферал.""" + if settings.REFERRAL_MAX_COMMISSION_PAYMENTS <= 0: + return False + paid_count = await get_commission_payment_count(db, referrer_id, referral_id) + if paid_count >= settings.REFERRAL_MAX_COMMISSION_PAYMENTS: + logger.info( + 'Лимит комиссионных платежей исчерпан', + referrer_id=referrer_id, + referral_id=referral_id, + paid_count=paid_count, + max_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS, + ) + return True + return False + + async def send_referral_notification( bot: Bot, telegram_id: int | None, @@ -178,17 +195,8 @@ 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 and await _is_commission_limit_reached(db, referrer.id, user.id): + return True if commission_amount > 0: balance_ok = await add_user_balance( @@ -346,17 +354,8 @@ 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 + if await _is_commission_limit_reached(db, referrer.id, user.id): + return True balance_ok = await add_user_balance( db, diff --git a/app/services/yookassa_service.py b/app/services/yookassa_service.py index ef1ef1ee..3b47f63f 100644 --- a/app/services/yookassa_service.py +++ b/app/services/yookassa_service.py @@ -1,5 +1,6 @@ import asyncio import uuid +from datetime import UTC, datetime from typing import Any import structlog @@ -357,6 +358,7 @@ class YooKassaService: metadata: dict[str, Any], receipt_email: str | None = None, receipt_phone: str | None = None, + idempotence_key: str | None = None, ) -> dict[str, Any] | None: """Создаёт рекуррентный автоплатёж через сохранённый payment_method_id (без confirmation).""" @@ -398,11 +400,13 @@ class YooKassaService: 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()) + if not idempotence_key: + sub_id = metadata.get('subscription_id', uuid.uuid4()) + idempotence_key = f'autopay_{sub_id}_{datetime.now(UTC).strftime("%Y-%m-%d")}' payment_request = builder.build() logger.info( - 'Создание автоплатежа YooKassa. Сумма: . payment_method_id: . Метаданные: ', + 'Создание автоплатежа YooKassa', amount=amount, currency=currency, payment_method_id=payment_method_id, @@ -416,7 +420,7 @@ class YooKassaService: ) logger.info( - 'Ответ YooKassa автоплатёж: ID=, Status=, Paid', + 'Ответ YooKassa автоплатёж', response_id=response.id, status=response.status, paid=response.paid, diff --git a/migrations/alembic/versions/0032_add_saved_payment_methods.py b/migrations/alembic/versions/0032_add_saved_payment_methods.py index 52eb6fa7..0b314475 100644 --- a/migrations/alembic/versions/0032_add_saved_payment_methods.py +++ b/migrations/alembic/versions/0032_add_saved_payment_methods.py @@ -36,9 +36,9 @@ def upgrade() -> None: 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()), + sa.Column('is_active', sa.Boolean(), server_default=sa.true_(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), )