@@ -173,4 +173,15 @@ class EmailService: cls.send_email( 'Отмена подписки на платформе AIR', html_message, (email,) ) + logger.info('Revoke recurring email sent: email=%s', email) + + @classmethod + def send_failed_recurring_charge_email(cls, email: str) -> None: + html_message = cls._render_letter_template( + template_name='payments/failed_recurring_charge_email', context={} + ) + cls.send_email( + 'Не удалось списать оплату', html_message, (email,) + ) + logger.info('Failed recurring charge email sent: email=%s', email) @@ -39,6 +39,8 @@ from authentication.services.email_service import EmailService from authentication.services.utm_service import UTMService from authentication.utils import get_client_ip from ml_model.services.minio_service import MinIOService +from payments.models import PaymentPlanUserInfo +from payments.selectors.payment_plan_selector import PaymentPlanSelector from payments.services.payment_method_service import PaymentMethodService from payments.services.referral_account import ReferralAccountService @@ -277,7 +279,15 @@ class UserService: if self.user.account_type in ('business_admin', 'business_security', 'business_account'): self.user.business_account.delete() - PaymentMethodService(self.user).delete_payment_method() + PaymentMethodService(self.user).deactivate_payment_methods() + free_plan = PaymentPlanSelector(self.user).get_free_plan( + corporate=self.user.payment_plan.plan.is_corporate + ) + PaymentPlanUserInfo.objects.filter(user=self.user).update( + next_payment_at=None, + plan_id=free_plan.pk, + current_token_balance=0, + ) self.user.is_deleted = True self.user.is_confirmed = False @@ -1,7 +1,8 @@ # Authentication mapper -from django.db.models import Prefetch +from django.db.models import Count, Prefetch, Q from payments.models.payment_plan_feature import PaymentPlanFeature +from payments.models.user_payment_method import PaymentMethod def _gen_only(chain: str, *fields: str): @@ -19,10 +20,8 @@ PATH_PREFETCH_MAP = { 'business_account__parent_company__company_companyipwhitelist', 'business_account__parent_company__user__payment_plan', 'business_account__parent_company__user__payment_plan__plan', - 'business_account__parent_company__user__payment_plan__method', 'payment_plan', 'payment_plan__plan', - 'payment_plan__method', ), 'prefetch': ( Prefetch( @@ -43,6 +42,16 @@ PATH_PREFETCH_MAP = { 'model__uid', ), ), + Prefetch( + 'payment_plan__methods', + queryset=PaymentMethod.objects.filter(primary=True, active=True).annotate( + total_attempts=Count( + 'payment_attempts', + filter=Q(payment_attempts__in_cycle=True), + ) + ), + to_attr='primary_methods', + ), 'social_auth', ), 'only': ( @@ -89,7 +98,6 @@ PATH_PREFETCH_MAP = { 'uid', 'last_payment_at', 'next_payment_at', - 'method_id', ), *_gen_only( 'payment_plan__plan', @@ -104,7 +112,6 @@ PATH_PREFETCH_MAP = { 'uid', 'last_payment_at', 'next_payment_at', - 'method_id', ), *_gen_only( 'business_account__parent_company__user__payment_plan__plan', @@ -167,7 +174,6 @@ PATH_PREFETCH_MAP = { 'business_account__parent_company__company_companyipwhitelist', 'payment_plan', 'payment_plan__plan', - 'payment_plan__method', ), 'prefetch': (), 'only': ( @@ -186,7 +192,6 @@ PATH_PREFETCH_MAP = { ), *_gen_only('payment_plan', 'uid'), *_gen_only('payment_plan__plan', 'uid', 'price'), - *_gen_only('payment_plan__method', 'uid'), ), }, } @@ -3,6 +3,7 @@ from typing import Any import jwt from django.conf import settings +from django.db.models import Count, Prefetch, Q from django.http import HttpRequest from django.utils.translation import gettext as _ from drf_spectacular.contrib.rest_framework_simplejwt import ( @@ -18,6 +19,7 @@ from authentication.mapper import PATH_PREFETCH_MAP from authentication.models import BusinessUserHost, CustomUserModel from authentication.services.token import TokenService from authentication.utils import get_client_ip +from payments.models.user_payment_method import PaymentMethod logger = logging.getLogger(__name__) @@ -48,15 +50,30 @@ class JWTAuthentication(BaseAuthentication): except Exception as exc: raise AuthenticationFailed(_('Access token invalid'), code='token_invalid') from exc try: - user = CustomUserModel.objects.select_related( - 'payment_plan', - 'payment_plan__plan', - 'business_account', - 'business_account__group', - 'business_account__parent_company__user__payment_plan', - 'business_account__parent_company__user__payment_plan__plan', - 'host_account', - ).get(uid=payload['uid']) + user = ( + CustomUserModel.objects.select_related( + 'payment_plan', + 'payment_plan__plan', + 'business_account', + 'business_account__group', + 'business_account__parent_company__user__payment_plan', + 'business_account__parent_company__user__payment_plan__plan', + 'host_account', + ) + .prefetch_related( + Prefetch( + 'payment_plan__methods', + queryset=PaymentMethod.objects.filter(primary=True, active=True).annotate( + total_attempts=Count( + 'payment_attempts', + filter=Q(payment_attempts__in_cycle=True), + ) + ), + to_attr='primary_methods', + ) + ) + .get(uid=payload['uid']) + ) _check_ip_client(user, request) except CustomUserModel.DoesNotExist as exc: raise AuthenticationFailed(_('User not found'), code='user_not_found') from exc @@ -490,7 +490,9 @@ UNLEASH_INSTANCE_ID = env.str('UNLEASH_INSTANCE_ID', '') UNLEASH_WEBHOOK_SECRET_KEY = env.str('UNLEASH_WEBHOOK_SECRET_KEY', 'defaultsecretkey') # RECURRING SETTINGS -MAX_RECURRING_ATTEMPTS = env.int('MAX_RECURRING_ATTEMPTS', 1) +RECURRING_RETRY_OFFSETS = env.list('RECURRING_RETRY_OFFSETS', default=[1, 3, 5, 8, 12, 16, 21, 28], subcast=int) +RECURRING_FULL_ACCESS_CUTOFF_DAY = env.int('RECURRING_FULL_ACCESS_CUTOFF_DAY', 4) +RECURRING_FAILED_CHARGE_EMAIL_DAYS = env.list('RECURRING_FAILED_CHARGE_EMAIL_DAYS', default=[3], subcast=int) # SSE STREAMING FF__STREAMING_ENABLED = env.bool('FF__STREAMING_ENABLED', False) @@ -9,6 +9,7 @@ from authentication.selectors.user_selector import UserSelector from ml_model.exceptions import NeuronModelNotExist from ml_model.models import ModelParameter, NeuronModel from ml_model.serializers import NeuronModelSerializer, NeuronModelsSerializer +from payments.services.payment_plan_service import PaymentPlanService from tools.chats.models import Chat from tools.media.models import Image, Video @@ -95,7 +96,8 @@ class NeuronModelSelector: return models def get_model_accessible_status(self, model: NeuronModel) -> bool: - in_plan = model in self.user.plan.accessed_models + plan = PaymentPlanService(self.user).get_plan_via_access() + in_plan = model in plan.accessed_models if self.user.account_type == 'business_account': return model.title in self.user.employee.parent_company.allowed_models and in_plan return in_plan @@ -35,6 +35,7 @@ from ml_model.services.base import StreamSimpleService from ml_model.services.openai_stream_mixin import OpenAIStreamMixin from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.services.payment_plan_service import PaymentPlanService from poller.models import Proxy from tools.chats.domain import RawSSEChunk @@ -340,7 +341,7 @@ class Chatgpt(Chatgpt_4, StreamSimpleService, OpenAIStreamMixin): predicted_input_price = Decimal(0) chunks: list[HumanMessage] = [] text_chunks: list[str] = [] - is_free_plan = self.store.user.plan.price <= 0 + is_free_plan = not PaymentPlanService(self.store.user).has_full_access() if is_free_plan: info.pop('code_interpreter', None) info.pop('verbosity', None) @@ -10,6 +10,7 @@ from langchain_core.messages import BaseMessage from messages.models import Message from ml_model.exceptions import ModelVersionNotAvailable, PaidPlanRequiredError from ml_model.services.chatgpt import Chatgpt +from payments.services.payment_plan_service import PaymentPlanService from poller.models import Proxy from tools.chats.domain import RawSSEChunk @@ -111,7 +112,7 @@ class Chatgpt_5_4(Chatgpt): model_name = input_message.info.get('version', self.BASE_VERSION) if model_name is None or model_name not in self.TOKENS_COST: raise ModelVersionNotAvailable(model_name, self.TOKENS_COST) - if self.store.user.plan.price <= 0 and model_name == 'gpt-5.4-pro': + if not PaymentPlanService(self.store.user).has_full_access() and model_name == 'gpt-5.4-pro': raise PaidPlanRequiredError('ChatGPT 5.4 PRO') if model_name == 'gpt-5.4-pro' and input_message.info.get('code_interpreter'): payload_message = copy(input_message) @@ -25,6 +25,7 @@ from ml_model.services.base import StreamSimpleService from ml_model.services.serper_mixin import SerperMixin from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.services.payment_plan_service import PaymentPlanService from poller.models import Proxy from tools.chats.domain import RawSSEChunk from tools.chats.models import Chat @@ -224,7 +225,7 @@ class Claude(SerperMixin, StreamSimpleService): if ( version_slug == 'claude-fable-5' and input_message.file - and self.store.user.plan.price > 0 + and PaymentPlanService(self.store.user).has_full_access() ): current_user_balance = PaymentPlanSelector(self.store.user).get_current_balance() if current_user_balance < (cost := Decimal('100')): @@ -240,7 +241,7 @@ class Claude(SerperMixin, StreamSimpleService): if version_slug == 'claude-fable-5': messages.insert(1, {'role': 'system', 'content': self.FABLE_SYSTEM_PROMPT}) - is_free_plan = self.store.user.plan.price <= 0 + is_free_plan = not PaymentPlanService(self.store.user).has_full_access() current_user_balance = PaymentPlanSelector(self.store.user).get_current_balance() is_low_balance = current_user_balance < Decimal('100') embedding_tokens = 0 @@ -19,6 +19,7 @@ from ml_model.exceptions import GenerationException, ModelVersionNotAvailable from ml_model.services.base import SimpleService from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.services.payment_plan_service import PaymentPlanService from ml_model.tasks import bytedance_model_ark_run, stream_bytedance_model_ark_run from tools.chats.domain import RawSSEChunk from tools.chats.models import Chat @@ -242,7 +243,7 @@ class Dola_Seed(SimpleService): predicted_input_price = ( predicted_input_tokens * price_map[prompt_type]['input'] / Decimal('1_000_000') ) - is_free_plan = self.store.user.plan.price <= 0 + is_free_plan = not PaymentPlanService(self.store.user).has_full_access() current_user_balance = PaymentPlanSelector(self.store.user).get_current_balance() max_output_tokens = min( max( @@ -12,6 +12,7 @@ from ml_model.services.base import SimpleService from ml_model.tasks import bytedance_model_ark_run, stream_bytedance_model_ark_run from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.services.payment_plan_service import PaymentPlanService from tools.chats.domain import RawSSEChunk from tools.chats.models import Chat from tools.copywrite.models import Copywrite @@ -65,7 +66,7 @@ class Glm_4_7(SimpleService): predicted_input_price = ( predicted_input_tokens * self.TOKENS_COST[version]['input'] / Decimal('1_000_000') ) - is_free_plan = self.store.user.plan.price <= 0 + is_free_plan = not PaymentPlanService(self.store.user).has_full_access() current_user_balance = PaymentPlanSelector(self.store.user).get_current_balance() max_output_tokens = min( max( @@ -20,6 +20,7 @@ from ml_model.services.FileService import FileProcessingService from ml_model.services.serper_mixin import SerperMixin from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.services.payment_plan_service import PaymentPlanService from poller.models import Proxy from tools.chats.domain import RawSSEChunk from tools.chats.models import Chat @@ -163,7 +164,7 @@ class Grok(SerperMixin, StreamSimpleService): self, input_message: Message, version: str, callback_data: dict ) -> tuple[list[dict[str, str | list]], int]: messages = self.get_chat_history() - is_free_plan = self.store.user.plan.price <= 0 + is_free_plan = not PaymentPlanService(self.store.user).has_full_access() current_user_balance = PaymentPlanSelector(self.store.user).get_current_balance() is_low_balance = current_user_balance < Decimal('100') messages.append({'role': 'user', 'content': input_message.content}) @@ -17,6 +17,7 @@ from ml_model.services.base import SimpleService from ml_model.tasks import openrouter_run from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.services.payment_plan_service import PaymentPlanService from poller.models import Proxy from tools.chats.models import Chat from tools.copywrite.models import Copywrite @@ -76,7 +77,7 @@ class Grok_4_1_Fast(SimpleService): + len(chunks) * 2100 * self.TOOLS_TOKEN_COSTS['text-embedding-3-small']['output'] ).quantize(Decimal('0.1'), rounding='ROUND_UP') if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < predict_price: - if self.store.user.plan.price <= 0: + if not PaymentPlanService(self.store.user).has_full_access(): return self.save_results( content='Файл не удаётся обработать — его размер больше максимально допустимого ' 'для вашего тарифа. Для продолжения выберите план с увеличенным лимитом.', @@ -0,0 +1,110 @@ +# Generated by Django 5.0 on 2026-07-24 11:45 + +import uuid +from datetime import timedelta + +import django.db.models.deletion +from django.db import migrations, models +from django.utils import timezone + + +def forwards_link_methods_and_attempts(apps, schema_editor): + PaymentPlanUserInfo = apps.get_model('payments', 'PaymentPlanUserInfo') + PaymentMethod = apps.get_model('payments', 'PaymentMethod') + PaymentAttempt = apps.get_model('payments', 'PaymentAttempt') + Payment = apps.get_model('payments', 'Payment') + + linked = set() + rows = ( + PaymentPlanUserInfo.objects.exclude(method_id=None) + .values_list('pk', 'user_id', 'method_id', 'next_payment_at', 'method__attempts') + .iterator() + ) + for ppi_id, user_id, method_id, next_at, attempts in rows: + linked.add(method_id) + PaymentMethod.objects.filter(pk=method_id).update( + user_plan_info_id=ppi_id, + primary=True, + active=True, + ) + if attempts <= 0: + continue + + failed_at = ( + Payment.objects.filter(user_id=user_id, status='canceled') + .order_by('-created_at') + .values_list('created_at', flat=True) + .first() + ) or timezone.now() + attempt = PaymentAttempt.objects.create(method_id=method_id, in_cycle=True) + PaymentAttempt.objects.filter(pk=attempt.pk).update( + created_at=failed_at, + updated_at=failed_at, + ) + if next_at is not None: + PaymentPlanUserInfo.objects.filter(pk=ppi_id).update( + next_payment_at=timezone.now() + timedelta(days=2), + ) + + PaymentMethod.objects.exclude(pk__in=linked).delete() + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ('payments', '0030_remove_paymentplan_points'), + ] + + operations = [ + migrations.CreateModel( + name='PaymentAttempt', + 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='Изменён')), + ('cancel_reason', models.CharField(blank=True, max_length=50, null=True, verbose_name='Cancel Reason')), + ('in_cycle', models.BooleanField(default=True, verbose_name='In Cycle')), + ('method', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_attempts', to='payments.paymentmethod', verbose_name='Payment Method')), + ], + options={ + 'verbose_name': 'Payment Attempt', + 'verbose_name_plural': 'Payment Attempts', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='paymentmethod', + name='user_plan_info', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='methods', to='payments.paymentplanuserinfo', verbose_name='User Plan Info'), + ), + migrations.AddField( + model_name='paymentmethod', + name='active', + field=models.BooleanField(default=False, verbose_name='Active'), + ), + migrations.AddField( + model_name='paymentmethod', + name='primary', + field=models.BooleanField(default=True, verbose_name='Primary'), + ), + migrations.RunPython(forwards_link_methods_and_attempts, migrations.RunPython.noop), + migrations.RemoveField( + model_name='paymentmethod', + name='attempts', + ), + migrations.RemoveField( + model_name='paymentplanuserinfo', + name='method', + ), + migrations.AlterField( + model_name='paymentmethod', + name='user_plan_info', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='methods', + to='payments.paymentplanuserinfo', + verbose_name='User Plan Info', + ), + ), + ] @@ -0,0 +1,24 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core.models import BaseModel +from payments.models import PaymentMethod + + +class PaymentAttempt(BaseModel): + method = models.ForeignKey( + PaymentMethod, + on_delete=models.CASCADE, + verbose_name=_('Payment Method'), + related_name='payment_attempts', + ) + cancel_reason = models.CharField(max_length=50, blank=True, null=True, verbose_name=_('Cancel Reason')) + in_cycle = models.BooleanField(default=True, verbose_name=_('In Cycle')) + + def __str__(self) -> str: + return f'Attempt of ({self.method})\nReason: {self.cancel_reason}' + + class Meta: + verbose_name = _('Payment Attempt') + verbose_name_plural = _('Payment Attempts') + ordering = ('-created_at',) @@ -50,14 +50,6 @@ class PaymentPlanUserInfo(BaseModel): ) last_payment_at = models.DateField(verbose_name=_('Last 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') ) @@ -78,9 +70,15 @@ class PaymentPlanUserInfo(BaseModel): self.last_payment_at = datetime.now().date() return super().save(force_insert, force_update, using, update_fields) + @property + def primary_method(self): + return self.methods.filter(active=True, primary=True).first() + @property def is_recurring(self) -> bool: - return self.method is not None + if hasattr(self, 'primary_methods'): + return bool(self.primary_methods) + return self.methods.filter(active=True, primary=True).exists() def __str__(self) -> str: return f'{self.user.email or "Ошибка"}' @@ -1,7 +1,8 @@ -from django.db import models +from django.db import models, transaction from django.utils.translation import gettext_lazy as _ from core.models import BaseModel +from payments.models import PaymentPlanUserInfo class PaymentMethod(BaseModel): @@ -13,10 +14,30 @@ class PaymentMethod(BaseModel): T_BANK = ('tinkoff_bank', _('T-bank')) SBP = ('sbp', _('SBP')) + user_plan_info = models.ForeignKey( + PaymentPlanUserInfo, + on_delete=models.CASCADE, + verbose_name=_('User Plan Info'), + related_name='methods', + ) gateway = models.CharField(max_length=20, choices=GatewayChoices.choices, verbose_name=_('Gateway')) payment_method_id = models.UUIDField(unique=True, verbose_name=_('Payment method UID')) metadata = models.JSONField(verbose_name=_('Meta')) - attempts = models.PositiveSmallIntegerField(default=0, verbose_name=_('Attempts')) + active = models.BooleanField(default=False, verbose_name=_('Active')) + primary = models.BooleanField(default=True, verbose_name=_('Primary')) + + @property + def attempts(self): + return self.payment_attempts.filter(in_cycle=True).count() + + def save(self, *args, **kwargs): + with transaction.atomic(): + if self.primary: + self.active = True + self.__class__.objects.filter(user_plan_info=self.user_plan_info).exclude(pk=self.pk).update( + primary=False, active=False + ) + super().save(*args, **kwargs) def __str__(self) -> str: return f'{self.gateway} ({self.payment_method_id})' @@ -6,19 +6,18 @@ from collections import defaultdict from datetime import date, timedelta from decimal import Decimal -from django.utils import timezone +from django.db.models.aggregates import Count 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 import CharField, F, Func, Prefetch, Q, Sum, Value from django.db.models.functions import Round, TruncDay, TruncMonth, TruncYear 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 SyncAuthBearer, SyncAuthBearer -from payments.exceptions.payer_not_found import PayerNotFound +from authentication.security import SyncAuthBearer from authentication.exceptions.business_host_exceptions.access_denied import AccessDenied from payments.models import ( Invoice, @@ -36,6 +35,7 @@ from payments.schemas import ( PaymentPlanSchema, ) from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.services.payment_method_service import PaymentMethodService from payments.typing import IntervalStrategyEnum, SourceStrategyEnum from payments.services.payment_service import PaymentService @@ -63,9 +63,25 @@ def handle_yookassa_webhook(request): logger.info('YooKassa webhook received: payment_id=%s', data['object']['id']) try: payment = PaymentService.handle_payment(data['object']['id']) - payer = CustomUserModel.objects.prefetch_related( - 'payment_plan', 'payment_plan__plan', 'payment_plan__method' - ).get(uid=payment.description) + payer = ( + CustomUserModel.objects.select_related( + 'payment_plan', + 'payment_plan__plan', + ) + .prefetch_related( + Prefetch( + 'payment_plan__methods', + queryset=PaymentMethod.objects.filter(primary=True, active=True).annotate( + total_attempts=Count( + 'payment_attempts', + filter=Q(payment_attempts__in_cycle=True), + ) + ), + to_attr='primary_methods', + ) + ) + .get(uid=payment.description) + ) PaymentService(payer).do_payment(payment) logger.info( 'YooKassa webhook processed: payment_id=%s payer_email=%s status=%s', @@ -74,7 +90,11 @@ def handle_yookassa_webhook(request): payment.status, ) except CustomUserModel.DoesNotExist: - raise HttpError(400, str(PayerNotFound)) + logger.info( + 'YooKassa webhook payer not found, ack without retry: payment_id=%s description=%s', + payment.id, + payment.description, + ) except Exception as exc: logger.exception(exc) raise HttpError(400, f'{exc}') @@ -83,13 +103,13 @@ def handle_yookassa_webhook(request): @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() + deactivate_count = PaymentMethodService(request.auth).deactivate_payment_methods() logger.info( - 'Recurring payment revoked by user: email=%s deleted_methods=%s', + 'Recurring payment revoked by user: email=%s deactivated_methods=%s', request.auth.email, - deleted_count, + deactivate_count, ) - if deleted_count == 0: + if deactivate_count == 0: raise HttpError(400, _('You do not have an active subscription to cancel')) return 200, {'detail': _('The recurring payment is successfully cancelled')} @@ -227,32 +247,3 @@ def create_payment_link(request, body: NewSubscriptionSchema): raise HttpError(403, str(exc)) except Exception as exc: raise HttpError(400, f'{exc}') - - -@router.post('gitlab-webhook', tags=['payments/gitlab-webhook'], auth=None) -def handle_gitlab_webhook(request): - """ - Currently disabled, pending future feature flags - """ - # try: - # 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 = PaymentMethod.objects.all().delete() - # logger.info( - # 'Recurring feature disabled: all payment methods removed count=%s', - # deleted_methods_count, - # ) - # updated_count = PaymentPlanUserInfo.objects.filter( - # plan__price__gt=0, - # plan__individual=False, - # ).update(next_payment_at=None if not is_active else (timezone.now() + timedelta(days=30))) - # logger.info( - # 'Recurring feature flag synced: active=%s updated_subscriptions=%s', - # is_active, - # updated_count, - # ) - # except Exception as exc: - # logger.error(exc) - return 200 @@ -19,7 +19,7 @@ class PaymentPlanSelector: return balance def get_free_plan(self, corporate: bool = False) -> PaymentPlan: - return PaymentPlan.objects.get_or_create(price=0, is_corporate=corporate)[0] + return PaymentPlan.objects.get(price=0, is_corporate=corporate) def is_plan_paid(self) -> bool: return self.user.payment_plan.plan.price != Decimal('0') @@ -1,9 +1,12 @@ import logging from django.conf import settings -from django.db.models import F +from django.db import transaction +from django.db.models import Q from authentication.models import CustomUserModel +from authentication.services.email_service import EmailService +from payments.models.attempt import PaymentAttempt from payments.models.user_payment_method import PaymentMethod @@ -16,28 +19,31 @@ class PaymentMethodService: def add_payment_method(self, yookassa_payment_method): gateway = yookassa_payment_method.type - if yookassa_payment_method.type in ('bank_card', 'sberbank', 'tinkoff_bank'): + if gateway in ('bank_card', 'sberbank', 'tinkoff_bank'): metadata = { 'card_type': yookassa_payment_method.card.card_type, 'last4': yookassa_payment_method.card.last4, } if source := getattr(yookassa_payment_method.card, 'source', None): gateway = source - elif yookassa_payment_method.type == 'yoo_money': + elif gateway == 'yoo_money': metadata = {'account_number': yookassa_payment_method.account_number} - elif yookassa_payment_method.type == 'sbp': + elif gateway == 'sbp': metadata = {'sbp_operation_id': yookassa_payment_method.sbp_operation_id} else: metadata = {} payment_method, created = PaymentMethod.objects.update_or_create( - user_plan_info__user=self.user, + payment_method_id=yookassa_payment_method.id, defaults={ + 'user_plan_info': self.user.payment_plan, 'gateway': gateway, - 'payment_method_id': yookassa_payment_method.id, 'metadata': metadata, - 'attempts': 0, + 'primary': True, + 'active': True, }, ) + if not created: + payment_method.payment_attempts.filter(in_cycle=True).update(in_cycle=False) logger.info( 'Payment method saved: email=%s method_uid=%s payment_method_id=%s created=%s', self.user.email, @@ -47,27 +53,49 @@ class PaymentMethodService: ) return payment_method - def inc_attempts(self) -> int: - return PaymentMethod.objects.filter(user_plan_info__user=self.user).update( - attempts=F('attempts') + 1 + def inc_attempts(self, cancel_reason: str) -> PaymentAttempt | None: + pp = self.user.payment_plan + method = pp.primary_methods[0] if pp.primary_methods else None + if not method: + return None + payment_attempt = PaymentAttempt.objects.create(method=method, cancel_reason=cancel_reason) + logger.info( + 'PaymentAttempt created: email=%s method_uid=%s reason=%s attempt_number=%s', + self.user.email, + method.uid, + cancel_reason, + method.attempts, + ) + return payment_attempt + + @classmethod + def get_next_postpone_payment_at(cls, attempts: int) -> int: + return settings.RECURRING_RETRY_OFFSETS[attempts] - settings.RECURRING_RETRY_OFFSETS[attempts - 1] + + def compare_attempts_with_max(self, attempts: int) -> bool: + if attempts >= len(settings.RECURRING_RETRY_OFFSETS): + self.deactivate_payment_methods() + logger.info( + 'Recurring payment methods deactivated due to attempts limit: email=%s attempts=%s', + self.user.email, + attempts, + ) + return True + return False + + def deactivate_payment_methods(self) -> int: + deactivated = ( + PaymentMethod.objects.filter(user_plan_info=self.user.payment_plan) + .filter(Q(active=True) | Q(primary=True)) + .update(active=False, primary=False) ) + if deactivated: - def compare_attempts_with_max(self, attempts: int) -> None: - if attempts >= settings.MAX_RECURRING_ATTEMPTS: - deleted = self.delete_payment_method() - if deleted: - logger.info( - 'Recurring payment method deleted due to attempts limit: email=%s attempts=%s', - self.user.email, - attempts, - ) - else: - logger.info( - 'Recurring payment method delete skipped after attempts limit, ' - 'payment method not found: email=%s attempts=%s', - self.user.email, - attempts, - ) + def _send(): + try: + EmailService.send_revoke_recurring_email(self.user.email) + except Exception: + logger.exception('Failed to send revoke recurring email') - def delete_payment_method(self) -> int: - return PaymentMethod.objects.filter(user_plan_info__user=self.user).delete()[0] + transaction.on_commit(_send) + return deactivated @@ -1,8 +1,14 @@ import logging +from datetime import timedelta from decimal import Decimal +from django.conf import settings +from django.db.models import F +from django.utils import timezone + from authentication.models import CustomUserModel from payments.models import Invoice, PaymentPlan, PaymentPlanUserInfo +from payments.selectors.payment_plan_selector import PaymentPlanSelector from payments.services.model_billing_service import ModelBillingService from payments.services.payment_service import PaymentService @@ -44,3 +50,44 @@ class PaymentPlanService: ModelBillingService(self.user).charge(payment_amount) if model: return Invoice.objects.create(model=model, user=self.user, cost=payment_amount) + + def inc_next_payment_at(self, time_diff: timedelta) -> None: + PaymentPlanUserInfo.objects.filter(user=self.user).update( + next_payment_at=F('next_payment_at') + time_diff + ) + + def has_full_access(self) -> bool: + pp = self.user.payment_plan_details + plan = pp.plan + if plan.price <= 0: + return False + if plan.individual or plan.is_corporate or pp.next_payment_at is None: + return True + + if hasattr(pp, 'primary_methods'): + method = pp.primary_methods[0] if pp.primary_methods else None + attempts = method.total_attempts if method else None + else: + method = pp.primary_method + attempts = method.attempts if method else None + if method is None: + return True + + offsets = settings.RECURRING_RETRY_OFFSETS + if not 1 <= attempts < len(offsets): + return True + + cutoff = settings.RECURRING_FULL_ACCESS_CUTOFF_DAY + cur, nxt = offsets[attempts - 1], offsets[attempts] + if cutoff > nxt: + return True + if cutoff <= cur: + return False + return pp.next_payment_at > timezone.now() + timedelta(days=nxt - cutoff) + + def get_plan_via_access(self): + return ( + self.user.plan + if self.has_full_access() + else PaymentPlanSelector(self.user).get_free_plan(corporate=self.user.plan.is_corporate) + ) \ No newline at end of file @@ -13,6 +13,7 @@ from yookassa import Payment as YookassaPayment from yookassa.domain.response import PaymentResponse as YookassaPaymentResponse from authentication.models import CustomUserModel +from authentication.services.email_service import EmailService from payments.models.payment import Payment as PaymentModel from payments.models.payment_plan import PaymentPlan, PaymentPlanUserInfo from payments.services.payment_method_service import PaymentMethodService @@ -62,22 +63,20 @@ class PaymentService: from payments.services.payment_plan_service import PaymentPlanService with transaction.atomic(): - payment_instance = self.save_payment(payment) + payment_instance, should_process = self.save_payment(payment) + if not should_process: + return payment_instance 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 = ( - payment_instance.plan.tokens_per_plan - if payment.metadata.get('recurring') - else self.calculate_buying_tokens(payment_instance.plan) + if payment.status == 'succeeded': + buying_tokens = self._calculate_buying_tokens( + payment_instance.plan, payment.metadata.get('recurring', False) ) - self.handle_succeeded_payment(payment, payment_instance.plan) + 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) @@ -86,7 +85,7 @@ class PaymentService: and payment.metadata.get('recurring') and self.user.payment_plan.is_recurring ): - self.handle_canceled_payment(payment) + self._handle_canceled_payment(payment) return payment_instance @@ -94,21 +93,16 @@ class PaymentService: def handle_payment(cls, payment_id: UUID) -> YookassaPaymentResponse: return YookassaPayment.find_one(payment_id) - def handle_captured_payment(self, payment_id: UUID) -> None: - idempotency_key = hashlib.sha256(f'capture:{payment_id}'.encode('utf-8')).hexdigest() - YookassaPayment.capture(str(payment_id), idempotency_key=idempotency_key) - logger.info('Payment captured: payment_id=%s email=%s', payment_id, self.user.email) - - def calculate_buying_tokens(self, plan: PaymentPlan): - if self.user.payment_plan.plan.price == Decimal('0'): + def _calculate_buying_tokens(self, plan: PaymentPlan, recurring: bool = False): + if self.user.payment_plan.plan.price == Decimal('0') or recurring: return plan.tokens_per_plan return self.user.payment_plan.current_token_balance + plan.tokens_per_plan - def handle_succeeded_payment(self, payment: YookassaPaymentResponse, plan: PaymentPlan) -> None: + def _handle_succeeded_payment(self, payment: YookassaPaymentResponse, plan: PaymentPlan) -> None: if payment.payment_method.saved and not plan.individual: payment_method = PaymentMethodService(self.user).add_payment_method(payment.payment_method) PaymentPlanUserInfo.objects.filter(user=self.user).update( - method=payment_method, next_payment_at=timezone.now() + timedelta(days=30) + next_payment_at=timezone.now() + timedelta(days=30) ) logger.info( 'Recurring payment method saved: email=%s method_uid=%s next_payment_at_set=true', @@ -130,15 +124,17 @@ class PaymentService: 'Recurring schedule cleared: email=%s reason=individual_plan', self.user.email, ) - PaymentMethodService(self.user).delete_payment_method() + PaymentMethodService(self.user).deactivate_payment_methods() logger.info( - 'Recurring payment method deleted after succeeded payment: email=%s', self.user.email + 'Recurring payment methods deactivated after succeeded payment: email=%s', self.user.email ) - def handle_canceled_payment(self, payment: YookassaPaymentResponse) -> None: + def _handle_canceled_payment(self, payment: YookassaPaymentResponse) -> None: + from payments.services.payment_plan_service import PaymentPlanService + temporary_cancel_reasons = ( 'call_issuer', - 'expired_on_capture', + 'general_decline', 'insufficient_funds', 'internal_timeout', 'issuer_unavailable', @@ -146,46 +142,76 @@ class PaymentService: ) payment_method_service = PaymentMethodService(self.user) if payment.cancellation_details.reason in temporary_cancel_reasons: - updated = payment_method_service.inc_attempts() - if updated: - self.user.payment_plan.method.refresh_from_db(fields=['attempts']) + payment_attempt = payment_method_service.inc_attempts(payment.cancellation_details.reason) + if payment_attempt: + attempts = payment_attempt.method.total_attempts + 1 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_attempt.method.uid, + attempts, payment.cancellation_details.reason, ) - payment_method_service.compare_attempts_with_max( - attempts=self.user.payment_plan.method.attempts - ) + if ( + settings.RECURRING_RETRY_OFFSETS[attempts - 1] + in settings.RECURRING_FAILED_CHARGE_EMAIL_DAYS + ): + email = self.user.email + + def _send_failed_charge_email(): + try: + EmailService.send_failed_recurring_charge_email(email) + except Exception: + logger.exception('Failed to send failed recurring charge email') + + transaction.on_commit(_send_failed_charge_email) + deactivated = payment_method_service.compare_attempts_with_max(attempts=attempts) + if not deactivated: + postpone_days = payment_method_service.get_next_postpone_payment_at(attempts) + PaymentPlanService(self.user).inc_next_payment_at(timedelta(days=postpone_days)) else: logger.info( - 'Recurring payment retry skipped, payment method not found: email=%s', + 'Recurring payment retry skipped, no active primary payment method: email=%s', self.user.email, ) else: - deleted = payment_method_service.delete_payment_method() - if deleted: + deactivated = payment_method_service.deactivate_payment_methods() + if deactivated: logger.info( - 'Recurring payment method deleted after cancel: email=%s reason=%s', + 'Recurring payment methods deactivated after cancel: email=%s reason=%s', self.user.email, payment.cancellation_details.reason, ) else: logger.info( - 'Recurring payment method delete skipped, payment method not found: email=%s', + 'Recurring payment methods deactivate skipped, no active/primary methods to deactivate: email=%s', self.user.email, ) - def save_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: - plan = PaymentPlan.objects.get_or_none(uid=payment.metadata.get('plan_uid')) + def save_payment(self, payment: YookassaPaymentResponse) -> tuple[PaymentModel, bool]: + existing = PaymentModel.objects.filter(uid=payment.id).first() + if ( + existing + and existing.status == payment.status + and existing.status + in ( + PaymentModel.SUCCEEDED, + PaymentModel.CANCELLED, + ) + ): + logger.info( + 'Duplicate webhook skipped: payment_id=%s email=%s status=%s', + payment.id, + self.user.email, + payment.status, + ) + return existing, False payment_instance, created = PaymentModel.objects.update_or_create( uid=payment.id, defaults=dict( user=self.user, amount=payment.amount.value, - plan=plan, + plan_id=payment.metadata.get('plan_uid'), status=payment.status, description=payment.description, ), @@ -196,7 +222,7 @@ class PaymentService: self.user.email, payment.status, created, - plan.uid if plan else None, + payment.metadata.get('plan_uid'), payment.amount.value, ) - return payment_instance + return payment_instance, True @@ -0,0 +1,10 @@ + + + + + Не удалось списать оплату + + +

Не удалось списать оплату за подписку. Мы повторим попытку позже - вам ничего делать не нужно.

+ + @@ -18,6 +18,7 @@ from payments.models import ( PromoCodeActivation, PaymentMethod, ) +from payments.models.attempt import PaymentAttempt from payments.models.referral_account import ReferralAccount, ReferralInvite @@ -54,6 +55,12 @@ class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): search_fields = ['price', 'tokens_per_plan'] +class PaymentMethodInline(admin.TabularInline): + model = PaymentMethod + extra = 0 + show_change_link = True + + @admin.register(PaymentPlanUserInfo) class PaymentPlanUserInfoAdmin(admin.ModelAdmin): list_display = [ @@ -65,6 +72,8 @@ class PaymentPlanUserInfoAdmin(admin.ModelAdmin): 'updated_at', 'next_payment_at', ] + + inlines = [PaymentMethodInline] raw_id_fields = ['user'] search_fields = [ @@ -102,9 +111,43 @@ class PaymentPlanFeatureAdmin(OrderedModelAdmin): list_filter = ('plan', 'model__category') +class PaymentAttemptInline(admin.TabularInline): + model = PaymentAttempt + extra = 0 + show_change_link = True + fields = ('cancel_reason', 'in_cycle') + readonly_fields = ('created_at',) + + @admin.register(PaymentMethod) class PaymentMethodAdmin(admin.ModelAdmin): - list_display = ['gateway', 'payment_method_id', 'attempts'] + list_display = ['user_email', 'gateway', 'primary', 'active'] + list_filter = ['primary', 'active', 'gateway'] + inlines = [PaymentAttemptInline] + search_fields = ['user_plan_info__user__email'] + list_select_related = ['user_plan_info', 'user_plan_info__user'] + raw_id_fields = ['user_plan_info'] + + @admin.display(description=_('Email'), ordering='user_plan_info__user__email') + def user_email(self, obj: PaymentMethod): + return obj.user_plan_info.user.email + + +@admin.register(PaymentAttempt) +class PaymentAttemptAdmin(admin.ModelAdmin): + list_display = ('method_gateway', 'method_payment_method_id', 'cancel_reason', 'in_cycle') + list_filter = ('in_cycle',) + search_fields = ('method__user_plan_info__user__email',) + raw_id_fields = ('method',) + list_select_related = ('method', 'method__user_plan_info__user') + + @admin.display(description=_('Gateway'), ordering='method__gateway') + def method_gateway(self, obj: PaymentAttempt): + return obj.method.gateway + + @admin.display(description=_('Payment method UID'), ordering='method__payment_method_id') + def method_payment_method_id(self, obj: PaymentAttempt): + return obj.method.payment_method_id @admin.register(Invoice) @@ -30,7 +30,14 @@ class PaymentPlanSchema(Schema): individual: bool @staticmethod - def resolve_accessed_models(obj): + def resolve_accessed_models(obj, context): + request = context.get('request') if context else None + user = getattr(request, 'auth', None) if request else None + if user is not None: + from payments.services.payment_plan_service import PaymentPlanService + + plan = PaymentPlanService(user).get_plan_via_access() + return list(plan.accessed_models.values_list('slug', flat=True)) return list(obj.accessed_models.values_list('slug', flat=True)) @@ -1,14 +1,12 @@ import logging from typing import Type -from django.conf import settings -from django.db import transaction -from django.db.models.signals import post_save, pre_save, pre_delete +from django.db.models.signals import post_save from django.dispatch import receiver from authentication.models.user import CustomUserModel -from authentication.services.email_service import EmailService -from payments.models import PaymentPlan, PaymentMethod, PaymentPlanUserInfo +from payments.models import PaymentPlanUserInfo +from payments.services.payment_method_service import PaymentMethodService from payments.services.referral_account import ReferralAccountService logger = logging.getLogger(__name__) @@ -25,41 +23,11 @@ def init_referral_account( 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=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() - - -@receiver(pre_delete, sender=PaymentMethod) -def send_revoke_email_on_method_delete(sender: Type[PaymentMethod], instance: PaymentMethod, **kwargs): - email = ( - PaymentPlanUserInfo.objects.filter(method_id=instance.pk) - .values_list('user__email', flat=True) - .first() - ) - - def _send(): - try: - EmailService.send_revoke_recurring_email(email) - except Exception: - logger.exception('Failed to send revoke recurring email') - - transaction.on_commit(_send) + PaymentPlanUserInfo.objects.filter(pk=instance.pk).update(next_payment_at=None) + PaymentMethodService(instance.user).deactivate_payment_methods() \ No newline at end of file @@ -8,13 +8,13 @@ from celery import shared_task from celery.utils.log import get_task_logger from django.core.cache import cache from django.db import transaction -from django.db.models import F +from django.db.models import F, Prefetch 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 payments.models import PaymentPlan, PaymentPlanUserInfo +from payments.models import PaymentMethod, PaymentPlan, PaymentPlanUserInfo from payments.services.payment_plan_service import PaymentPlanService from yookassa import Payment as YookassaPayment @@ -44,33 +44,41 @@ def withdraw(user_id: UUID, amount: Decimal): @shared_task def execute_recurring_payments() -> None: overdue_payments = ( - PaymentPlanUserInfo.objects.select_related('user', 'plan', 'method') + PaymentPlanUserInfo.objects.select_related('user', 'plan') .filter( next_payment_at__isnull=False, next_payment_at__lte=timezone.now(), plan__price__gt=0, plan__individual=False, plan__is_corporate=False, - method__isnull=False, user__is_deleted=False, + methods__primary=True, + methods__active=True, + ) + .distinct() + .order_by('uid') + .prefetch_related( + Prefetch( + 'methods', + queryset=PaymentMethod.objects.filter(primary=True, active=True), + to_attr='primary_methods', + ) ) .only( 'uid', 'next_payment_at', 'user_id', 'plan_id', - 'method_id', 'user__uid', 'user__email', 'plan__uid', 'plan__price', 'plan__tokens_per_plan', - 'method__uid', - 'method__payment_method_id', - 'method__attempts', ) ) for overdue_payment in overdue_payments.iterator(chunk_size=CHUNK_SIZE): + if not overdue_payment.primary_methods: + continue customer = overdue_payment.user plan = overdue_payment.plan receipt_data = { @@ -86,7 +94,7 @@ def execute_recurring_payments() -> None: } payment_data = { 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, - 'payment_method_id': overdue_payment.method.payment_method_id, + 'payment_method_id': overdue_payment.primary_methods[0].payment_method_id, 'receipt': receipt_data, 'description': str(customer.uid), 'capture': True, @@ -100,7 +108,7 @@ def execute_recurring_payments() -> None: dt = timezone.make_aware(dt) period = dt.astimezone(dt_timezone.utc).replace(microsecond=0).isoformat() idempotency_key = hashlib.sha256( - f'recurring:{customer.uid}:{plan.uid}:{period}:{overdue_payment.method.attempts}'.encode('utf-8') + f'recurring:{customer.uid}:{plan.uid}:{period}'.encode('utf-8') ).hexdigest() YookassaPayment.create(payment_data, idempotency_key=idempotency_key) logger.info( @@ -108,7 +116,7 @@ def execute_recurring_payments() -> None: customer.email, plan.uid, plan.price, - overdue_payment.method.uid, + overdue_payment.primary_methods[0].uid, ) @@ -119,25 +127,37 @@ def revoke_recurring_payments() -> None: logger.info('Revoke recurring already running, skipping') return try: - qs = PaymentPlanUserInfo.objects.filter( - next_payment_at__isnull=False, - next_payment_at__lte=timezone.now(), - plan__price__gt=0, - plan__individual=False, - plan__is_corporate=False, - method__isnull=True, + qs = ( + PaymentPlanUserInfo.objects.filter( + next_payment_at__isnull=False, + next_payment_at__lte=timezone.now(), + plan__price__gt=0, + plan__individual=False, + plan__is_corporate=False, + ) + .exclude(methods__primary=True, methods__active=True) + .distinct() + .order_by('uid') ) canceled_count = 0 - free_regular_plan = PaymentPlan.objects.get_or_create(price=0, is_corporate=False)[0] + free_regular_plan = PaymentPlan.objects.get(price=0, is_corporate=False) qs_iter = qs.values_list('uid', flat=True).iterator(chunk_size=CHUNK_SIZE) while uids := list(islice(qs_iter, CHUNK_SIZE)): with transaction.atomic(): - canceled_count += qs.filter(uid__in=uids).update( - next_payment_at=None, - plan_id=free_regular_plan.pk, - current_token_balance=0, + canceled_count += ( + qs.filter(uid__in=uids) + .exclude( + methods__primary=True, + methods__active=True, + ) + .distinct() + .update( + next_payment_at=None, + plan_id=free_regular_plan.pk, + current_token_balance=0, + ) ) logger.info('Revoke recurring finished: free_regular=%s', canceled_count) finally: @@ -70,6 +70,9 @@ BUSINESS_EMAIL_RECIPIENT=help@root.ru YOOKASSA_ACCOUNT_ID=322563 YOOKASSA_SECRET_KEY=test_i_Au0KbXnOmdVf1icljT7v4CuDHLG8mXVkyofJQFBns YOOKASSA_RESULT_PAYMENT_URL=http://localhost +RECURRING_RETRY_OFFSETS=1,3,5,8,12,16,21,28 +RECURRING_FULL_ACCESS_CUTOFF_DAY=4 +RECURRING_FAILED_CHARGE_EMAIL_DAYS=3 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'