@@ -294,10 +294,14 @@ CELERY_BEAT_SCHEDULE = { 'task': 'payments.tasks.send_low_balance_message', 'schedule': crontab(0, 8), }, - # 'execute_recurring_payments': { - # 'task': 'payments.tasks.execute_recurring_payments', - # 'schedule': crontab(*env.list('RECURRING_PAYMENT_CRONTAB_SCHEDULE', [])), - # }, + 'execute_recurring_payments': { + 'task': 'payments.tasks.execute_recurring_payments', + 'schedule': crontab(*env.list('RECURRING_PAYMENT_CRONTAB_SCHEDULE', [])), + }, + 'revoke_recurring_payments': { + 'task': 'payments.tasks.revoke_recurring_payments', + 'schedule': crontab(*env.list('RECURRING_PAYMENT_CRONTAB_SCHEDULE', [])), + }, } CACHES = { @@ -1,6 +1,7 @@ from types import MappingProxyType -from typing import List, Mapping +from typing import List, Mapping, Never +import orjson from UnleashClient import UnleashClient from django.conf import settings @@ -16,7 +17,7 @@ class UnleashFeatureFlagService(FeatureFlagService): app_name=settings.FEATURE_FLAG_APP_NAME, instance_id=settings.FEATURE_FLAG_INSTANCE_ID, cache=UnleashRedisCache(), - environment=settings.FEATURE_FLAG_APP_NAME + environment=settings.FEATURE_FLAG_APP_NAME, ) def get_flag_state_by_emails(self, name: str, emails: List[Email]) -> Mapping[Email, State]: @@ -24,3 +25,18 @@ class UnleashFeatureFlagService(FeatureFlagService): def get_flag_state(self, name: str, email: Email) -> State: return self.client.is_enabled(feature_name=name, context={'userId': email}) + + def is_feature_enabled(self, name: str) -> bool: + for feature in orjson.loads(self.client.engine.get_state())['features']: + if feature['name'] == name: + return bool(feature.get('enabled')) + raise ValueError(f'Feature flag "{name}" not found in Unleash state') + + def get_user_emails(self, name: str) -> List[Email] | List[Never]: + for feature in orjson.loads(self.client.engine.get_state())['features']: + if feature['name'] == name: + userIds = feature['strategies'][0]['parameters'].get('userIds') + if userIds: + return userIds.replace(' ', '').split(',') + return [] + raise ValueError(f'Feature flag "{name}" not found in Unleash state') @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-14 22:35+0300\n" +"POT-Creation-Date: 2026-05-24 01:30+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -207,7 +207,7 @@ msgstr "Бизнес Группы" #: authentication/models/user.py:224 authentication/models/user_telegram.py:22 #: authentication/models/user_vk.py:12 payments/admin.py:37 #: payments/admin.py:95 payments/models/invoice.py:15 -#: payments/models/payment.py:26 payments/models/payment_plan.py:50 +#: payments/models/payment.py:26 payments/models/payment_plan.py:43 #: tools/media/models.py:108 msgid "User" msgstr "Пользователь" @@ -666,7 +666,9 @@ msgstr "" #: ml_model/exceptions.py:47 #, python-format msgid "Image exceeds the maximum allowed pixel count (%(max_pixels)d)." -msgstr "Размер изображения превышает максимально допустимое количество пикселей (%(max_pixels)d)." +msgstr "" +"Размер изображения превышает максимально допустимое количество пикселей " +"(%(max_pixels)d)." #: ml_model/exceptions.py:53 msgid "The model is not responding" @@ -749,21 +751,16 @@ msgstr "" "Сервис временно недоступен из-за высокой нагрузки. Пожалуйста, попробуйте " "позже" -#: ml_model/exceptions.py:166 -msgid "Available only in paid plan" -msgstr "Доступно только в платном тарифе" - -#: ml_model/exceptions.py:171 -msgid "The \"Use code\" option cannot be used together with an attached image." -msgstr "" -"Нельзя одновременно использовать параметр «Использовать код» вместе с " -"прикреплённым изображением." +#: ml_model/exceptions.py:169 +#, python-format +msgid "%(feature)s is available only in paid plan." +msgstr "%(feature)s доступно только в платном тарифном плане." -#: ml_model/exceptions.py:177 +#: ml_model/exceptions.py:176 msgid "Face not found in the image. Please try another image with a face." msgstr "Не найдено лицо на картинке. Попробуйте другую картинку с лицом." -#: ml_model/exceptions.py:168 +#: ml_model/exceptions.py:181 msgid "The input image may contain real person." msgstr "Загруженное изображение может содержать реального человека." @@ -1065,14 +1062,25 @@ msgstr "Инструкции Моделей" msgid "no model by this id" msgstr "Не найдено моделей по этому ID" -#: ml_model/services/chatgpt.py:154 +#: ml_model/services/chatgpt.py:149 msgid "No matching version found" msgstr "Соответствующая версия не найдена" -#: ml_model/services/chatgpt_5_4.py:270 +#: ml_model/services/chatgpt_5.py:130 ml_model/services/chatgpt_5_4.py:156 +#: ml_model/services/chatgpt_5_5.py:210 +msgid "The \"Use code\" option cannot be used together with an attached image." +msgstr "" +"Нельзя одновременно использовать параметр «Использовать код» вместе с " +"прикреплённым изображением." + +#: ml_model/services/chatgpt_5_4.py:315 ml_model/services/chatgpt_5_5.py:362 msgid "Image is ready" msgstr "Изображение готово" +#: ml_model/services/chatgpt_5_5.py:188 +msgid "File analysis" +msgstr "Анализ файлов" + #: ml_model/services/elevenlabs_music.py:45 msgid "Duration cannot be less than 5 seconds" msgstr "Длительность не может быть меньше 5 секунд" @@ -1100,7 +1108,7 @@ msgstr "Неизвестный бакет для загрузки" msgid "1080p output is not supported for Seedance Dreamina 2.0 Fast." msgstr "1080р разрешение не поддерживается для Seedance Dreamina 2.0 Fast." -#: ml_model/services/seedream.py:78 +#: ml_model/services/seedream.py:87 msgid "3K output is not supported for Seedream 4.5" msgstr "3К разрешение не поддерживается для Seedream 4.5" @@ -1108,7 +1116,7 @@ msgstr "3К разрешение не поддерживается для Seedre msgid "No image given for improving" msgstr "Нет изображения для улучшения" -#: ml_model/tasks.py:188 +#: ml_model/tasks.py:137 msgid "Lyrics is too long" msgstr "Текст песни слишком длинный" @@ -1126,7 +1134,7 @@ msgstr "" msgid "Missing" msgstr "Отсутствующий" -#: payments/apps.py:12 payments/models/payment.py:60 +#: payments/apps.py:11 payments/models/payment.py:60 msgid "Payments" msgstr "Платежи" @@ -1195,36 +1203,36 @@ msgstr "Индивидуальный" msgid "Is visible" msgstr "Видимый" -#: payments/models/payment_plan.py:41 payments/models/payment_plan.py:56 +#: payments/models/payment_plan.py:34 payments/models/payment_plan.py:49 #: payments/models/payment_plan_feature.py:16 msgid "Payment Plan" msgstr "Платежный План" -#: payments/models/payment_plan.py:42 +#: payments/models/payment_plan.py:35 msgid "Payment Plans" msgstr "Платежные Планы" -#: payments/models/payment_plan.py:58 +#: payments/models/payment_plan.py:51 msgid "Last payment at" msgstr "Последнее время платежа" -#: payments/models/payment_plan.py:59 +#: payments/models/payment_plan.py:52 msgid "Next payment at" msgstr "Следующее время платежа" -#: payments/models/payment_plan.py:63 payments/models/user_payment_method.py:25 +#: payments/models/payment_plan.py:56 payments/models/user_payment_method.py:25 msgid "Payment Method" msgstr "Платежный метод" -#: payments/models/payment_plan.py:69 +#: payments/models/payment_plan.py:62 msgid "Current balance" msgstr "Текущий баланс" -#: payments/models/payment_plan.py:75 +#: payments/models/payment_plan.py:68 msgid "Referral balance" msgstr "Реферальный баланс" -#: payments/models/payment_plan.py:96 payments/models/payment_plan.py:97 +#: payments/models/payment_plan.py:89 payments/models/payment_plan.py:90 msgid "User Balance" msgstr "Баланс пользователя" @@ -1364,13 +1372,13 @@ msgstr "" msgid "Unknown account type" msgstr "Неизвестный тип аккаунта" -#: payments/tests/test_plans.py:23 payments/tests/test_plans.py:191 -#: payments/tests/test_plans.py:194 +#: payments/tests/test_plans.py:23 payments/tests/test_plans.py:190 +#: payments/tests/test_plans.py:193 msgid "Chat-bots" msgstr "Чат-боты" -#: payments/tests/test_plans.py:27 payments/tests/test_plans.py:192 -#: payments/tests/test_plans.py:197 +#: payments/tests/test_plans.py:27 payments/tests/test_plans.py:191 +#: payments/tests/test_plans.py:196 msgid "Images" msgstr "Изображения" @@ -1410,7 +1418,7 @@ msgstr "Публичный API" msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:201 tools/media/apis.py:209 +#: tools/chats/apis.py:203 tools/media/apis.py:209 #: tools/public_api/views/base.py:100 msgid "" "An unexpected generation error has occurred. Please try again later or use a " @@ -1419,7 +1427,7 @@ msgstr "" "Произошла непредвиденная ошибка при генерации. Пожалуйста попробуйте позже " "или используйте другую модель" -#: tools/chats/apis.py:257 +#: tools/chats/apis.py:259 msgid "The message has already been deleted" msgstr "Сообщение уже было удалено" @@ -1566,6 +1574,9 @@ msgstr "Название голоса успешно обновлено" msgid "Preset voices are shared and cannot be deleted. Use your own voice id." msgstr "Пресеты общие для всех — их нельзя удалить. Укажите id своего голоса." +#~ msgid "Available only in paid plan" +#~ msgstr "Доступно только в платном тарифе" + #~ msgid "Card type" #~ msgstr "Тип карты" @@ -122,7 +122,7 @@ class Chatgpt_5_4(Chatgpt): is_regular_user = self.store.user.account_type == 'regular' is_free_plan = is_regular_user and plan_info and plan_info.plan.price <= 0 if is_free_plan and model_name == 'gpt-5.4-pro': - raise PaidPlanRequiredError() + raise PaidPlanRequiredError('ChatGPT 5.4 PRO') if is_free_plan: info.pop('web_search', None) info.pop('code_interpreter', None) @@ -16,7 +16,7 @@ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, System from messages.models import Message -from ml_model.exceptions import CorruptedFileError, FileExtensionNotSupported, InvalidParameterError +from ml_model.exceptions import CorruptedFileError, FileExtensionNotSupported, InvalidParameterError, PaidPlanRequiredError from ml_model.services import Chatgpt from ml_model.services.EmbeddingService import EmbeddingService from ml_model.services.FileService import FileProcessingService @@ -185,11 +185,7 @@ class Chatgpt_5_5(Chatgpt): file_extension = file_service.get_file_extension(raw_file_extension, file_bytes) if file_extension in ('pdf', 'doc', 'docx', 'xlsx'): if is_free_plan: - return self.save_results( - results=[AIMessage('**Анализ файла пропускается в бесплатной версии!**')], - elapsed_time=timedelta(minutes=0, seconds=0), - generated_image=None, - ) + raise PaidPlanRequiredError(gettext('File analysis')) text = file_service.get_file_data(file_extension, file_bytes) text_chunks = EmbeddingService.split_text_to_chunks(text) chunks = [HumanMessage(content=chunk_text) for chunk_text in text_chunks] @@ -162,8 +162,13 @@ class ServiceHighDemandError(Exception): class PaidPlanRequiredError(Exception): + def __init__(self, feature: str) -> None: + self.feature = feature + def __str__(self) -> str: - return _('Available only in paid plan') + return _('%(feature)s is available only in paid plan.') % { + 'feature': self.feature, + } class FaceNotFoundError(Exception): @@ -1,3 +1,4 @@ +import hashlib import logging from datetime import timedelta @@ -102,7 +103,8 @@ class PaymentService: return await sync_to_async(YookassaPayment.find_one)(payment_id) def handle_captured_payment(self, payment_id: UUID) -> None: - YookassaPayment.capture(str(payment_id)) + idempotency_key = hashlib.sha256(f'capture:{payment_id}'.encode('utf-8')).hexdigest() + YookassaPayment.capture(str(payment_id), idempotency_key=idempotency_key) logger.info('Payment captured: payment_id=%s email=%s', payment_id, self.user.email) def calculate_buying_tokens(self, plan: PaymentPlan): @@ -1,8 +1,13 @@ +import hashlib +from datetime import timezone as dt_timezone from decimal import Decimal -from uuid import UUID, uuid4 +from itertools import islice +from uuid import UUID from celery import shared_task from celery.utils.log import get_task_logger +from django.core.cache import cache +from django.db import transaction from django.db.models import F from django.utils import timezone @@ -10,14 +15,15 @@ from authentication.models.business_host import BusinessUserHost from authentication.models.user import CustomUserModel from authentication.services.email_service import EmailService from lib.unleash.client import celery_client -from payments.models import PaymentPlanUserInfo, PaymentMethod -from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.models import PaymentMethod, PaymentPlan, PaymentPlanUserInfo from payments.services.payment_plan_service import PaymentPlanService - from yookassa import Payment as YookassaPayment logger = get_task_logger(__name__) +CHUNK_SIZE = 1000 +LOCK_TIMEOUT_SECONDS = 10 * 60 + @shared_task def send_low_balance_message(): @@ -38,39 +44,42 @@ def withdraw(user_id: UUID, amount: Decimal): @shared_task def execute_recurring_payments() -> None: - overdue_payments = PaymentPlanUserInfo.objects.select_related('user', 'plan', 'method').filter( - next_payment_at__isnull=False, - next_payment_at__lte=timezone.now(), - plan__price__gt=0, - plan__individual=False, + if not celery_client.is_feature_enabled('recurring_payments'): + logger.info('Recurring payments feature disabled, skipping execute') + return + + emails = celery_client.get_user_emails('recurring_payments') + overdue_payments = ( + PaymentPlanUserInfo.objects.select_related('user', 'plan', 'method') + .filter( + next_payment_at__isnull=False, + next_payment_at__lte=timezone.now(), + plan__price__gt=0, + plan__individual=False, + plan__is_corporate=False, + method__isnull=False, + ) + .only( + 'uid', + 'next_payment_at', + 'user_id', + 'plan_id', + 'method_id', + 'user__uid', + 'user__email', + 'plan__uid', + 'plan__price', + 'plan__tokens_per_plan', + 'method__uid', + 'method__payment_method_id', + 'method__attempts', + ) ) - canceled_recurring_payments = [] - logger.info('Recurring payments task started: overdue_count=%s', overdue_payments.count()) - for overdue_payment in overdue_payments: + if emails: + overdue_payments = overdue_payments.filter(user__email__in=emails) + for overdue_payment in overdue_payments.iterator(chunk_size=CHUNK_SIZE): customer = overdue_payment.user plan = overdue_payment.plan - if not celery_client.get_flag_state('recurring_payments', overdue_payment.user.email): - overdue_payment.next_payment_at = None - canceled_recurring_payments.append(overdue_payment) - logger.info( - 'Recurring payment canceled by feature flag: email=%s plan_uid=%s', - customer.email, - plan.uid, - ) - continue - if not overdue_payment.is_recurring: - free_plan = PaymentPlanSelector(customer).get_free_plan(plan.is_corporate) - overdue_payment.next_payment_at = None - overdue_payment.plan = free_plan - overdue_payment.current_token_balance = 0 - canceled_recurring_payments.append(overdue_payment) - logger.info( - 'Recurring payment canceled due to missing method: email=%s plan_uid=%s switched_to_free_plan_uid=%s', - customer.email, - plan.uid, - free_plan.uid, - ) - continue receipt_data = { 'customer': {'email': customer.email}, 'items': [ @@ -93,7 +102,14 @@ def execute_recurring_payments() -> None: 'plan_uid': str(plan.uid), }, } - YookassaPayment.create(payment_data, uuid4()) + dt = overdue_payment.next_payment_at + if timezone.is_naive(dt): + dt = timezone.make_aware(dt) + period = dt.astimezone(dt_timezone.utc).replace(microsecond=0).isoformat() + idempotency_key = hashlib.sha256( + f'recurring:{customer.uid}:{plan.uid}:{period}:{overdue_payment.method.attempts}'.encode('utf-8') + ).hexdigest() + YookassaPayment.create(payment_data, idempotency_key=idempotency_key) logger.info( 'Recurring payment initiated: email=%s plan_uid=%s amount=%s method_uid=%s', customer.email, @@ -101,13 +117,63 @@ def execute_recurring_payments() -> None: plan.price, overdue_payment.method.uid, ) - methods_for_delete = [crp.method.uid for crp in canceled_recurring_payments if crp.method] - PaymentPlanUserInfo.objects.bulk_update( - canceled_recurring_payments, fields=['next_payment_at', 'plan', 'current_token_balance'] - ) - deleted_methods_count, deleted_details = PaymentMethod.objects.filter(uid__in=methods_for_delete).delete() - logger.info( - 'Recurring payments task finished: canceled_count=%s deleted_methods=%s', - len(canceled_recurring_payments), - deleted_methods_count, - ) \ No newline at end of file + + +@shared_task +def revoke_recurring_payments() -> None: + lock_key = 'recurring_payments:revoke_lock' + if not cache.add(lock_key, 'locked', timeout=LOCK_TIMEOUT_SECONDS): + logger.info('Revoke recurring already running, skipping') + return + try: + feature_name = 'recurring_payments' + base_qs = PaymentPlanUserInfo.objects.filter( + next_payment_at__isnull=False, + next_payment_at__lte=timezone.now(), + plan__price__gt=0, + plan__individual=False, + plan__is_corporate=False, + ) + revoked_count = 0 + canceled_count = 0 + deleted_methods_count = 0 + + if not celery_client.is_feature_enabled(feature_name): + flag_off_qs = base_qs + flag_on_qs = base_qs.none() + else: + allowed_emails = celery_client.get_user_emails(feature_name) + if allowed_emails: + flag_off_qs = base_qs.exclude(user__email__in=allowed_emails) + flag_on_qs = base_qs.filter(user__email__in=allowed_emails, method__isnull=True) + else: + flag_off_qs = base_qs.none() + flag_on_qs = base_qs.filter(method__isnull=True) + + uid_iter = flag_off_qs.values_list('uid', flat=True).iterator(chunk_size=CHUNK_SIZE) + while uids := list(islice(uid_iter, CHUNK_SIZE)): + with transaction.atomic(): + deleted_methods_count += PaymentMethod.objects.filter( + user_plan_info__uid__in=uids + ).delete()[0] + revoked_count += flag_off_qs.filter(uid__in=uids).update(next_payment_at=None) + + if flag_on_qs.exists(): + free_regular_plan = PaymentPlan.objects.get_or_create(price=0, is_corporate=False)[0] + flag_on_iter = flag_on_qs.values_list('uid', flat=True).iterator(chunk_size=CHUNK_SIZE) + while uids := list(islice(flag_on_iter, CHUNK_SIZE)): + with transaction.atomic(): + canceled_count += flag_on_qs.filter(uid__in=uids).update( + next_payment_at=None, + plan_id=free_regular_plan.pk, + current_token_balance=0, + ) + + logger.info( + 'Revoke recurring finished: revoked=%s free_regular=%s deleted_methods=%s', + revoked_count, + canceled_count, + deleted_methods_count, + ) + finally: + cache.delete(lock_key)