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()),
)