@@ -151,7 +151,8 @@ class CustomUserModel(AbstractBaseUser, PermissionsMixin, BaseModel): @property def balance(self): - return self.payment_plan.current_token_balance + pp = self.payment_plan + return pp.current_token_balance + pp.referral_balance @property def account_type(self): @@ -230,7 +231,7 @@ def on_user_creation_signal(sender, instance, created, **kwargs): if created: free_plan = PaymentPlanSelector(instance).get_free_plan() - PaymentPlanService(instance).subscribe_user_to_plan(free_plan) + PaymentPlanService(instance).subscribe_user_to_plan(free_plan, free_plan.tokens_per_plan) if not instance.profile_picture_name: instance.profile_picture_name = instance.LAST_NAME_AVATARS.get(instance.last_name, None) instance.save() @@ -64,8 +64,9 @@ class BusinessHostService: ) -> BusinessAccountService: password = generate_token(15) user = CustomUserModel.objects.create_user(email=email, password=password) + plan = PaymentPlan.objects.get(price=0, is_corporate=False) - PaymentPlanService(user).subscribe_user_to_plan(PaymentPlan.objects.get(price=0, is_corporate=False)) + PaymentPlanService(user).subscribe_user_to_plan(plan, plan.tokens_per_plan) account_service = BusinessAccountService.create(user, host, account_privileges=account_privileges) EmailService.send_corporate_greeting_email(account_service.account, password) @@ -174,9 +175,6 @@ class BusinessHostService: serializer = NewBusinessHostSerializer(data=request.data) serializer.is_valid(raise_exception=True) - if PaymentPlanSelector(self.user).is_plan_paid(): - raise business_host_exceptions.AlreadyHasPlan() - if AccountStatusSelector(self.user).is_business_host(): raise business_host_exceptions.AlreadyHost() @@ -184,10 +182,6 @@ class BusinessHostService: raise business_host_exceptions.AlreadyAccount() host = BusinessUserHost.objects.create(user=self.user, **serializer.validated_data) - host.save() - PaymentPlanService(self.user).subscribe_user_to_plan( - PaymentPlan.objects.get(price=0, is_corporate=True) - ) EmailService(self.user).send_copr_purchase_email(host) account_type = UserSelector(self.user).check_account_type() @@ -1,4 +1,6 @@ import logging +from datetime import datetime +from decimal import Decimal from typing import Any, Sequence import dns.resolver @@ -162,3 +164,18 @@ class EmailService: cls.send_email( f'AIR: баланс корпоративного аккаунта ниже {host.token_cap}', html_message, host.token_cap_emails ) + + @classmethod + def send_payment_email( + cls, product_title: str, next_payment_at: datetime | str, amount: Decimal, payer_email: str, status: str + ) -> None: + templates = { + 'succeeded': 'payments/success_payment_notification', + 'canceled': 'payments/canceled_payment_notification', + } + html_message = cls._render_letter_template( + template_name=templates[status], + context={'product_title': product_title, 'next_payment_at': next_payment_at, 'amount': amount}, + ) + cls.send_email('Детали платежа', html_message, (payer_email,)) + @@ -42,17 +42,15 @@ PATH_PREFETCH_MAP = { 'account_privileges', 'parent_company__user__uid', ), - *_gen_only('payment_plan', 'uid', 'last_payment_at', 'next_payment_at'), - *_gen_only('payment_plan__plan', 'uid', 'price', 'tokens_per_plan', 'duration'), + *_gen_only('payment_plan', 'uid', 'last_payment_at'), + *_gen_only('payment_plan__plan', 'uid', 'price', 'tokens_per_plan'), *_gen_only( 'business_account__parent_company__user__payment_plan', 'uid', 'last_payment_at', - 'next_payment_at', 'plan__uid', 'plan__price', 'plan__tokens_per_plan', - 'plan__duration', ), ), }, @@ -81,7 +79,23 @@ PATH_PREFETCH_MAP = { 'host_account', 'payment_plan', 'business_account__parent_company__user__payment_plan__plan', - 'payment_plan__plan' + 'payment_plan__plan', + 'payment_plan__method', + ), + 'prefetch': ( + Prefetch( + 'business_account__parent_company__user__payment_plan__plan', + queryset=NeuronModel.objects.only('slug'), + ), + ), + }, + '/api/v1/v2/payments/plans': { + 'select': ( + 'host_account', + 'payment_plan', + 'business_account__parent_company__user__payment_plan__plan', + 'payment_plan__plan', + 'payment_plan__method', ), 'prefetch': ( Prefetch( @@ -1,8 +1,16 @@ import os from celery import Celery +from celery.signals import worker_process_init os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings') app = Celery('backend') app.config_from_object('django.conf:settings', namespace='CELERY') app.autodiscover_tasks() + + +from lib.unleash.client import celery_client + +@worker_process_init.connect +def configure_workers(sender=None, conf=None, **kwargs): + celery_client.client.initialize_client(fetch_toggles=False) @@ -294,6 +294,10 @@ 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', [])), + }, } CACHES = { @@ -347,7 +351,6 @@ ImageFile.LOAD_TRUNCATED_IMAGES = True YOOKASSA_ACCOUNT_ID = env.str('YOOKASSA_ACCOUNT_ID', default='defaultapikey') YOOKASSA_SECRET_KEY = env.str('YOOKASSA_SECRET_KEY', default='defaultapikey') YOOKASSA_RESULT_PAYMENT_URL = env.str('YOOKASSA_RESULT_PAYMENT_URL', default='defaultapikey') -RECURRENT_RATE = env.str('RECURRENT_RATE', 'days') # Email EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_USE_TLS = env.bool('EMAIL_USE_TLS', default=False) @@ -495,3 +498,12 @@ if CACHEOPS_REDIS: 'reports.*': {'ops': 'all', 'timeout': 60 * 60}, 'token_blacklist.outstandingtoken': {'ops': 'get', 'timeout': 60 * 60 * 24}, } + +# UNLEASH settings +FEATURE_FLAG_API_URL = env.str('FEATURE_FLAG_API_URL') +FEATURE_FLAG_APP_NAME = env.str('FEATURE_FLAG_APP_NAME', 'staging') +FEATURE_FLAG_INSTANCE_ID = env.str('FEATURE_FLAG_INSTANCE_ID') +FEATURE_FLAG_WEBHOOK_SECRET_KEY = env.str('FEATURE_FLAG_WEBHOOK_SECRET_KEY', 'FEATURE_FLAG_WEBHOOK_SECRET_KEY') + +# RECURRING SETTINGS +MAX_RECURRING_ATTEMPTS = env.int('MAX_RECURRING_ATTEMPTS', 1) @@ -18,15 +18,20 @@ from backend.public import urlpatterns as public_urlpatterns api = NinjaAPI(title='AIR API', version='1.0.0', docs_url=None) compatibility_api = NinjaAPI(title='AIR API DEBUG', version='0.0.1', docs_url=None) +compatibility_api_v2 = NinjaAPI(title='AIR API DEBUG', version='0.0.2', docs_url=None) + api.add_router('copywrite/', 'tools.copywrite.routes.v1.router') api.add_router('users/', 'users.routes.v1.router') api.add_router('chats/', 'tools.chats.routes.v1.router') api.add_router('media/', 'tools.media.routes.v1.router') + compatibility_api.add_router('auth/', 'authentication.routes.v1.router') compatibility_api.add_router('payments/', 'payments.routes.v1.router') compatibility_api.add_router('reports/', 'reports.routes.v1.router') compatibility_api.add_router('ml_model/', 'ml_model.routes.v1.router') +compatibility_api_v2.add_router('payments/', 'payments.routes.v2.router') + logger = logging.getLogger(__name__) @@ -62,6 +67,7 @@ urlpatterns += [ urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) api.docs_url = '/docs' compatibility_api.docs_url = '/docs' +compatibility_api_v2.docs_url = '/docs' urlpatterns += [ path('api/v1/healthz/', healthz_status), @@ -84,6 +90,7 @@ urlpatterns += [ ), path('api/v1/api/', api.urls), path('api/v1/', compatibility_api.urls), + path('api/v1/v2/', compatibility_api_v2.urls), ] urlpatterns += public_urlpatterns @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod +from typing import List, Mapping + +from lib.typing import Email, State + + +class FeatureFlagService(ABC): + + @abstractmethod + def get_flag_state_by_emails(self, name: str, emails: List[Email]) -> Mapping[Email, State]: + pass + + @abstractmethod + def get_flag_state(self, name: str, email: Email) -> State: + pass @@ -0,0 +1,26 @@ +from types import MappingProxyType +from typing import List, Mapping + +from UnleashClient import UnleashClient +from django.conf import settings + +from lib.services.feature_flag import FeatureFlagService +from lib.typing import Email, State +from lib.unleash.cache import UnleashRedisCache + + +class UnleashFeatureFlagService(FeatureFlagService): + def __init__(self) -> None: + self.client = UnleashClient( + url=settings.FEATURE_FLAG_API_URL, + app_name=settings.FEATURE_FLAG_APP_NAME, + instance_id=settings.FEATURE_FLAG_INSTANCE_ID, + cache=UnleashRedisCache(), + environment=settings.FEATURE_FLAG_APP_NAME + ) + + def get_flag_state_by_emails(self, name: str, emails: List[Email]) -> Mapping[Email, State]: + return MappingProxyType({email: self.get_flag_state(name, email) for email in emails}) + + def get_flag_state(self, name: str, email: Email) -> State: + return self.client.is_enabled(feature_name=name, context={'userId': email}) @@ -0,0 +1,27 @@ +from typing import Any, Optional +from UnleashClient.cache import BaseCache +from django.core.cache import caches + + +class UnleashRedisCache(BaseCache): + PREFIX = 'unleash:' + + def __init__(self, cache_alias='default'): + self.cache = caches[cache_alias] + + def set(self, key: str, value: Any): + self.cache.set(self.PREFIX + key, value) + + def mset(self, data: dict): + self.cache.set_many({self.PREFIX + k: v for k, v in data.items()}) + + def get(self, key: str, default: Optional[Any] = None): + return self.cache.get(self.PREFIX + key, default) + + def exists(self, key: str): + return self.cache.has_key(self.PREFIX + key) + + def destroy(self): + client = self.cache.client.get_client(write=True) + for key in client.scan_iter(f"{self.PREFIX}*"): + client.delete(key) @@ -0,0 +1,4 @@ +from lib.services.unleash_feature_flag import UnleashFeatureFlagService + +web_client = UnleashFeatureFlagService() +celery_client = UnleashFeatureFlagService() \ No newline at end of file @@ -0,0 +1,2 @@ +type Email = str +type State = bool \ No newline at end of file @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-03 17:52+0300\n" +"POT-Creation-Date: 2026-04-09 17:28+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -190,7 +190,7 @@ msgstr "Дочерние Бизнес Аккаунты" #: authentication/models/business_group.py:8 ml_model/models.py:17 #: ml_model/models.py:37 ml_model/models.py:61 ml_model/models.py:268 -#: tools/chats/models.py:9 tools/media/models.py:59 tools/media/models.py:93 +#: tools/chats/models.py:9 tools/media/models.py:59 tools/media/models.py:95 msgid "Title" msgstr "Название" @@ -206,9 +206,9 @@ msgstr "Бизнес Группы" #: authentication/models/email_token.py:13 authentication/models/user.py:222 #: authentication/models/user.py:223 authentication/models/user_telegram.py:22 #: authentication/models/user_vk.py:12 payments/admin.py:37 -#: payments/admin.py:97 payments/models/invoice.py:15 -#: payments/models/payment.py:26 payments/models/payment_plan.py:62 -#: tools/media/models.py:106 +#: payments/admin.py:95 payments/models/invoice.py:15 +#: payments/models/payment.py:26 payments/models/payment_plan.py:50 +#: tools/media/models.py:108 msgid "User" msgstr "Пользователь" @@ -553,7 +553,7 @@ msgstr "Бизнес-аккаунт для данного юзера не най msgid "Invited account can either accept or reject an invitation" msgstr "Приглашенный аккаунт может принять или отклонить приглашение" -#: authentication/services/email_service.py:50 +#: authentication/services/email_service.py:52 msgid "Error occured when proceed email sending" msgstr "Случилась ошибка во время отправки email" @@ -787,7 +787,7 @@ msgstr "Теги" msgid "Neuron Model" msgstr "Нейронная Модель" -#: ml_model/models.py:156 ml_model/models.py:402 payments/admin.py:103 +#: ml_model/models.py:156 ml_model/models.py:402 payments/admin.py:101 msgid "Model" msgstr "Модель" @@ -1042,20 +1042,24 @@ msgstr "Нет изображения для улучшения" msgid "Model data cannot be retrieved" msgstr "Невозможно получить данные модели" -#: payments/admin.py:35 payments/admin.py:69 payments/admin.py:95 +#: payments/admin.py:35 payments/admin.py:67 payments/admin.py:93 msgid "You can search by user email, exacted company name" msgstr "" "Вы можете осуществлять поиск по e-mail пользователя, точному названию " "компании" -#: payments/admin.py:40 payments/admin.py:100 +#: payments/admin.py:40 payments/admin.py:98 msgid "Missing" msgstr "Отсутствующий" -#: payments/apps.py:9 payments/models/payment.py:60 +#: payments/apps.py:11 payments/models/payment.py:60 msgid "Payments" msgstr "Платежи" +#: payments/exceptions/full_balance.py:5 +msgid "Your balance is already full" +msgstr "Ваш баланс уже пополнен до максимума" + #: payments/exceptions/insufficient_balance.py:18 #, python-format msgid "" @@ -1097,60 +1101,56 @@ msgstr "Статус" msgid "Payment" msgstr "Платеж" -#: payments/models/payment_plan.py:18 +#: payments/models/payment_plan.py:13 msgid "Price" msgstr "Цена" -#: payments/models/payment_plan.py:22 +#: payments/models/payment_plan.py:17 msgid "Tokens per plan" msgstr "Токенов за план" -#: payments/models/payment_plan.py:25 +#: payments/models/payment_plan.py:20 msgid "Is corporate" msgstr "Корпоративный" -#: payments/models/payment_plan.py:26 +#: payments/models/payment_plan.py:21 msgid "Individual" msgstr "Индивидуальный" -#: payments/models/payment_plan.py:27 -msgid "Is recurrent" -msgstr "Рекуррентный" - -#: payments/models/payment_plan.py:29 -msgid "Duration" -msgstr "Длительность" - -#: payments/models/payment_plan.py:34 +#: payments/models/payment_plan.py:22 msgid "Is visible" msgstr "Видимый" -#: payments/models/payment_plan.py:53 payments/models/payment_plan.py:68 +#: payments/models/payment_plan.py:41 payments/models/payment_plan.py:56 #: payments/models/payment_plan_feature.py:16 msgid "Payment Plan" msgstr "Платежный План" -#: payments/models/payment_plan.py:54 +#: payments/models/payment_plan.py:42 msgid "Payment Plans" msgstr "Платежные Планы" -#: payments/models/payment_plan.py:70 +#: payments/models/payment_plan.py:58 msgid "Last payment at" msgstr "Последнее время платежа" -#: payments/models/payment_plan.py:71 +#: payments/models/payment_plan.py:59 msgid "Next payment at" msgstr "Следующее время платежа" -#: payments/models/payment_plan.py:73 +#: payments/models/payment_plan.py:63 payments/models/user_payment_method.py:17 +msgid "Payment Method" +msgstr "Платежный метод" + +#: payments/models/payment_plan.py:69 msgid "Current balance" msgstr "Текущий баланс" -#: payments/models/payment_plan.py:79 -msgid "Recurrent billing task" -msgstr "Рекуррентная задача на платеж" +#: payments/models/payment_plan.py:75 +msgid "Referral balance" +msgstr "Реферальный баланс" -#: payments/models/payment_plan.py:100 payments/models/payment_plan.py:101 +#: payments/models/payment_plan.py:90 payments/models/payment_plan.py:91 msgid "User Balance" msgstr "Баланс пользователя" @@ -1222,19 +1222,35 @@ msgstr "Активация Промокода" msgid "Promocode Activations" msgstr "Активации Промокодов" -#: payments/models/user_payment_method.py:24 -msgid "Payment Method" -msgstr "Платежный метод" +#: payments/models/user_payment_method.py:8 +msgid "Payment method UID" +msgstr "UID платёжного метода" + +#: payments/models/user_payment_method.py:9 +msgid "Card type" +msgstr "Тип карты" + +#: payments/models/user_payment_method.py:10 +msgid "Last four card digits" +msgstr "Последние 4 цифры карты" -#: payments/models/user_payment_method.py:25 +#: payments/models/user_payment_method.py:11 +msgid "Attempts" +msgstr "Попытки" + +#: payments/models/user_payment_method.py:18 msgid "Payment Methods" msgstr "Платежные методы" -#: payments/routes/v1.py:94 +#: payments/routes/v1.py:100 +msgid "The recurring payment is successfully cancelled" +msgstr "Автоплатежи успешно отключены" + +#: payments/routes/v1.py:150 msgid "Expenses" msgstr "Затраты" -#: payments/routes/v1.py:98 +#: payments/routes/v1.py:154 msgid "Refills" msgstr "Пополнения" @@ -1246,21 +1262,13 @@ msgstr "" msgid "Unknown account type" msgstr "Неизвестный тип аккаунта" -#: payments/services/payment_method_service.py:46 -msgid "No current active payment method is set" -msgstr "Ни одного активного метода не установлено" - -#: payments/services/payment_method_service.py:55 -msgid "No payment method by this id" -msgstr "Не найдено метода платежа по этому ID" - -#: 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 "Изображения" @@ -1304,7 +1312,7 @@ msgstr "Публичный API" msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:197 tools/media/apis.py:202 +#: tools/chats/apis.py:197 tools/media/apis.py:201 #: tools/public_api/views/base.py:100 msgid "" "An unexpected generation error has occurred. Please try again later or use a " @@ -1326,12 +1334,14 @@ msgstr "Чат %(id)s" msgid "Chat" msgstr "Чат" -#: tools/media/apis.py:173 +#: tools/media/apis.py:172 msgid "" "Temporary issues with the service, we are already working on a solution." msgstr "Временные неполадки с сервисом, мы уже работаем над их решением." -#: tools/media/apis.py:255 +#: tools/media/apis.py:248 tools/media/apis.py:283 +#: tools/public_api/views/ml_service.py:63 +#: tools/public_api/views/ml_service.py:107 msgid "Voice not found." msgstr "Голос не найден." @@ -1343,7 +1353,7 @@ msgstr "Хранилище клонирования голоса" msgid "Voice clone stores" msgstr "Хранилища клонирования голоса" -#: tools/media/models.py:54 tools/media/models.py:131 +#: tools/media/models.py:54 tools/media/models.py:121 msgid "Voice" msgstr "Голос" @@ -1355,7 +1365,7 @@ msgstr "Инструментал" msgid "Kind" msgstr "Тип" -#: tools/media/models.py:72 tools/media/models.py:103 +#: tools/media/models.py:72 tools/media/models.py:105 msgid "File" msgstr "Файл" @@ -1371,23 +1381,32 @@ msgstr "Пресет" msgid "Presets" msgstr "Пресеты" -#: tools/media/models.py:100 +#: tools/media/models.py:93 +msgid "Создан" +msgstr "" + +#: tools/media/models.py:94 +msgid "Изменён" +msgstr "" + +#: tools/media/models.py:102 msgid "Only MP3, OGG, WAV and WEBA audio files are allowed." msgstr "Разрешены только аудиофайлы MP3, OGG, WAV и WEBA." -#: tools/media/models.py:108 +#: tools/media/models.py:110 msgid "Transcription" msgstr "Транскрипция" -#: tools/media/models.py:112 +#: tools/media/models.py:114 msgid "Unknown file" msgstr "Неизвестный файл" -#: tools/media/models.py:132 +#: tools/media/models.py:122 msgid "Voices" msgstr "Голоса" -#: tools/media/routes/v1.py:74 +#: tools/media/routes/v1.py:76 tools/public_api/views/voice.py:86 +#: tools/public_api/views/voice.py:98 msgid "Voice not found" msgstr "Голос не найден" @@ -1425,31 +1444,32 @@ msgstr "" msgid "The request must not be empty" msgstr "Запрос не должен быть пустым" -#: tools/public_api/views/ml_service.py:65 +#: tools/public_api/views/ml_service.py:128 msgid "You must provide a model parameter" msgstr "Необходимо указать параметр 'model'" -#: tools/public_api/views/ml_service.py:80 +#: tools/public_api/views/ml_service.py:143 msgid "Missing required parameter: 'messages'" msgstr "Отсутствует обязательный параметр: 'messages'" -#: tools/public_api/views/ml_service.py:130 +#: tools/public_api/views/ml_service.py:193 msgid "Model not found" msgstr "Модель не найдена" -#: tools/public_api/views/voice.py:40 +#: tools/public_api/views/voice.py:44 msgid "Your voice has been uploaded successfully" msgstr "Ваш голос успешно загружен" -#: tools/public_api/views/voice.py:72 +#: tools/public_api/views/voice.py:77 +msgid "Preset voices are shared and cannot be edited. Use your own voice id." +msgstr "" +"Пресеты общие для всех — их нельзя редактировать. Укажите id своего голоса." + +#: tools/public_api/views/voice.py:87 msgid "Voice title updated successfully" msgstr "Название голоса успешно обновлено" -#: tools/public_api/views/voice.py -msgid "Preset voices are shared and cannot be edited. Use your own voice id." -msgstr "Пресеты общие для всех — их нельзя редактировать. Укажите id своего голоса." - -#: tools/public_api/views/voice.py +#: tools/public_api/views/voice.py:93 msgid "Preset voices are shared and cannot be deleted. Use your own voice id." msgstr "Пресеты общие для всех — их нельзя удалить. Укажите id своего голоса." @@ -1460,6 +1480,24 @@ msgstr "Пресеты общие для всех — их нельзя удал #~ "Случилась ошибка во время генерации. Она может возникать из-за того, что " #~ "NSFW-контент запрещен. Попробуйте снова" +#~ msgid "Is recurrent" +#~ msgstr "Рекуррентный" + +#~ msgid "Payment Datetime" +#~ msgstr "Дата и время платежа" + +#~ msgid "Recurring Payment" +#~ msgstr "Автоплатеж" + +#~ msgid "Recurring Payments" +#~ msgstr "Автоплатежи" + +#~ msgid "No current active payment method is set" +#~ msgstr "Ни одного активного метода не установлено" + +#~ msgid "No payment method by this id" +#~ msgstr "Не найдено метода платежа по этому ID" + #~ msgid "Regular users cannot send introductory letters" #~ msgstr "Обычные пользователи не могут отсылать письма" @@ -0,0 +1,5 @@ +from django.utils.translation import gettext as _ + +class FullBalanceException(Exception): + def __str__(self) -> str: + return _('Your balance is already full') \ No newline at end of file @@ -0,0 +1,56 @@ +# Generated by Django 5.0.11 on 2026-02-12 08:34 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0026_remove_paymentplan_title'), + ] + + operations = [ + migrations.CreateModel( + name='PaymentMethod', + fields=[ + ('uid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Изменён')), + ('payment_method_id', models.UUIDField(unique=True, verbose_name='Payment method UID')), + ('card_type', models.CharField(max_length=15, verbose_name='Card type')), + ('last_four', models.CharField(max_length=4, verbose_name='Last four card digits')), + ('attempts', models.PositiveSmallIntegerField(default=0, verbose_name='Attempts')), + ], + options={ + 'verbose_name': 'Payment Method', + 'verbose_name_plural': 'Payment Methods', + }, + ), + migrations.RemoveField( + model_name='paymentplan', + name='duration', + ), + migrations.RemoveField( + model_name='paymentplan', + name='is_recurrent', + ), + migrations.RemoveField( + model_name='paymentplanuserinfo', + name='plan_schedule', + ), + migrations.AlterField( + model_name='paymentplanuserinfo', + name='next_payment_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Next payment at'), + ), + migrations.AddField( + model_name='paymentplanuserinfo', + name='method', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user_plan_info', to='payments.paymentmethod', verbose_name='Payment Method'), + ), + migrations.DeleteModel( + name='UserPaymentMethod', + ), + ] @@ -0,0 +1,18 @@ +# Generated by Django 5.0.11 on 2026-04-18 06:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0027_paymentmethod_remove_paymentplan_duration_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='paymentplanuserinfo', + name='referral_balance', + field=models.DecimalField(decimal_places=10, default=0, max_digits=100, verbose_name='Referral balance'), + ), + ] @@ -1,6 +1,6 @@ from payments.models.payment import Payment from payments.models.payment_plan import PaymentPlan, PaymentPlanUserInfo -from payments.models.user_payment_method import UserPaymentMethod +from payments.models.user_payment_method import PaymentMethod from payments.models.invoice import Invoice from payments.models.promocode import PromoCode, PromoCodeActivation from payments.models.payment_plan_feature import PaymentPlanFeature @@ -9,9 +9,9 @@ __all__ = ( 'Payment', 'PaymentPlan', 'PaymentPlanUserInfo', - 'UserPaymentMethod', + 'PaymentMethod', 'Invoice', 'PromoCode', 'PromoCodeActivation', - 'PaymentPlanFeature' + 'PaymentPlanFeature', ) @@ -1,20 +1,15 @@ from datetime import datetime -from dateutil.relativedelta import relativedelta from django.contrib.auth import get_user_model from django.contrib.postgres.fields import ArrayField from django.db import models from django.utils.translation import gettext_lazy as _ -from django_celery_beat.models import PeriodicTask from core.models import BaseModel from ml_model.models import NeuronModel class PaymentPlan(BaseModel): - MONTH = 'month' - YEAR = 'year' - DURATION_CHOICES = ((MONTH, MONTH), (YEAR, YEAR)) price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name=_('Price')) tokens_per_plan = models.DecimalField( max_digits=50, @@ -24,13 +19,6 @@ class PaymentPlan(BaseModel): ) is_corporate = models.BooleanField(default=False, verbose_name=_('Is corporate')) individual = models.BooleanField(default=False, verbose_name=_('Individual')) - is_recurrent = models.BooleanField(default=False, verbose_name=_('Is recurrent')) - duration = models.CharField( - verbose_name=_('Duration'), - max_length=100, - choices=DURATION_CHOICES, - default=MONTH, - ) is_visible = models.BooleanField(default=True, verbose_name=_('Is visible')) points = ArrayField( default=list, @@ -68,18 +56,23 @@ class PaymentPlanUserInfo(BaseModel): verbose_name=_('Payment Plan'), ) last_payment_at = models.DateField(verbose_name=_('Last payment at')) - next_payment_at = models.DateField(verbose_name=_('Next payment at')) + next_payment_at = models.DateTimeField(blank=True, null=True, verbose_name=_('Next payment at')) + method = models.OneToOneField( + 'PaymentMethod', + on_delete=models.SET_NULL, + verbose_name=_('Payment Method'), + related_name='user_plan_info', + null=True, + blank=True + ) current_token_balance = models.DecimalField( max_digits=100, decimal_places=10, verbose_name=_('Current balance') ) - plan_schedule = models.OneToOneField( - PeriodicTask, - on_delete=models.CASCADE, - related_name='payment_plan', - verbose_name=_('Recurrent billing task'), - default=None, - null=True, - blank=True, + referral_balance = models.DecimalField( + max_digits=100, + decimal_places=10, + default=0, + verbose_name=_('Referral balance'), ) def save( @@ -90,9 +83,12 @@ class PaymentPlanUserInfo(BaseModel): update_fields=None, ): self.last_payment_at = datetime.now().date() - self.next_payment_at = self.last_payment_at + relativedelta(months=1) return super().save(force_insert, force_update, using, update_fields) + @property + def is_recurring(self) -> bool: + return self.method is not None + def __str__(self) -> str: return f'{self.user.email or "Ошибка"}' @@ -126,8 +126,9 @@ class PromoCodeActivation(BaseModel): def add_tokens_referral(self, amount: int): self.add_tokens(amount=amount) - self.promocode.owner.payment_plan.current_token_balance += amount - self.promocode.owner.payment_plan.save() + owner_pp = self.promocode.owner.payment_plan + owner_pp.referral_balance += amount + owner_pp.save(update_fields=['referral_balance']) def __str__(self) -> str: return f'{self.promocode}' @@ -1,24 +1,17 @@ -from django.contrib.auth import get_user_model from django.db import models from django.utils.translation import gettext_lazy as _ from core.models import BaseModel -class UserPaymentMethod(BaseModel): - user = models.ForeignKey( - get_user_model(), - on_delete=models.CASCADE, - related_name='payment_methods', - verbose_name='Пользователь', - ) - currently_active = models.BooleanField(default=False, verbose_name='Способ платежа активен') - payment_method_id = models.UUIDField(unique=True, verbose_name='UID платёжного метода') - card_type = models.CharField(max_length=15, verbose_name='Тип карты') - last_four = models.CharField(max_length=4, verbose_name='Последние 4 цифры карты') +class PaymentMethod(BaseModel): + payment_method_id = models.UUIDField(unique=True, verbose_name=_('Payment method UID')) + card_type = models.CharField(max_length=15, verbose_name=_('Card type')) + last_four = models.CharField(max_length=4, verbose_name=_('Last four card digits')) + attempts = models.PositiveSmallIntegerField(default=0, verbose_name=_('Attempts')) def __str__(self) -> str: - return self.user.email + return f'Card {self.card_type} ****{self.last_four}' class Meta: verbose_name = _('Payment Method') @@ -1,9 +1,14 @@ +import orjson + import calendar import logging from collections import defaultdict from datetime import date, timedelta from decimal import Decimal +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + from dateutil.relativedelta import relativedelta from django.db.models import CharField, F, Func, Prefetch, Sum, Value from django.db.models.functions import Round, TruncDay, TruncMonth, TruncYear @@ -11,8 +16,18 @@ from django.utils.translation import gettext as _ from ninja import Query, Router from ninja.errors import HttpError +from authentication.models import CustomUserModel from authentication.security import AsyncAuthBearer, SyncAuthBearer -from payments.models import Invoice, Payment, PaymentPlan, PaymentPlanFeature +from payments.exceptions.payer_not_found import PayerNotFound +from payments.models import ( + Invoice, + Payment, + PaymentPlan, + PaymentPlanFeature, + PaymentMethod, + PaymentPlanUserInfo, +) +from authentication.services.email_service import EmailService from payments.schema import UserBalance from payments.schemas import ( ExpensesParamsSchema, @@ -22,9 +37,8 @@ from payments.schemas import ( PaymentPlanSchema, ) from payments.selectors.payment_plan_selector import PaymentPlanSelector -from payments.services.payment_plan_service import PaymentPlanService -from payments.services.payment_service import PaymentService from payments.typing import IntervalStrategyEnum, SourceStrategyEnum +from payments.services.payment_service import PaymentService router = Router(auth=SyncAuthBearer(), tags=['payments']) @@ -44,6 +58,60 @@ def get_user_balance(request): raise HttpError(401, f'{exc}') +@router.post('payment-result', tags=['payments/payment-result'], auth=None) +async def handle_yookassa_webhook(request): + data = orjson.loads(request.body) + logger.info('YooKassa webhook received: payment_id=%s', data['object']['id']) + payment = await PaymentService.handle_payment(data['object']['id']) + try: + payer = await CustomUserModel.objects.prefetch_related('payment_plan', 'payment_plan__plan', 'payment_plan__method').aget( + uid=payment.description + ) + payer_current_plan = payer.payment_plan.plan + payment_instance = await PaymentService(payer).do_payment(payment) + logger.info( + 'YooKassa webhook processed: payment_id=%s payer_email=%s status=%s', + payment.id, + payer.email, + payment.status, + ) + except CustomUserModel.DoesNotExist: + raise HttpError(400, str(PayerNotFound)) + except Exception as exc: + logger.exception(exc) + raise HttpError(400, f'{exc}') + try: + if payment.status in ('succeeded', 'canceled'): + payment_plan = await PaymentPlanUserInfo.objects.select_related('method').aget(user=payer) + next_payment_at = ( + payment_plan.next_payment_at + if payment_plan.is_recurring + else 'Автоплатежи отключены' + ) + new_plan = payment_instance.plan + EmailService.send_payment_email( + f'Вы {"приобрели план" if payer_current_plan.price != new_plan.price else "восстановили баланс по плану"} {new_plan.tokens_per_plan} токенов', + next_payment_at, + payment_instance.amount.quantize(Decimal('0.01'), rounding='ROUND_UP'), + payment_instance.user.email, + payment.status, + ) + except Exception as exc: + logger.exception(exc) + return 200 + + +@router.post('revoke-recurring-payment', tags=['payments/revoke-recurring-payment']) +def revoke_recurring_payment(request): + deleted_count, deleted_details = PaymentMethod.objects.filter(user_plan_info__user=request.auth).delete() + logger.info( + 'Recurring payment revoked by user: email=%s deleted_methods=%s', + request.auth.email, + deleted_count, + ) + return 200, {'detail': _('The recurring payment is successfully cancelled')} + + @router.get('expenses', tags=['payments/expenses'], response=list[ExpensesSchema]) def list_expenses(request, data: ExpensesParamsSchema = Query(...)): try: @@ -89,7 +157,7 @@ def list_expenses(request, data: ExpensesParamsSchema = Query(...)): ), amount=Round(Sum('cost')), ) - case SourceStrategyEnum.DAYS.BUDGET: + case SourceStrategyEnum.BUDGET: expenses = { 'source': _('Expenses'), 'amount': qs.aggregate(amount=Round(Sum('cost')))['amount'], @@ -144,7 +212,6 @@ async def list_payment_plans(request): uid=plan.uid, price=plan.price, tokens_per_plan=plan.tokens_per_plan, - duration=plan.duration, is_corporate=plan.is_corporate, points=plan.points, grouped_features=[{'name': cat, 'features': feats} for cat, feats in grouped.items()], @@ -163,17 +230,38 @@ async def create_payment_link(request, body: NewSubscriptionSchema): if request.auth.account_type not in {'regular', 'business_host'}: raise HttpError(401, 'Unauthorized') payment_plan = await PaymentPlan.objects.aget(uid=body.uid) - payment_url = await PaymentService(request.auth).create_payment_link(payment_plan) + payment_url = (await PaymentService(request.auth).create_payment_link(payment_plan))[0] + logger.info( + 'Payment link endpoint completed: email=%s plan_uid=%s', + request.auth.email, + payment_plan.uid, + ) return PaymentLinkSchema(payment_url=payment_url) except Exception as exc: raise HttpError(400, f'{exc}') -@router.delete('plans', tags=['payments/plans'], auth=AsyncAuthBearer(), response={200: None, 400: str}) -async def cancel_plan_subscription(request): +@router.post('gitlab-webhook', tags=['payments/gitlab-webhook'], auth=None) +async def handle_gitlab_webhook(request): try: - if request.auth.account_type not in {'regular', 'business_host'}: - raise HttpError(401, 'Unauthorized') - await PaymentPlanService(request.auth).cancel_payment_plan() + data = orjson.loads(request.body)['object_attributes'] + if data['name'] == 'recurring_payments': + is_active = data['active'] + if not is_active: + deleted_methods_count, deleted_details = await PaymentMethod.objects.all().adelete() + logger.info( + 'Recurring feature disabled: all payment methods removed count=%s', + deleted_methods_count, + ) + updated_count = await PaymentPlanUserInfo.objects.filter( + plan__price__gt=0, + plan__individual=False, + ).aupdate(next_payment_at=None if not is_active else (timezone.now() + relativedelta(months=1))) + logger.info( + 'Recurring feature flag synced: active=%s updated_subscriptions=%s', + is_active, + updated_count, + ) except Exception as exc: - raise HttpError(400, f'{exc}') + logger.error(exc) + return 200 @@ -0,0 +1,33 @@ +import logging + +from ninja import Router +from ninja.errors import HttpError + +from authentication.security import AsyncAuthBearer, SyncAuthBearer + +from payments.models import PaymentPlan +from payments.schemas import NewSubscriptionSchema, PaymentLinkPriceSchema +from payments.services.payment_service import PaymentService + +router = Router(auth=SyncAuthBearer(), tags=['payments']) + +logger = logging.getLogger(__name__) + + +@router.post('plans', tags=['payments/plans'], auth=AsyncAuthBearer(), response=PaymentLinkPriceSchema) +async def create_payment_link(request, body: NewSubscriptionSchema): + try: + if request.auth.account_type not in {'regular', 'business_host'}: + raise HttpError(401, 'Unauthorized') + payment_plan = await PaymentPlan.objects.aget(uid=body.uid) + payment_url, plan_price, plan_tokens = await PaymentService(request.auth).create_payment_link( + payment_plan + ) + logger.info( + 'Payment link endpoint completed: email=%s plan_uid=%s', + request.auth.email, + payment_plan.uid, + ) + return PaymentLinkPriceSchema(payment_url=payment_url, price=plan_price, buying_tokens=plan_tokens) + except Exception as exc: + raise HttpError(400, f'{exc}') \ No newline at end of file @@ -1,31 +0,0 @@ -from uuid import UUID - -from authentication.models.user import CustomUserModel -from payments.models.user_payment_method import UserPaymentMethod -from payments.serializers import PaymentMethodSerializer - - -class PaymentMethodSelector: - def __init__(self, user: CustomUserModel): - self.user = user - - def list(self, serialize: bool = False): - methods = self.user.payment_methods.all() - if serialize: - return PaymentMethodSerializer(methods, many=True) - return methods - - def get_payment_method_by_uuid(self, method_id: UUID) -> UserPaymentMethod | None: - payment = UserPaymentMethod.objects.filter(user=self.user, payment_method_id=method_id) - - if not payment.exists(): - return None - - return payment.first() - - def get_current_active_method(self) -> UserPaymentMethod: - method = UserPaymentMethod.objects.filter(user=self.user, currently_active=True) - if not method.exists(): - raise Exception(f'No active payment method for user {self.user}') - - return method.first() @@ -18,15 +18,17 @@ class PaymentPlanSelector: if ( self.user.account_type in ('business_account', 'business_admin', 'business_security') ) and self.user.business_account.acceptance_status == InvitationStatus.ACCEPTED: - balance = ( + pp = self.user.business_account.parent_company.user.payment_plan + total_available = pp.current_token_balance + pp.referral_balance + limit = ( self.user.business_account.group.token_limit if self.user.business_account.group else self.user.business_account.token_limit ) - if balance is None: - balance = self.user.business_account.parent_company.user.payment_plan.current_token_balance + balance = min(total_available, limit) if limit is not None else total_available else: - balance = self.user.payment_plan.current_token_balance + pp = self.user.payment_plan + balance = pp.current_token_balance + pp.referral_balance return balance @@ -43,8 +45,8 @@ class PaymentPlanSelector: return UserPaymentPlanSerializer(plan) - def get_free_plan(self, corporate: bool = False, recurrent: bool = False) -> PaymentPlan: - return PaymentPlan.objects.get_or_create(price=0, is_corporate=corporate, is_recurrent=recurrent)[0] + def get_free_plan(self, corporate: bool = False) -> PaymentPlan: + return PaymentPlan.objects.get_or_create(price=0, is_corporate=corporate)[0] def is_plan_paid(self) -> bool: return self.user.payment_plan.plan.price != Decimal('0') @@ -1,21 +0,0 @@ -from typing import Type, TypeVar -from uuid import UUID - -from django_celery_beat.models import PeriodicTask - -Self = TypeVar('Self', bound='RecurrentPaymentSelector') - - -class RecurrentPaymentSelector: - def __init__(self, task: PeriodicTask): - self.task = task - - @classmethod - def from_user_id(cls: Type[Self], user_id: UUID) -> Self | None: - task = PeriodicTask.objects.filter(name=f'recurrent_{user_id}').first() - if task is None: - return None - return cls(task) - - def to_service(self, service): - return service(self.task) @@ -30,13 +30,20 @@ class ModelBillingService: else: raise Exception(_('Unknown account type')) - if plan.current_token_balance < amount: - raise InsufficientBalance(plan.current_token_balance, amount) + total_available = plan.current_token_balance + plan.referral_balance + if total_available < amount: + raise InsufficientBalance(total_available, amount) - if (allowance is not None) and (allowance < amount): + if allowance is not None and allowance < amount: raise InsufficientBalance(allowance, amount) - plan.current_token_balance -= amount + remainder = amount + from_main = min(plan.current_token_balance, remainder) + plan.current_token_balance -= from_main + remainder -= from_main + if remainder > 0: + plan.referral_balance -= remainder + if allowance is not None: if self.user.business_account.group is not None: self.user.business_account.group.token_limit -= amount @@ -1,69 +1,34 @@ import logging from uuid import UUID -from django.db.transaction import atomic -from django.utils.translation import gettext_lazy as _ -from rest_framework.request import Request +from authentication.models import CustomUserModel +from payments.models.user_payment_method import PaymentMethod -from authentication.models.user import CustomUserModel -from payments.models.user_payment_method import UserPaymentMethod -from payments.selectors.payment_method_selector import PaymentMethodSelector -from payments.serializers import ResetPaymentMethodSerializer logger = logging.getLogger(__name__) class PaymentMethodService: - def __init__(self, user: CustomUserModel): + def __init__(self, user: CustomUserModel) -> None: self.user = user def add_payment_method(self, method_id: UUID, card_type: str, last_four: str): - logger.info('START %s', self.add_payment_method.__name__) - existing = PaymentMethodSelector(self.user).get_payment_method_by_uuid(method_id) - logger.info('CURRENT PAYMENT METHOD: %s', existing) - if existing is not None: - return - - payment_method, created = UserPaymentMethod.objects.get_or_create( - user=self.user, - last_four=last_four, - payment_method_id=method_id, - card_type=card_type, - currently_active=True, + payment_method, created = PaymentMethod.objects.update_or_create( + user_plan_info__user=self.user, + defaults={'payment_method_id': method_id, 'last_four': last_four, 'card_type': card_type, 'attempts': 0}, ) - logger.info('NEW PAYMENT METHOD: %s', payment_method) - payment_method.save() - - def delete_payment_method(self, method_id: UUID): - existing = PaymentMethodSelector(self.user).get_payment_method_by_uuid(method_id) - if existing is not None: - existing.delete() - - def unset_payment_method(self): - current_active = PaymentMethodSelector(self.user).get_current_active_method() - - if current_active is None: - raise Exception(_('No current active payment method is set')) - - current_active.is_active = False - current_active.save() - - def set_new_payment_method(self, id: UUID): - method = PaymentMethodSelector(self.user).get_payment_method_by_uuid(id) - - if method is None: - raise Exception(_('No payment method by this id')) - - method.is_active = True - method.save() - - @atomic - def change_methods(self, id: UUID): - self.unset_payment_method() - self.set_new_payment_method(id) - - def update(self, request: Request): - serializer = ResetPaymentMethodSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + logger.info( + 'Payment method saved: email=%s method_uid=%s payment_method_id=%s created=%s', + self.user.email, + payment_method.uid, + method_id, + created, + ) + return payment_method - self.change_methods(serializer.validated_data['uid']) + def delete_payment_method(self): + PaymentMethod.objects.filter(user_plan_info__user=self.user).delete() + logger.info( + 'Payment method deleted: email=%s', + self.user.email, + ) @@ -1,19 +1,10 @@ import logging from decimal import Decimal -from asgiref.sync import sync_to_async -from rest_framework.request import Request - from authentication.models import CustomUserModel from payments.models import Invoice, PaymentPlan, PaymentPlanUserInfo -from payments.selectors.payment_plan_selector import PaymentPlanSelector -from payments.selectors.recurrent_payment_selector import ( - RecurrentPaymentSelector, -) -from payments.serializers import SuccessPaymentResult from payments.services.model_billing_service import ModelBillingService from payments.services.payment_service import PaymentService -from payments.services.recurrent_payment_service import RecurrentPaymentService logger = logging.getLogger(__name__) @@ -26,46 +17,28 @@ class PaymentPlanService: def add_tokens(self, amount: float | Decimal): self.user.payment_plan.current_token_balance += amount self.user.payment_plan.save() + logger.info( + 'Tokens added to plan balance: email=%s amount=%s new_balance=%s', + self.user.email, + amount, + self.user.payment_plan.current_token_balance, + ) - @classmethod - def handle_success_payment(cls, request: Request): - """Parse Yookassa Successful Payment Request and - Subscribe user to paid Payment Plan""" - serializer = SuccessPaymentResult(data=request.data['object']) - serializer.is_valid(raise_exception=True) - payment_id = serializer.validated_data['id'] - payment = PaymentService.confirm_payment(payment_id) - if payment.status == payment.SUCCEEDED: - cls(payment.user).subscribe_user_to_plan(payment.plan) - - def handle_recurrent(self, plan: PaymentPlanUserInfo): - current = RecurrentPaymentSelector.from_user_id(self.user.uid) - if current is None: - task = RecurrentPaymentService.create(self.user, plan.plan) - plan.plan_schedule = task.task - plan.save() - else: - current.to_service(RecurrentPaymentService).switch_plan(self.user, plan.plan.uid) - - def subscribe_user_to_plan(self, plan: PaymentPlan): - plan_info, created = PaymentPlanUserInfo.objects.get_or_create( + def subscribe_user_to_plan(self, plan: PaymentPlan, amount: Decimal): + _, created = PaymentPlanUserInfo.objects.update_or_create( user=self.user, defaults={ 'plan': plan, - 'current_token_balance': plan.tokens_per_plan, + 'current_token_balance': amount, }, ) - if not created: - plan_info.plan = plan - plan_info.current_token_balance += plan.tokens_per_plan - plan_info.save() - - async def cancel_payment_plan(self): - plan_info: PaymentPlanUserInfo = self.user.payment_plan - plan_info.plan = await sync_to_async(PaymentPlanSelector(self.user).get_free_plan)( - corporate=self.user.is_corporate() + logger.info( + 'User subscribed to plan: email=%s plan_uid=%s tokens=%s created=%s', + self.user.email, + plan.uid, + amount, + created, ) - await plan_info.asave() def update_per_token_plan_details(self, payment_amount: Decimal, model=None): ModelBillingService(self.user).charge(payment_amount) @@ -1,18 +1,22 @@ import logging + +from decimal import Decimal from uuid import UUID, uuid4 +from asgiref.sync import sync_to_async +from dateutil.relativedelta import relativedelta from django.conf import settings +from django.db import transaction +from django.utils import timezone from yookassa import Configuration from yookassa import Payment as YookassaPayment -from yookassa.domain.models.payment_data.response.payment_data_bank_card import ( - PaymentDataBankCard, -) +from yookassa.domain.response import PaymentResponse as YookassaPaymentResponse from authentication.models import CustomUserModel -from payments.exceptions.payer_not_found import PayerNotFound +from lib.unleash.client import web_client +from payments.exceptions.full_balance import FullBalanceException from payments.models.payment import Payment as PaymentModel -from payments.models.payment_plan import PaymentPlan -from payments.selectors.payment_method_selector import PaymentMethodSelector +from payments.models.payment_plan import PaymentPlan, PaymentPlanUserInfo from payments.services.payment_method_service import PaymentMethodService from payments.services.referral_account import ReferralAccountService @@ -26,20 +30,48 @@ class PaymentService: def __init__(self, user: CustomUserModel): self.user = user - async def create_payment_link(self, plan: PaymentPlan) -> str: + async def create_payment_link(self, plan: PaymentPlan) -> tuple[str, Decimal, Decimal]: + current_plan = self.user.payment_plan.plan + current_balance = self.user.payment_plan.current_token_balance + plan_price = plan.price + plan_tokens = plan.tokens_per_plan + if ( + current_balance >= plan_tokens + and plan == current_plan + and web_client.get_flag_state('recurring_payments', self.user.email) + ): + raise FullBalanceException + if 0 < current_plan.price <= plan.price and self.user.payment_plan.is_recurring: + plans_price_diff = plan.price - current_plan.price + token_price = current_plan.price / current_plan.tokens_per_plan + tokens_diff = current_plan.tokens_per_plan - current_balance + additional_tokens = 0 if tokens_diff < 0 else tokens_diff + plan_price = Decimal(plans_price_diff + (token_price * additional_tokens)).quantize( + Decimal('1'), rounding='ROUND_UP' + ) + balance_sufficient = (plan_tokens - current_balance) <= 0 + plan_tokens = ( + plan_tokens - current_plan.tokens_per_plan + if balance_sufficient + else (plan_tokens - current_balance) + ) + product_title = f'Вы {"купили план" if current_plan.price != plan.price else "восстановили баланс по плану"} {plan.tokens_per_plan} токенов' receipt_data = { 'customer': {'email': self.user.email}, 'items': [ { - 'description': f'План {plan.tokens_per_plan} токенов за {plan.price} р.', - 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'description': product_title, + 'amount': {'value': f'{plan_price}', 'currency': 'RUB'}, 'vat_code': 1, 'quantity': '1', } ], } + is_recurring = ( + web_client.get_flag_state('recurring_payments', self.user.email) and not plan.individual + ) payment_data = { - 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'amount': {'value': f'{plan_price}', 'currency': 'RUB'}, 'payment_method_data': {'type': 'bank_card'}, 'receipt': receipt_data, 'confirmation': { @@ -48,71 +80,161 @@ class PaymentService: }, 'description': str(self.user.uid), 'capture': True, + 'save_payment_method': is_recurring, + 'metadata': { + 'plan_uid': str(plan.uid), + 'buying_tokens': str(plan_tokens), + }, } - - if plan.is_recurrent: - payment_data.update(save_payment_method=True) payment = YookassaPayment.create(payment_data, uuid4()) - return payment.confirmation.confirmation_url - - @classmethod - def save_payment_method(cls, user: CustomUserModel, method_data: PaymentDataBankCard): - method_id = method_data.id - card = method_data.card - card_type = card.card_type - - last_four = card.last4 - PaymentMethodService(user).add_payment_method( - method_id=UUID(method_id), card_type=card_type, last_four=last_four + logger.info( + 'Payment link created: email=%s plan_uid=%s price=%s recurring=%s', + self.user.email, + plan.uid, + plan_price, + is_recurring, + ) + return ( + payment.confirmation.confirmation_url, + plan_price, + plan_tokens.quantize(Decimal('1'), rounding='ROUND_UP'), ) - @classmethod - def confirm_payment(cls, payment_id: UUID): - payment = YookassaPayment.find_one(str(payment_id)) - try: - user = CustomUserModel.objects.get(uid=payment.description) - except CustomUserModel.DoesNotExist: - raise PayerNotFound - if payment.status == 'waiting_for_capture': - YookassaPayment.capture(str(payment_id)) - elif payment.status == 'succeeded': - method_data = payment.payment_method - if method_data.saved: - cls.save_payment_method(user, method_data) - payment_instance = cls.save_payment(user=user, payment=payment) - try: - if user.referer_account: - ReferralAccountService.apply_accrual( - referer_account=user.referer_account, - payment=payment_instance, + @sync_to_async + def do_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: + from payments.services.payment_plan_service import PaymentPlanService + + with transaction.atomic(): + payment_instance = self.save_payment(payment) + logger.info( + 'Processing payment webhook: payment_id=%s email=%s status=%s', + payment.id, + self.user.email, + payment.status, + ) + if payment.status == 'waiting_for_capture': + self.handle_captured_payment(payment.id) + elif payment.status == 'succeeded': + buying_tokens = self.calculate_buying_tokens( + Decimal(f'{payment.metadata["buying_tokens"]}'), payment_instance.plan ) - except Exception as exc: - logger.exception(exc) + self.handle_succeeded_payment(payment, payment_instance.plan) + PaymentPlanService(self.user).subscribe_user_to_plan(payment_instance.plan, buying_tokens) + if ref_acc := self.user.referer_account: + ReferralAccountService.apply_accrual(referer_account=ref_acc, payment=payment_instance) + elif payment.status == 'canceled' and payment.metadata.get('recurring'): + self.handle_canceled_payment(payment) + return payment_instance @classmethod - def save_payment(cls, user: CustomUserModel, payment: YookassaPayment) -> PaymentModel: - payment_instance, _ = PaymentModel.objects.update_or_create( + async def handle_payment(cls, payment_id: UUID) -> YookassaPaymentResponse: + return await sync_to_async(YookassaPayment.find_one)(payment_id) + + def handle_captured_payment(self, payment_id: UUID) -> None: + YookassaPayment.capture(str(payment_id)) + logger.info('Payment captured: payment_id=%s email=%s', payment_id, self.user.email) + + def calculate_buying_tokens(self, buying_tokens: Decimal, plan: PaymentPlan): + if not web_client.get_flag_state('recurring_payments', self.user.email): + return self.user.payment_plan.current_token_balance + buying_tokens + current_plan_price = self.user.payment_plan.plan.price + cap = plan.tokens_per_plan + if current_plan_price == Decimal('0') or current_plan_price > plan.price: + return min(buying_tokens, cap) + if self.user.payment_plan.is_recurring: + current_balance = self.user.payment_plan.current_token_balance + if current_balance >= cap: + return cap + return min(current_balance + buying_tokens, cap) + return min(buying_tokens, cap) + + def handle_succeeded_payment(self, payment: YookassaPaymentResponse, plan: PaymentPlan) -> None: + if ( + payment.payment_method.saved + and not payment.authorization_details.three_d_secure.applied + and web_client.get_flag_state('recurring_payments', self.user.email) + and not plan.individual + ): + card = payment.payment_method.card + payment_method = PaymentMethodService(self.user).add_payment_method( + payment.payment_method.id, card.card_type, card.last4 + ) + PaymentPlanUserInfo.objects.filter(user=self.user).update( + method=payment_method, next_payment_at=timezone.now() + relativedelta(months=1) + ) + logger.info( + 'Recurring payment method saved: email=%s method_uid=%s next_payment_at_set=true', + self.user.email, + payment_method.uid, + ) + else: + if web_client.get_flag_state('recurring_payments', self.user.email) and not plan.individual: + PaymentPlanUserInfo.objects.filter(user=self.user).update( + next_payment_at=timezone.now() + relativedelta(months=1) + ) + logger.info( + 'Recurring schedule updated without saved method: email=%s next_payment_at_set=true', + self.user.email, + ) + else: + PaymentPlanUserInfo.objects.filter(user=self.user).update(next_payment_at=None) + logger.info( + 'Recurring schedule cleared: email=%s reason=feature_disabled_or_individual_plan', + self.user.email, + ) + PaymentMethodService(self.user).delete_payment_method() + logger.info( + 'Recurring payment method deleted after succeeded payment: email=%s', self.user.email + ) + + def handle_canceled_payment(self, payment: YookassaPaymentResponse) -> None: + logger.error(f'Recurrent payment error: {payment.cancellation_details.reason}') + temporary_cancel_reasons = ( + 'call_issuer', + 'expired_on_capture', + 'insufficient_funds', + 'internal_timeout', + 'issuer_unavailable', + 'payment_method_limit_exceeded', + ) + if payment.cancellation_details.reason in temporary_cancel_reasons: + self.user.payment_plan.method.attempts += 1 + self.user.payment_plan.method.save() + logger.info( + 'Recurring payment canceled with retry: email=%s method_uid=%s attempts=%s reason=%s', + self.user.email, + self.user.payment_plan.method.uid, + self.user.payment_plan.method.attempts, + payment.cancellation_details.reason, + ) + else: + PaymentMethodService(self.user).delete_payment_method() + logger.info( + 'Recurring payment method deleted after cancel: email=%s reason=%s', + self.user.email, + payment.cancellation_details.reason, + ) + + def save_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: + plan = PaymentPlan.objects.get_or_none(uid=payment.metadata.get('plan_uid')) + payment_instance, created = PaymentModel.objects.update_or_create( uid=payment.id, defaults=dict( - user=user, + user=self.user, amount=payment.amount.value, - plan=PaymentPlan.objects.get_or_none(price=payment.amount.value), + plan=plan, status=payment.status, description=payment.description, ), ) - return payment_instance - - def conduct_recurring_payment(self, plan: PaymentPlan, description: str): - logger.info('START %s' % self.conduct_recurring_payment.__name__) - payment_method = PaymentMethodSelector(self.user).get_current_active_method() - logger.info('PAYMENT METHOD: %s' % payment_method) - YookassaPayment.create( - { - 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, - 'capture': True, - 'payment_method_id': f'{payment_method.payment_method_id}', - 'description': description, - } + logger.info( + 'Payment persisted: payment_id=%s email=%s status=%s created=%s plan_uid=%s amount=%s', + payment.id, + self.user.email, + payment.status, + created, + plan.uid if plan else None, + payment.amount.value, ) + return payment_instance @@ -1,53 +0,0 @@ -import json -import logging -from typing import Type, TypeVar - -from django.conf import settings -from django.utils import timezone -from django_celery_beat.models import IntervalSchedule, PeriodicTask - -from authentication.models.user import CustomUserModel -from payments.models import PaymentPlan - -Self = TypeVar('Self', bound='RecurrentPaymentService') -logger = logging.getLogger(__name__) - - -class RecurrentPaymentService: - def __init__(self, task: PeriodicTask): - self.task = task - - @classmethod - def create(cls: Type[Self], user: CustomUserModel, payment_plan: PaymentPlan) -> Self: - subscription_days_duration = ( - 30 - if payment_plan.duration == payment_plan.MONTH - else 365 - if payment_plan.duration == payment_plan.YEAR - else 30 - ) - schedule = IntervalSchedule.objects.filter( - every=subscription_days_duration, period=settings.RECURRENT_RATE - ) - - if not schedule.exists(): - schedule = IntervalSchedule.objects.create(every=30, period=settings.RECURRENT_RATE) - else: - schedule = schedule.first() - - task = PeriodicTask.objects.create( - name=f'recurrent_{user.uid}', - task='conduct_recurrent', - interval=schedule, - args=json.dumps([str(payment_plan.uid), str(user.uid)]), - start_time=timezone.now(), - ) - return cls(task) - - def switch_plan(self, user: CustomUserModel, new_plan: PaymentPlan) -> Self: - self.delete() - self.create(user=user, payment_plan=new_plan) - return self - - def delete(self): - self.task.delete() @@ -1,3 +1,4 @@ +import logging from decimal import Decimal from authentication.models.user import CustomUserModel @@ -8,6 +9,8 @@ from payments.models.referral_account import ( ReferralInvite, ) +logger = logging.getLogger(__name__) + class ReferralAccountService: @classmethod @@ -15,17 +18,26 @@ class ReferralAccountService: return ReferralAccount.objects.create(owner=user) @classmethod - def create_invite(cls, referer_account: CustomUserModel, invitee: CustomUserModel): + def create_invite(cls, referer_account: ReferralAccount, invitee: CustomUserModel): return ReferralInvite.objects.create(referer_account=referer_account, invitee=invitee) @classmethod def apply_accrual(cls, referer_account: ReferralAccount, payment: Payment): - accrual_amount = (payment.plan.tokens_per_plan * Decimal(0.2)).quantize(Decimal('1')) - accrual = ReferralAccrual.objects.create( - referer_account=referer_account, - payment=payment, - amount=accrual_amount, - ) - referer_account.owner.payment_plan.current_token_balance += accrual_amount - referer_account.owner.payment_plan.save() - return accrual + if not ReferralAccrual.objects.filter( + referer_account=referer_account, payment__user=payment.user + ).first(): + accrual_amount = (payment.plan.tokens_per_plan * Decimal(0.2)).quantize(Decimal('1')) + ReferralAccrual.objects.create( + referer_account=referer_account, + payment=payment, + amount=accrual_amount, + ) + pp = referer_account.owner.payment_plan + pp.referral_balance += accrual_amount + pp.save(update_fields=['referral_balance']) + logger.info( + 'Referral accrual applied: referer_email=%s invitee_email=%s amount=%s', + referer_account.owner.email, + payment.user.email if payment.user else None, + accrual_amount, + ) @@ -0,0 +1,20 @@ + + + + + Неуспешный платеж + + +

Здравствуйте!

+ +

К сожалению, платёж за восстановление баланса по плану на сумму {{ amount }} не был успешно + обработан.

+ +

+ Пожалуйста, убедитесь, что на вашем счёте достаточно средств или + обновите платёжные данные, чтобы избежать прерывания доступа. +

+ +

Если у вас возникнут вопросы, вы всегда можете обратиться в нашу службу поддержки.

+ + @@ -0,0 +1,16 @@ + + + + + Детали платежа + + +

Здравствуйте!

+ +

{{ product_title }} на нашем сайте.

+ +

Следующая дата пополнения: {{ next_payment_at }}.

+ +

Если у вас возникнут вопросы, вы всегда можете обратиться в нашу службу поддержки.

+ + @@ -129,11 +129,11 @@ class PlansAPITest(BaseAuthorizedAPITest): 'uid', 'price', 'tokens_per_plan', - 'duration', + 'is_corporate', 'points', 'grouped_features', 'accessed_models', - 'individual' + 'individual', ], ) @@ -46,9 +46,21 @@ class BalanceAPITest(BaseAuthorizedAPITest): balance = self.get().json()['current_token_balance'] self.assertEqual(Decimal(balance), Decimal('8.00')) + def test_balance_includes_referral(self) -> None: + pp = self.user.payment_plan + pp.current_token_balance = Decimal('3') + pp.referral_balance = Decimal('7') + pp.save() + balance = self.get().json()['current_token_balance'] + self.assertEqual(Decimal(balance), Decimal('10')) + def test_displaying_balance(self) -> None: balance = self.get().json()['current_token_balance'] self.assertEqual(Decimal(balance), Decimal('10.00')) + hp = self.host_user.payment_plan + hp.current_token_balance = Decimal('900') + hp.referral_balance = Decimal('0') + hp.save() business_account = BusinessAccount.objects.create( user=self.user, parent_company=self.host, acceptance_status='accepted', token_limit=Decimal('50') ) @@ -16,7 +16,7 @@ from payments.models import ( PaymentPlanUserInfo, PromoCode, PromoCodeActivation, - UserPaymentMethod, + PaymentMethod, ) from payments.models.referral_account import ReferralAccount, ReferralInvite @@ -47,9 +47,6 @@ class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): '__str__', 'uid', 'price', - 'tokens_per_plan', - 'duration', - 'is_recurrent', 'is_corporate', 'individual', 'is_visible', @@ -59,7 +56,7 @@ class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): @admin.register(PaymentPlanUserInfo) class PaymentPlanUserInfoAdmin(admin.ModelAdmin): - list_display = ['user', 'current_token_balance', 'plan', 'updated_at'] + list_display = ['user', 'current_token_balance', 'referral_balance', 'plan', 'updated_at', 'next_payment_at'] raw_id_fields = ['user'] search_fields = [ @@ -76,9 +73,9 @@ class PaymentPlanFeatureAdmin(OrderedModelAdmin): list_filter = ('plan', 'model__category') -@admin.register(UserPaymentMethod) -class UserPaymentMethodAdmin(admin.ModelAdmin): - raw_id_fields = ['user'] +@admin.register(PaymentMethod) +class PaymentMethodAdmin(admin.ModelAdmin): + list_display = ['payment_method_id', 'card_type', 'last_four', 'attempts'] @admin.register(Invoice) @@ -224,3 +221,4 @@ class ReferralAccountAdmin(admin.ModelAdmin): @admin.display(description='Получено бонусов') def _accrued_bonuses(self, obj: ReferralAccount): return f'{obj.accrued_bonuses.aggregate(total=Coalesce(Sum("amount"), Decimal(0), output_field=models.DecimalField()))["total"]} токенов' + @@ -2,6 +2,8 @@ from django.apps import AppConfig from django.core.signals import setting_changed from django.utils.translation import gettext_lazy as _ +from lib.unleash.client import web_client + class PaymentsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' @@ -9,8 +11,9 @@ class PaymentsConfig(AppConfig): verbose_name = _('Payments') def ready(self): - from .signals import init_referral_account + import payments.signals - setting_changed.connect(init_referral_account) + web_client.client.initialize_client() + setting_changed.connect(payments.signals.init_referral_account) return super().ready() @@ -1,4 +1,5 @@ -from datetime import date +from datetime import date, datetime +from decimal import Decimal from typing import List, Optional from uuid import UUID @@ -24,7 +25,6 @@ class PaymentPlanSchema(Schema): uid: UUID price: condecimal(max_digits=10, decimal_places=2) tokens_per_plan: condecimal(max_digits=10, decimal_places=2) - duration: str is_corporate: bool points: list[str] grouped_features: list[GroupedPlanFeatureSchema] = [] @@ -47,8 +47,9 @@ class PaymentLinkSchema(Schema): class UserPlanDetailSchema(Schema): uid: UUID plan: PaymentPlanSchema - last_payment_at: date - next_payment_at: date + last_payment_at: datetime + next_payment_at: datetime | None + is_recurring: bool class PromoCodeSchema(ModelSchema): @@ -66,14 +67,19 @@ class ExpensesParamsSchema(Schema): source_strategy: SourceStrategyEnum | None = None start: Optional[date] = Query( default=date.min, - example="2025-12-17", + example='2025-12-17', ) end: Optional[date] = Query( default=date.min, - example="2025-12-07", + example='2025-12-07', ) class ExpensesSchema(Schema): source: str - amount: condecimal(max_digits=10, decimal_places=2) \ No newline at end of file + amount: condecimal(max_digits=10, decimal_places=2) + + +class PaymentLinkPriceSchema(PaymentLinkSchema): + price: Decimal + buying_tokens: Decimal @@ -9,7 +9,6 @@ class PaymentPlanSerializer(serializers.Serializer): uid = serializers.UUIDField() price = serializers.DecimalField(max_digits=10, decimal_places=2) tokens_per_plan = serializers.DecimalField(max_digits=50, decimal_places=2) - duration = serializers.CharField(read_only=True) is_corporate = serializers.BooleanField() accessed_models = serializers.SerializerMethodField() individual = serializers.BooleanField() @@ -53,26 +52,6 @@ class UserPlanDetailSerializer(serializers.Serializer): current_token_balance = serializers.IntegerField() -class SuccessPaymentResult(serializers.Serializer): - id = serializers.UUIDField() - - -class PaymentMethodSerializer(serializers.Serializer): - uid = serializers.UUIDField() - currently_active = serializers.BooleanField() - payment_method_id = serializers.UUIDField() - card_type = serializers.CharField() - last_four = serializers.CharField() - - -class ResetPaymentMethodSerializer(serializers.Serializer): - uid = serializers.UUIDField() - - -class DeletePaymentMethodSerializer(serializers.Serializer): - uid = serializers.UUIDField() - - class UserPaymentPlanSerializer(serializers.Serializer): current_token_balance = serializers.DecimalField(max_digits=50, decimal_places=2) @@ -1,11 +1,16 @@ +import logging from typing import Type +from django.conf import settings from django.db.models.signals import post_save from django.dispatch import receiver from authentication.models.user import CustomUserModel +from payments.models import PaymentPlan, PaymentMethod, PaymentPlanUserInfo from payments.services.referral_account import ReferralAccountService +logger = logging.getLogger(__name__) + @receiver(post_save, sender=CustomUserModel) def init_referral_account( @@ -16,3 +21,39 @@ def init_referral_account( ): if created: ReferralAccountService.create_account(user=instance) + + +@receiver(post_save, sender=PaymentPlan) +def delete_recurrent_for_individual_plans( + sender: Type[PaymentPlan], instance: PaymentPlan, created: bool, **kwargs +): + if instance.individual: + PaymentPlanUserInfo.objects.filter(plan=instance).update(next_payment_at=None) + PaymentMethod.objects.filter(user_plan_info__plan=instance).delete() + + +@receiver(post_save, sender=PaymentMethod) +def delete_method_with_exceeded_attempts( + sender: Type[PaymentMethod], instance: PaymentMethod, created: bool, **kwargs +): + if instance.attempts >= settings.MAX_RECURRING_ATTEMPTS: + user_email = PaymentPlanUserInfo.objects.filter(method=instance).values_list('user__email', flat=True).first() + logger.info( + 'Payment method deleted due to attempts limit: email=%s', + user_email, + ) + instance.delete() + + +@receiver(post_save, sender=PaymentPlanUserInfo) +def clear_recurrent_on_individual_plan_assignment( + sender: Type[PaymentPlanUserInfo], instance: PaymentPlanUserInfo, created: bool, **kwargs +): + if not instance.plan.individual: + return + + method_id = instance.method_id + PaymentPlanUserInfo.objects.filter(pk=instance.pk).update(next_payment_at=None, method=None) + + if method_id: + PaymentMethod.objects.filter(pk=method_id).delete() \ No newline at end of file @@ -1,15 +1,21 @@ from decimal import Decimal -from uuid import UUID +from uuid import UUID, uuid4 from celery import shared_task from celery.utils.log import get_task_logger from django.db.models import F +from django.utils import timezone 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.services.payment_plan_service import PaymentPlanService +from yookassa import Payment as YookassaPayment + logger = get_task_logger(__name__) @@ -17,7 +23,8 @@ logger = get_task_logger(__name__) def send_low_balance_message(): hosts = BusinessUserHost.objects.filter( token_cap_enabled=True, - token_cap__gt=F('user__payment_plan__current_token_balance'), + token_cap__gt=F('user__payment_plan__current_token_balance') + + F('user__payment_plan__referral_balance'), ) for host in hosts: EmailService.send_low_balance_email(host) @@ -27,3 +34,82 @@ def send_low_balance_message(): def withdraw(user_id: UUID, amount: Decimal): user = CustomUserModel.objects.get(uid=user_id) PaymentPlanService(user).update_per_token_plan_details(amount) + + +@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, + ) + canceled_recurring_payments = [] + logger.info('Recurring payments task started: overdue_count=%s', overdue_payments.count()) + for overdue_payment in overdue_payments: + 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 + product_title = f'Вы восстановили баланс по плану {plan.tokens_per_plan} токенов' + receipt_data = { + 'customer': {'email': customer.email}, + 'items': [ + { + 'description': product_title, + 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'vat_code': 1, + 'quantity': '1', + } + ], + } + payment_data = { + 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'payment_method_id': overdue_payment.method.payment_method_id, + 'receipt': receipt_data, + 'description': str(customer.uid), + 'capture': True, + 'metadata': { + 'recurring': True, + 'plan_uid': str(plan.uid), + 'buying_tokens': str(plan.tokens_per_plan), + }, + } + YookassaPayment.create(payment_data, uuid4()) + logger.info( + 'Recurring payment initiated: email=%s plan_uid=%s amount=%s method_uid=%s', + customer.email, + plan.uid, + 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 @@ -10,16 +10,6 @@ urlpatterns = [ name='referral-account', ), path('invoices', views.InvoicesAPIView.as_view(), name='invoices'), - path( - 'methods', - views.PaymentMethodsAPIView.as_view(), - name='payment-methods', - ), - path( - 'payment-result', - views.PaymentConfirmationAPIView.as_view(), - name='payment-result', - ), path('promocode', views.PromoCodeAPIView.as_view(), name='promocode'), path( 'telegram-sub', @@ -15,20 +15,15 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from authentication.permissions import IsAnonymous, IsTelegramAirBot +from authentication.permissions import IsTelegramAirBot from authentication.selectors.user_selector import UserSelector -from payments.exceptions.payer_not_found import PayerNotFound from payments.models import Invoice from payments.permissions import IsAllowedToPay -from payments.selectors.payment_method_selector import PaymentMethodSelector -from payments.selectors.payment_plan_selector import PaymentPlanSelector from payments.selectors.payment_selector import PaymentSelector from payments.serializers import ( - DeletePaymentMethodSerializer, InvoiceSerializer, ReferralAccountSerializer, ) -from payments.services.payment_method_service import PaymentMethodService from payments.services.payment_plan_service import PaymentPlanService from payments.services.promocode_service import PromoCodeService @@ -49,54 +44,6 @@ class PaymentAPIView(APIView): return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) -class PaymentMethodsAPIView(APIView): - permission_classes = (IsAuthenticated, IsAllowedToPay) - - def get(self, request, *args, **kwargs): - """List user's saved payment methods (cards)""" - try: - result = PaymentMethodSelector(self.request.user).list(serialize=True) - return Response(result.data, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - def put(self, request, *args, **kwargs): - """Update payment method (card) data.""" - try: - PaymentMethodService(self.request.user).update(request) - return Response({'ok': True}, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, *args, **kwargs): - """Delete user's payment method (card)""" - try: - serializer = DeletePaymentMethodSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - PaymentMethodService(self.request.user).delete_payment_method(**serializer.validated_data) - return Response({'ok': True}, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - -class PaymentConfirmationAPIView(APIView): - permission_classes = (IsAnonymous,) - - def post(self, request, *args, **kwargs): - """Endpoint for payment service (Yookassa) WebHook. Hook happens after successful payment.""" - try: - PaymentPlanService.handle_success_payment(self.request) - return Response(status=status.HTTP_200_OK) - except PayerNotFound as err: - return Response({'detail': str(err)}, status=status.HTTP_400_BAD_REQUEST) - except Exception as err: - logger.exception(err) - return Response({'detail': {str(err)}}, status=status.HTTP_400_BAD_REQUEST) - - class InvoicesAPIView(ListAPIView): permission_classes = [ IsAuthenticated, @@ -66,7 +66,6 @@ BUSINESS_EMAIL_RECIPIENT=help@root.ru YOOKASSA_ACCOUNT_ID=322563 YOOKASSA_SECRET_KEY=test_i_Au0KbXnOmdVf1icljT7v4CuDHLG8mXVkyofJQFBns YOOKASSA_RESULT_PAYMENT_URL=http://localhost -RECURRENT_RATE=days USER_CONFIRMATION_URL='https://app.air.fail/confirm' USER_PASSWORD_RESET_URL='https://app.air.fail/changePassword' INVITATION_RESPONSE_URL='https://app.air.fail/business/confirm' @@ -90,4 +89,9 @@ CHANNELS_PORT_MDB=6379 LOG_LEVEL=debug DOMAIN=localhost -PROVIDER=docker \ No newline at end of file +PROVIDER=docker + +# UNLEASH +FEATURE_FLAG_API_URL=https://gitlab.com/api/v4/feature_flags/unleash/64383616 +FEATURE_FLAG_INSTANCE_ID=glffct-s7Ce_Ki4hCjMfnYVsi5y +FEATURE_FLAG_APP_NAME=production \ No newline at end of file @@ -174,6 +174,34 @@ doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] +[[package]] +name = "apscheduler" +version = "3.11.1" +description = "In-process task scheduler with Cron-like capabilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2"}, + {file = "apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221"}, +] + +[package.dependencies] +tzlocal = ">=3.0" + +[package.extras] +doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] +etcd = ["etcd3", "protobuf (<=3.21.0)"] +gevent = ["gevent"] +mongodb = ["pymongo (>=3.0)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=1.4)"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytz", "twisted ; python_version < \"3.14\""] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + [[package]] name = "argon2-cffi" version = "23.1.0" @@ -1590,6 +1618,21 @@ files = [ wasmer = ">=1.0.0" wasmer-compiler-cranelift = ">=1.0.0" +[[package]] +name = "fcache" +version = "0.6.0" +description = "a dictionary-like, file-based cache module for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "fcache-0.6.0-py3-none-any.whl", hash = "sha256:dbf0753bb7400ed80d703df9ffcfb438786698872ed92c50169f95cb0eac8306"}, + {file = "fcache-0.6.0.tar.gz", hash = "sha256:79949f0aafe8cedc5c9064631b3c157941a288e59f9991dd158c23e8e60b5422"}, +] + +[package.dependencies] +platformdirs = ">=3.0,<4.0" + [[package]] name = "filetype" version = "1.2.0" @@ -2310,6 +2353,30 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "importlib-metadata" +version = "8.7.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, + {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] + [[package]] name = "incremental" version = "24.7.2" @@ -2759,6 +2826,21 @@ requests-toolbelt = ">=1.0.0,<2.0.0" [package.extras] langsmith-pyo3 = ["langsmith-pyo3 (>=0.1.0rc2,<0.2.0)"] +[[package]] +name = "launchdarkly-eventsource" +version = "1.5.0" +description = "LaunchDarkly SSE Client" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "launchdarkly_eventsource-1.5.0-py3-none-any.whl", hash = "sha256:4c3bf3e9f318792712dc2eb00905c5b15a1b8df2cf247b9dcef4dd3560fdde1e"}, + {file = "launchdarkly_eventsource-1.5.0.tar.gz", hash = "sha256:29337766b409774f81ad5dda43e2d12dcbc1f02f07c7d4fdab3330f6b46b0c5d"}, +] + +[package.dependencies] +urllib3 = ">=1.26.0,<3" + [[package]] name = "lxml" version = "5.4.0" @@ -2962,6 +3044,145 @@ pycryptodome = "*" typing-extensions = "*" urllib3 = "*" +[[package]] +name = "mmh3" +version = "5.2.0" +description = "Python extension for MurmurHash (MurmurHash3), a set of fast and robust hash functions." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "mmh3-5.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:81c504ad11c588c8629536b032940f2a359dda3b6cbfd4ad8f74cb24dcd1b0bc"}, + {file = "mmh3-5.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b898cecff57442724a0f52bf42c2de42de63083a91008fb452887e372f9c328"}, + {file = "mmh3-5.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:be1374df449465c9f2500e62eee73a39db62152a8bdfbe12ec5b5c1cd451344d"}, + {file = "mmh3-5.2.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0d753ad566c721faa33db7e2e0eddd74b224cdd3eaf8481d76c926603c7a00e"}, + {file = "mmh3-5.2.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dfbead5575f6470c17e955b94f92d62a03dfc3d07f2e6f817d9b93dc211a1515"}, + {file = "mmh3-5.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7434a27754049144539d2099a6d2da5d88b8bdeedf935180bf42ad59b3607aa3"}, + {file = "mmh3-5.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cadc16e8ea64b5d9a47363013e2bea469e121e6e7cb416a7593aeb24f2ad122e"}, + {file = "mmh3-5.2.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d765058da196f68dc721116cab335e696e87e76720e6ef8ee5a24801af65e63d"}, + {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8b0c53fe0994beade1ad7c0f13bd6fec980a0664bfbe5a6a7d64500b9ab76772"}, + {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:49037d417419863b222ae47ee562b2de9c3416add0a45c8d7f4e864be8dc4f89"}, + {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6ecb4e750d712abde046858ee6992b65c93f1f71b397fce7975c3860c07365d2"}, + {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:382a6bb3f8c6532ea084e7acc5be6ae0c6effa529240836d59352398f002e3fc"}, + {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7733ec52296fc1ba22e9b90a245c821adbb943e98c91d8a330a2254612726106"}, + {file = "mmh3-5.2.0-cp310-cp310-win32.whl", hash = "sha256:127c95336f2a98c51e7682341ab7cb0be3adb9df0819ab8505a726ed1801876d"}, + {file = "mmh3-5.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:419005f84ba1cab47a77465a2a843562dadadd6671b8758bf179d82a15ca63eb"}, + {file = "mmh3-5.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:d22c9dcafed659fadc605538946c041722b6d1104fe619dbf5cc73b3c8a0ded8"}, + {file = "mmh3-5.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7901c893e704ee3c65f92d39b951f8f34ccf8e8566768c58103fb10e55afb8c1"}, + {file = "mmh3-5.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5f5536b1cbfa72318ab3bfc8a8188b949260baed186b75f0abc75b95d8c051"}, + {file = "mmh3-5.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cedac4f4054b8f7859e5aed41aaa31ad03fce6851901a7fdc2af0275ac533c10"}, + {file = "mmh3-5.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eb756caf8975882630ce4e9fbbeb9d3401242a72528230422c9ab3a0d278e60c"}, + {file = "mmh3-5.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:097e13c8b8a66c5753c6968b7640faefe85d8e38992703c1f666eda6ef4c3762"}, + {file = "mmh3-5.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7c0c7845566b9686480e6a7e9044db4afb60038d5fabd19227443f0104eeee4"}, + {file = "mmh3-5.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61ac226af521a572700f863d6ecddc6ece97220ce7174e311948ff8c8919a363"}, + {file = "mmh3-5.2.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:582f9dbeefe15c32a5fa528b79b088b599a1dfe290a4436351c6090f90ddebb8"}, + {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ebfc46b39168ab1cd44670a32ea5489bcbc74a25795c61b6d888c5c2cf654ed"}, + {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1556e31e4bd0ac0c17eaf220be17a09c171d7396919c3794274cb3415a9d3646"}, + {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81df0dae22cd0da87f1c978602750f33d17fb3d21fb0f326c89dc89834fea79b"}, + {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:eba01ec3bd4a49b9ac5ca2bc6a73ff5f3af53374b8556fcc2966dd2af9eb7779"}, + {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9a011469b47b752e7d20de296bb34591cdfcbe76c99c2e863ceaa2aa61113d2"}, + {file = "mmh3-5.2.0-cp311-cp311-win32.whl", hash = "sha256:bc44fc2b886243d7c0d8daeb37864e16f232e5b56aaec27cc781d848264cfd28"}, + {file = "mmh3-5.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:8ebf241072cf2777a492d0e09252f8cc2b3edd07dfdb9404b9757bffeb4f2cee"}, + {file = "mmh3-5.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5f317a727bba0e633a12e71228bc6a4acb4f471a98b1c003163b917311ea9a9"}, + {file = "mmh3-5.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:384eda9361a7bf83a85e09447e1feafe081034af9dd428893701b959230d84be"}, + {file = "mmh3-5.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c9da0d568569cc87315cb063486d761e38458b8ad513fedd3dc9263e1b81bcd"}, + {file = "mmh3-5.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86d1be5d63232e6eb93c50881aea55ff06eb86d8e08f9b5417c8c9b10db9db96"}, + {file = "mmh3-5.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf7bee43e17e81671c447e9c83499f53d99bf440bc6d9dc26a841e21acfbe094"}, + {file = "mmh3-5.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7aa18cdb58983ee660c9c400b46272e14fa253c675ed963d3812487f8ca42037"}, + {file = "mmh3-5.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9d032488fcec32d22be6542d1a836f00247f40f320844dbb361393b5b22773"}, + {file = "mmh3-5.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1861fb6b1d0453ed7293200139c0a9011eeb1376632e048e3766945b13313c5"}, + {file = "mmh3-5.2.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:99bb6a4d809aa4e528ddfe2c85dd5239b78b9dd14be62cca0329db78505e7b50"}, + {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f8d8b627799f4e2fcc7c034fed8f5f24dc7724ff52f69838a3d6d15f1ad4765"}, + {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5995088dd7023d2d9f310a0c67de5a2b2e06a570ecfd00f9ff4ab94a67cde43"}, + {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1a5f4d2e59d6bba8ef01b013c472741835ad961e7c28f50c82b27c57748744a4"}, + {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fd6e6c3d90660d085f7e73710eab6f5545d4854b81b0135a3526e797009dbda3"}, + {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c4a2f3d83879e3de2eb8cbf562e71563a8ed15ee9b9c2e77ca5d9f73072ac15c"}, + {file = "mmh3-5.2.0-cp312-cp312-win32.whl", hash = "sha256:2421b9d665a0b1ad724ec7332fb5a98d075f50bc51a6ff854f3a1882bd650d49"}, + {file = "mmh3-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d80005b7634a3a2220f81fbeb94775ebd12794623bb2e1451701ea732b4aa3"}, + {file = "mmh3-5.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:3d6bfd9662a20c054bc216f861fa330c2dac7c81e7fb8307b5e32ab5b9b4d2e0"}, + {file = "mmh3-5.2.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:e79c00eba78f7258e5b354eccd4d7907d60317ced924ea4a5f2e9d83f5453065"}, + {file = "mmh3-5.2.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:956127e663d05edbeec54df38885d943dfa27406594c411139690485128525de"}, + {file = "mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c3dca4cb5b946ee91b3d6bb700d137b1cd85c20827f89fdf9c16258253489044"}, + {file = "mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e651e17bfde5840e9e4174b01e9e080ce49277b70d424308b36a7969d0d1af73"}, + {file = "mmh3-5.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:9f64bf06f4bf623325fda3a6d02d36cd69199b9ace99b04bb2d7fd9f89688504"}, + {file = "mmh3-5.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ddc63328889bcaee77b743309e5c7d2d52cee0d7d577837c91b6e7cc9e755e0b"}, + {file = "mmh3-5.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb0fdc451fb6d86d81ab8f23d881b8d6e37fc373a2deae1c02d27002d2ad7a05"}, + {file = "mmh3-5.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b29044e1ffdb84fe164d0a7ea05c7316afea93c00f8ed9449cf357c36fc4f814"}, + {file = "mmh3-5.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58981d6ea9646dbbf9e59a30890cbf9f610df0e4a57dbfe09215116fd90b0093"}, + {file = "mmh3-5.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e5634565367b6d98dc4aa2983703526ef556b3688ba3065edb4b9b90ede1c54"}, + {file = "mmh3-5.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0271ac12415afd3171ab9a3c7cbfc71dee2c68760a7dc9d05bf8ed6ddfa3a7a"}, + {file = "mmh3-5.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:45b590e31bc552c6f8e2150ff1ad0c28dd151e9f87589e7eaf508fbdd8e8e908"}, + {file = "mmh3-5.2.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bdde97310d59604f2a9119322f61b31546748499a21b44f6715e8ced9308a6c5"}, + {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc9c5f280438cf1c1a8f9abb87dc8ce9630a964120cfb5dd50d1e7ce79690c7a"}, + {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c903e71fd8debb35ad2a4184c1316b3cb22f64ce517b4e6747f25b0a34e41266"}, + {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:eed4bba7ff8a0d37106ba931ab03bdd3915fbb025bcf4e1f0aa02bc8114960c5"}, + {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1fdb36b940e9261aff0b5177c5b74a36936b902f473180f6c15bde26143681a9"}, + {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7303aab41e97adcf010a09efd8f1403e719e59b7705d5e3cfed3dd7571589290"}, + {file = "mmh3-5.2.0-cp313-cp313-win32.whl", hash = "sha256:03e08c6ebaf666ec1e3d6ea657a2d363bb01effd1a9acfe41f9197decaef0051"}, + {file = "mmh3-5.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:7fddccd4113e7b736706e17a239a696332360cbaddf25ae75b57ba1acce65081"}, + {file = "mmh3-5.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa0c966ee727aad5406d516375593c5f058c766b21236ab8985693934bb5085b"}, + {file = "mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e5015f0bb6eb50008bed2d4b1ce0f2a294698a926111e4bb202c0987b4f89078"}, + {file = "mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0f3ed828d709f5b82d8bfe14f8856120718ec4bd44a5b26102c3030a1e12501"}, + {file = "mmh3-5.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:f35727c5118aba95f0397e18a1a5b8405425581bfe53e821f0fb444cbdc2bc9b"}, + {file = "mmh3-5.2.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bc244802ccab5220008cb712ca1508cb6a12f0eb64ad62997156410579a1770"}, + {file = "mmh3-5.2.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ff3d50dc3fe8a98059f99b445dfb62792b5d006c5e0b8f03c6de2813b8376110"}, + {file = "mmh3-5.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:37a358cc881fe796e099c1db6ce07ff757f088827b4e8467ac52b7a7ffdca647"}, + {file = "mmh3-5.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b9a87025121d1c448f24f27ff53a5fe7b6ef980574b4a4f11acaabe702420d63"}, + {file = "mmh3-5.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ba55d6ca32eeef8b2625e1e4bfc3b3db52bc63014bd7e5df8cc11bf2b036b12"}, + {file = "mmh3-5.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9ff37ba9f15637e424c2ab57a1a590c52897c845b768e4e0a4958084ec87f22"}, + {file = "mmh3-5.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a094319ec0db52a04af9fdc391b4d39a1bc72bc8424b47c4411afb05413a44b5"}, + {file = "mmh3-5.2.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5584061fd3da584659b13587f26c6cad25a096246a481636d64375d0c1f6c07"}, + {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecbfc0437ddfdced5e7822d1ce4855c9c64f46819d0fdc4482c53f56c707b935"}, + {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7b986d506a8e8ea345791897ba5d8ba0d9d8820cd4fc3e52dbe6de19388de2e7"}, + {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:38d899a156549da8ef6a9f1d6f7ef231228d29f8f69bce2ee12f5fba6d6fd7c5"}, + {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d86651fa45799530885ba4dab3d21144486ed15285e8784181a0ab37a4552384"}, + {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c463d7c1c4cfc9d751efeaadd936bbba07b5b0ed81a012b3a9f5a12f0872bd6e"}, + {file = "mmh3-5.2.0-cp314-cp314-win32.whl", hash = "sha256:bb4fe46bdc6104fbc28db7a6bacb115ee6368ff993366bbd8a2a7f0076e6f0c0"}, + {file = "mmh3-5.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c7f0b342fd06044bedd0b6e72177ddc0076f54fd89ee239447f8b271d919d9b"}, + {file = "mmh3-5.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:3193752fc05ea72366c2b63ff24b9a190f422e32d75fdeae71087c08fff26115"}, + {file = "mmh3-5.2.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:69fc339d7202bea69ef9bd7c39bfdf9fdabc8e6822a01eba62fb43233c1b3932"}, + {file = "mmh3-5.2.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:12da42c0a55c9d86ab566395324213c319c73ecb0c239fad4726324212b9441c"}, + {file = "mmh3-5.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7f9034c7cf05ddfaac8d7a2e63a3c97a840d4615d0a0e65ba8bdf6f8576e3be"}, + {file = "mmh3-5.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:11730eeb16dfcf9674fdea9bb6b8e6dd9b40813b7eb839bc35113649eef38aeb"}, + {file = "mmh3-5.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:932a6eec1d2e2c3c9e630d10f7128d80e70e2d47fe6b8c7ea5e1afbd98733e65"}, + {file = "mmh3-5.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ca975c51c5028947bbcfc24966517aac06a01d6c921e30f7c5383c195f87991"}, + {file = "mmh3-5.2.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b0b58215befe0f0e120b828f7645e97719bbba9f23b69e268ed0ac7adde8645"}, + {file = "mmh3-5.2.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29c2b9ce61886809d0492a274a5a53047742dea0f703f9c4d5d223c3ea6377d3"}, + {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a367d4741ac0103f8198c82f429bccb9359f543ca542b06a51f4f0332e8de279"}, + {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5a5dba98e514fb26241868f6eb90a7f7ca0e039aed779342965ce24ea32ba513"}, + {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:941603bfd75a46023807511c1ac2f1b0f39cccc393c15039969806063b27e6db"}, + {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:132dd943451a7c7546978863d2f5a64977928410782e1a87d583cb60eb89e667"}, + {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f698733a8a494466432d611a8f0d1e026f5286dee051beea4b3c3146817e35d5"}, + {file = "mmh3-5.2.0-cp314-cp314t-win32.whl", hash = "sha256:6d541038b3fc360ec538fc116de87462627944765a6750308118f8b509a8eec7"}, + {file = "mmh3-5.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e912b19cf2378f2967d0c08e86ff4c6c360129887f678e27e4dde970d21b3f4d"}, + {file = "mmh3-5.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e7884931fe5e788163e7b3c511614130c2c59feffdc21112290a194487efb2e9"}, + {file = "mmh3-5.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3c6041fd9d5fb5fcac57d5c80f521a36b74aea06b8566431c63e4ffc49aced51"}, + {file = "mmh3-5.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:58477cf9ef16664d1ce2b038f87d2dc96d70fe50733a34a7f07da6c9a5e3538c"}, + {file = "mmh3-5.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:be7d3dca9358e01dab1bad881fb2b4e8730cec58d36dd44482bc068bfcd3bc65"}, + {file = "mmh3-5.2.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:931d47e08c9c8a67bf75d82f0ada8399eac18b03388818b62bfa42882d571d72"}, + {file = "mmh3-5.2.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dd966df3489ec13848d6c6303429bbace94a153f43d1ae2a55115fd36fd5ca5d"}, + {file = "mmh3-5.2.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c677d78887244bf3095020b73c42b505b700f801c690f8eaa90ad12d3179612f"}, + {file = "mmh3-5.2.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63830f846797187c5d3e2dae50f0848fdc86032f5bfdc58ae352f02f857e9025"}, + {file = "mmh3-5.2.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c3f563e8901960e2eaa64c8e8821895818acabeb41c96f2efbb936f65dbe486c"}, + {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:96f1e1ac44cbb42bcc406e509f70c9af42c594e72ccc7b1257f97554204445f0"}, + {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7bbb0df897944b5ec830f3ad883e32c5a7375370a521565f5fe24443bfb2c4f7"}, + {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:1fae471339ae1b9c641f19cf46dfe6ffd7f64b1fba7c4333b99fa3dd7f21ae0a"}, + {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:aa6e5d31fdc5ed9e3e95f9873508615a778fe9b523d52c17fc770a3eb39ab6e4"}, + {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:746a5ee71c6d1103d9b560fa147881b5e68fd35da56e54e03d5acefad0e7c055"}, + {file = "mmh3-5.2.0-cp39-cp39-win32.whl", hash = "sha256:10983c10f5c77683bd845751905ba535ec47409874acc759d5ce3ff7ef34398a"}, + {file = "mmh3-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:fdfd3fb739f4e22746e13ad7ba0c6eedf5f454b18d11249724a388868e308ee4"}, + {file = "mmh3-5.2.0-cp39-cp39-win_arm64.whl", hash = "sha256:33576136c06b46a7046b6d83a3d75fbca7d25f84cec743f1ae156362608dc6d2"}, + {file = "mmh3-5.2.0.tar.gz", hash = "sha256:1efc8fec8478e9243a78bb993422cf79f8ff85cb4cf6b79647480a31e0d950a8"}, +] + +[package.extras] +benchmark = ["pymmh3 (==0.0.5)", "pyperf (==2.9.0)", "xxhash (==3.5.0)"] +docs = ["myst-parser (==4.0.1)", "shibuya (==2025.7.24)", "sphinx (==8.2.3)", "sphinx-copybutton (==0.5.2)"] +lint = ["black (==25.1.0)", "clang-format (==20.1.8)", "isort (==6.0.1)", "pylint (==3.3.7)"] +plot = ["matplotlib (==3.10.3)", "pandas (==2.3.1)"] +test = ["pytest (==8.4.1)", "pytest-sugar (==1.0.0)"] +type = ["mypy (==1.17.0)"] + [[package]] name = "msgpack" version = "1.1.0" @@ -3550,6 +3771,22 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa typing = ["typing-extensions ; python_version < \"3.10\""] xmp = ["defusedxml"] +[[package]] +name = "platformdirs" +version = "3.11.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, + {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, +] + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] + [[package]] name = "pluggy" version = "1.5.0" @@ -4890,6 +5127,18 @@ files = [ {file = "ruff-0.9.9.tar.gz", hash = "sha256:0062ed13f22173e85f8f7056f9a24016e692efeea8704d1a5e8011b8aa850933"}, ] +[[package]] +name = "semver" +version = "3.0.4" +description = "Python helper for Semantic Versioning (https://semver.org)" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"}, + {file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"}, +] + [[package]] name = "sentry-sdk" version = "2.39.0" @@ -5478,6 +5727,47 @@ files = [ ] markers = {typing = "sys_platform == \"win32\""} +[[package]] +name = "tzlocal" +version = "5.3.1" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, + {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] + +[[package]] +name = "unleashclient" +version = "6.4.0" +description = "Python client for the Unleash feature toggle system!" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "unleashclient-6.4.0-py3-none-any.whl", hash = "sha256:e0b80fffbda50427115707cf042611ad130b5ed3b01611301f06a9fc8981eea7"}, + {file = "unleashclient-6.4.0.tar.gz", hash = "sha256:54952e5d64c05835e44315efd17e8ba0e7815e1cc431ba510d6c1928c363b003"}, +] + +[package.dependencies] +apscheduler = "<4.0.0" +fcache = "*" +importlib_metadata = "*" +launchdarkly-eventsource = "*" +mmh3 = "*" +python-dateutil = "*" +requests = "*" +semver = "<4.0.0" +yggdrasil-engine = ">=1.0.0" + [[package]] name = "uritemplate" version = "4.1.1" @@ -5800,6 +6090,64 @@ idna = ">=2.0" multidict = ">=4.0" propcache = ">=0.2.0" +[[package]] +name = "yggdrasil-engine" +version = "1.0.0" +description = "Engine for evaluating Unleash feature flags" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "yggdrasil_engine-1.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a1ed0b4d8d1702b3e6a1f454f89be2fdac544ff1b7d979afa62224377ae0cf6e"}, + {file = "yggdrasil_engine-1.0.0-cp310-abi3-macosx_11_0_x86_64.whl", hash = "sha256:9ff016dad3f6972d79dc121625df4c5ea35e8f69519de3873a408daf0b65bd54"}, + {file = "yggdrasil_engine-1.0.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:de577c8abd064861423b55d245e16770927332b1d3a6ef2e94ccb4b5d759349b"}, + {file = "yggdrasil_engine-1.0.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:b1dece26decdcf1fc061bc9a3771618d00bf9431466cfe2b952151f92814d169"}, + {file = "yggdrasil_engine-1.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:938f1a88c801f4ccc6289dced4033cb05a6f7042f373581ce7d8a999da6b82d0"}, + {file = "yggdrasil_engine-1.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6386ec0c0fd0a9c196e54b9cd36b1edecb0233a618496210cb70f914eae593b9"}, + {file = "yggdrasil_engine-1.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:345c6003f07480fcf6d7c5ba8522de2cbafa4521bbc42013ceaeef34c2395a74"}, + {file = "yggdrasil_engine-1.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:52f648a83049c3ff29bc2d8f6064c745895e38d021051948fab55a041d0d1195"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:cecae8456c3b45e71a84e7e5c9467fb26343c27a86c7b9b53c080959f5bf4092"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:8f1ff08afa2a2c6b1b856439078a473abca275223636630fb7a67108e587fe79"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-manylinux2014_aarch64.whl", hash = "sha256:447f2beb4c743db01aa8ae26eb38b5cd314aaa664388361006456ca6735b5a42"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-manylinux2014_x86_64.whl", hash = "sha256:9c784fe3d47e88f4f059d59d47ea0e64c9e352d1e5e8b91efdaba36c3509d94d"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cf325a422b1fb1033999b6dffac3ed211e1dabe4c7e5815ce3ce3d46819eff8b"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f52b362ec2c5f9e192de99877b8e801b82a46ffe2ae565d7b676c549b1b88ad1"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e9adb857705bf646541124527b049c4cca17b82de9c0390ab51c8f7ab97474e1"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:33a11e16f9436425896faf6cdff58eab43b0cd2979011c7aff63e3e919c8888a"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:b92167283a0c4b04f38f738a04a990f01176a075f6a8af3e62d7174f34646fd1"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-macosx_11_0_x86_64.whl", hash = "sha256:5d190184f3feace6112a220d47052398432cf3aa6eb33efe06dcfcb9b06a3d6d"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-manylinux2014_aarch64.whl", hash = "sha256:90d627ca958eefebeb8d183b7f53cf32f1bc0e558e600ce3334ecd56bbe2b1ef"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-manylinux2014_x86_64.whl", hash = "sha256:0a86e2cd08225bd76e7df64f1a41582161b1ac19317f41f68175b6b79bc281ae"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a50704d10ed1054047eb87bca85c348f4c9cba35d6bff9058c2c487739c3aa3c"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:770509f97e27228dca593663f16d07cbd08f61535fe2f3203b725ff144e191ed"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-win_amd64.whl", hash = "sha256:8438548e05525eaf4f770918a836e04829fdaee32e14da6f2ff7e8b3f2512d53"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-win_arm64.whl", hash = "sha256:69c0ec4ab606fad5dd8ff96b565af4c3a9c5dd1fd6844e549ea0bf3db4a2f763"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:90f9336a24a9e09fe211eb6079e5c4a9e2cc245e90842356be511bd7bc73c594"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-macosx_11_0_x86_64.whl", hash = "sha256:d0cabfd198b1ac927d0c56d0d667ab9497acb8b60351ff45a4000214c79a54a0"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-manylinux2014_aarch64.whl", hash = "sha256:21340f75a9283bfc1feb78ba071703f432e77ca1fcd32fe1a28a5d1314a8a39d"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-manylinux2014_x86_64.whl", hash = "sha256:2cac50c85887a6c078ca6d3792d2b7241539d6e385648006ca8255cd20fce431"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3eb8bc69111e1f3d10649504a33b6a6677cd932037c6c7e77f12c80c1d8b3c63"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fe9d2ef59ebdbfbbc689b440c45283f3c385b7b5e1ee637618d8e279fc41301"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-win_amd64.whl", hash = "sha256:40131927c89f8f8bfc66e01f4b8e9168433b64c23b8a3ea1614bdddc0ca3ae14"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-win_arm64.whl", hash = "sha256:d5830f797a2309a99fdf49fe469a52ad5bb0bbb22ee68b409d47a07c72e5f5b3"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:cafb7e5bb3cb4143b2fd83f99250bd23f8c4d9f9c60f1fd8783c1615a4bdfcea"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-macosx_11_0_x86_64.whl", hash = "sha256:7aafb572c6d7ddbdd9724ad494f53add883bdd1def24a63e175854d5c76dd26e"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:4d73c51519455b7758f551982ba6f5ede70990b51da9e9c070cef2afcd2f36ce"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:1581d90fb52497cbd7a0eee54d8f325139c5f76d90a3931dbd42d9d0c0209d83"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b6b7a28e81e09c2719241b41ec63ff0acda00c2f2b5a54e83d703101440e41c1"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ab486a8401e3e0358fa75ee183dc9096443df979602c156a70a2259884f5f856"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:816fcf92995ab9725968746ad20258bb1d87602d2d2ebf14bc0b56aa405ab03e"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:2a8f833cdd555c33747532792cf0cc6620dca9bb6a693ab1ff8dea375a2d9970"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:4b1765db6b31cd1d7bef5821493834003df50121604907bce82e84d3dc0c80d9"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ee9ebcf0b206ce8959147d8b7faeab1833c0587e2573f8f3d6dd4a98605b8d97"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:42a199714bab52c9e22108a74b1b4cfdefd5f451139024cc6d8828111835189d"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:f3132700301f2a7f0cb3509fda9d159db2fb28437ae1e1bd214604520ad10905"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25503d8711ef3d009707be73eaaa500646b90ecd88085b4c2cbe6f2a176c3986"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6d4ebaa62898e2e59da960ab6e24214d2b06312f36e5199fb9d22715b46224db"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:5eb80a90d002d3f32e7ecf0743699818c84b39bfd349be20ab780177dc113c33"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:8be8bdd4e2ba82febe493c70fa5d4c1a6235c8f250f62db853ec3f4bcbdb2e63"}, +] + [[package]] name = "yookassa" version = "2.5.0" @@ -5818,6 +6166,26 @@ netaddr = "*" requests = "*" urllib3 = "*" +[[package]] +name = "zipp" +version = "3.23.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, + {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + [[package]] name = "zope-interface" version = "7.2" @@ -5876,4 +6244,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "edd413e469f523e2a363dd9b28c0a4aeea799ac371f2995b0b327c933a456b56" +content-hash = "fd3ef389880458b7761823ce581a3e67518ed434d916f4e69ff4ea7be6eb5fe4" @@ -63,6 +63,8 @@ httptools = "^0.6.4" wsproto = "^1.2.0" sentry-sdk = {extras = ["django"], version = "^2.39.0"} googletrans = "^4.0.2" +python-dateutil = "^2.9.0.post0" +unleashclient = "^6.4.0" [tool.poetry.group.test.dependencies]