@@ -230,7 +230,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) @@ -170,8 +170,8 @@ class EmailService: cls, product_title: str, next_payment_at: datetime | str, amount: Decimal, payer_email: str, status: str ) -> None: templates = { - 'succeeded': 'authentication/success_payment_notification', - 'canceled': 'authentication/canceled_payment_notification', + 'succeeded': 'payments/success_payment_notification', + 'canceled': 'payments/canceled_payment_notification', } html_message = cls._render_letter_template( template_name=templates[status], @@ -80,8 +80,7 @@ PATH_PREFETCH_MAP = { 'payment_plan', 'business_account__parent_company__user__payment_plan__plan', 'payment_plan__plan', - 'payment_method', - 'payment_method__recurring_payment' + 'payment_plan__method', ), 'prefetch': ( Prefetch( @@ -508,3 +508,6 @@ 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) @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-01-21 13:19+0300\n" +"POT-Creation-Date: 2026-02-12 11:29+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -20,7 +20,7 @@ msgstr "" "n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || " "(n%100>=11 && n%100<=14)? 2 : 3);\n" -#: authentication/exceptions/business_account.py:6 authentication/views.py:416 +#: authentication/exceptions/business_account.py:6 authentication/views.py:436 msgid "Business account not found" msgstr "Сотрудник не найден" @@ -33,34 +33,24 @@ msgid "You cannot change the password of an unconfirmed e-mail user." msgstr "Вы не можете изменить пароль неподтвержденного по e-mail пользователя." #: authentication/exceptions/business_account.py:21 -msgid "" -"Admin staff cannot change the password of another admin staff member" -msgstr "" -"Администраторы не могут изменять пароль другого администратора" +msgid "Admin staff cannot change the password of another admin staff member" +msgstr "Администраторы не могут изменять пароль другого администратора" #: authentication/exceptions/business_account.py:26 -msgid "" -"Admin staff cannot update other admin staff members" -msgstr "" -"Администраторы не могут обновлять других администраторов" +msgid "Admin staff cannot update other admin staff members" +msgstr "Администраторы не могут обновлять других администраторов" #: authentication/exceptions/business_account.py:31 -msgid "" -"Admin staff cannot create other admin staff members" -msgstr "" -"Администраторы не могут создавать других администраторов" +msgid "Admin staff cannot create other admin staff members" +msgstr "Администраторы не могут создавать других администраторов" #: authentication/exceptions/business_account.py:36 -msgid "" -"Admin staff cannot delete other admin staff members" -msgstr "" -"Администраторы не могут удалять других администраторов" +msgid "Admin staff cannot delete other admin staff members" +msgstr "Администраторы не могут удалять других администраторов" #: authentication/exceptions/business_account.py:41 -msgid "" -"Admin staff cannot promote other staff members to admin" -msgstr "" -"Администраторы не могут повышать других сотрудников до администраторов" +msgid "Admin staff cannot promote other staff members to admin" +msgstr "Администраторы не могут повышать других сотрудников до администраторов" #: authentication/exceptions/business_account.py:46 msgid "" @@ -200,8 +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 -#: payments/models/payment_plan.py:28 tools/chats/models.py:9 -#: tools/media/models.py:41 +#: tools/chats/models.py:9 tools/media/models.py:41 msgid "Title" msgstr "Название" @@ -217,8 +206,8 @@ 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:63 +#: payments/admin.py:95 payments/models/invoice.py:15 +#: payments/models/payment.py:26 payments/models/payment_plan.py:50 msgid "User" msgstr "Пользователь" @@ -509,21 +498,21 @@ msgstr "Вайтлист для отмены политик" msgid "Whitelists to cancel policies" msgstr "Вайтлисты для отмены политик" -#: authentication/security.py:34 +#: authentication/security.py:35 #, fuzzy #| msgid "Hidden" msgid "Forbidden" msgstr "Скрытый" -#: authentication/security.py:47 +#: authentication/security.py:48 msgid "Access token is expired" msgstr "Срок действия токена доступа истек" -#: authentication/security.py:60 +#: authentication/security.py:61 msgid "User not found" msgstr "Пользователь не найден" -#: authentication/security.py:98 authentication/security.py:129 +#: authentication/security.py:96 authentication/security.py:125 msgid "Access token expired or does not exist" msgstr "Токен доступа просрочен или не существует" @@ -549,7 +538,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" @@ -573,32 +562,32 @@ msgstr "Пароли не совпадают" msgid "Current password is wrong" msgstr "Текущий пароль неверен" -#: authentication/views.py:125 authentication/views.py:234 -#: authentication/views.py:330 authentication/views.py:361 +#: authentication/views.py:133 authentication/views.py:242 +#: authentication/views.py:348 authentication/views.py:381 msgid "Server error occured" msgstr "Случилась серверная ошибка" -#: authentication/views.py:230 +#: authentication/views.py:238 msgid "Email not found" msgstr "Email не найден" -#: authentication/views.py:326 +#: authentication/views.py:318 authentication/views.py:370 +msgid "Email sending error: email not found" +msgstr "Ошибка отправки письма: email не найден" + +#: authentication/views.py:340 msgid "Business account has been deleted" msgstr "Сотрудник успешно удален" -#: authentication/views.py:350 +#: authentication/views.py:368 msgid "Business account has been reinvited" msgstr "Повторное приглашение сотруднику успешно отправлено" -#: authentication/views.py:367 -msgid "Email sending error: email not found" -msgstr "Ошибка отправки письма: email не найден" - -#: authentication/views.py:420 +#: authentication/views.py:440 msgid "Business account password has been updated" msgstr "Пароль сотрудника успешно обновлен" -#: authentication/views.py:467 +#: authentication/views.py:489 msgid "Could not confirm email, please try again." msgstr "Невозможно подтвердить email, попробуйте позже" @@ -764,7 +753,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 "Модель" @@ -826,7 +815,7 @@ msgstr "Текстовый файл (Блокнот)" msgid "ZIP Archive" msgstr "ZIP архив" -#: ml_model/models.py:228 payments/routes/v1.py:97 +#: ml_model/models.py:228 payments/tests/test_plans.py:35 msgid "Audio" msgstr "Аудио" @@ -1014,20 +1003,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 "" @@ -1069,52 +1062,52 @@ 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:23 +#: payments/models/payment_plan.py:22 msgid "Is visible" msgstr "Видимый" -#: payments/models/payment_plan.py:54 payments/models/payment_plan.py:69 +#: 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:55 +#: payments/models/payment_plan.py:42 msgid "Payment Plans" msgstr "Платежные Планы" -#: payments/models/payment_plan.py:71 +#: payments/models/payment_plan.py:58 msgid "Last payment at" msgstr "Последнее время платежа" -#: payments/models/payment_plan.py:72 +#: payments/models/payment_plan.py:59 msgid "Next payment at" msgstr "Следующее время платежа" -#: payments/models/payment_plan.py:74 +#: 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:77 payments/models/payment_plan.py:78 +#: payments/models/payment_plan.py:90 payments/models/payment_plan.py:91 msgid "User Balance" msgstr "Баланс пользователя" @@ -1186,47 +1179,38 @@ msgstr "Активация Промокода" msgid "Promocode Activations" msgstr "Активации Промокодов" -#: payments/models/recurring_payment.py:11 -#: 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/recurring_payment.py:14 -msgid "Payment Datetime" -msgstr "Дата и время платежа" +#: payments/models/user_payment_method.py:9 +msgid "Card type" +msgstr "Тип карты" -#: payments/models/recurring_payment.py:28 -msgid "Recurring Payment" -msgstr "Автоплатеж" +#: payments/models/user_payment_method.py:10 +msgid "Last four card digits" +msgstr "Последние 4 цифры карты" -#: payments/models/recurring_payment.py:29 -msgid "Recurring Payments" -msgstr "Автоплатежи" +#: payments/models/user_payment_method.py:11 +msgid "Attempts" +msgstr "Попытки" -#: payments/models/user_payment_method.py:25 +#: payments/models/user_payment_method.py:18 msgid "Payment Methods" msgstr "Платежные методы" -#: payments/routes/v1.py:74 +#: 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:75 +#: payments/routes/v1.py:154 msgid "Refills" msgstr "Пополнения" -#: payments/routes/v1.py:97 -msgid "Chat-bots" -msgstr "Чат-боты" - -#: payments/routes/v1.py:97 -msgid "Images" -msgstr "Изображения" - -#: payments/routes/v1.py:97 -msgid "Video" -msgstr "Видео" - #: payments/selectors/model_payment_selector.py:25 msgid "Messages for this model are not registered in a selector" msgstr "" @@ -1235,17 +1219,19 @@ msgstr "" msgid "Unknown account type" msgstr "Неизвестный тип аккаунта" -#: payments/services/payment_method_service.py:46 -msgid "No current active payment method is set" -msgstr "Ни одного активного метода не установлено" +#: payments/tests/test_plans.py:23 payments/tests/test_plans.py:190 +#: payments/tests/test_plans.py:193 +msgid "Chat-bots" +msgstr "Чат-боты" -#: payments/services/payment_method_service.py:55 -msgid "No payment method by this id" -msgstr "Не найдено метода платежа по этому ID" +#: payments/tests/test_plans.py:27 payments/tests/test_plans.py:191 +#: payments/tests/test_plans.py:196 +msgid "Images" +msgstr "Изображения" -#: payments/views.py:186 -msgid "The recurring payment is successfully cancelled" -msgstr "Автоплатежи успешно отключены" +#: payments/tests/test_plans.py:31 +msgid "Video" +msgstr "Видео" #: poller/models.py:11 msgid "Address" @@ -1283,7 +1269,7 @@ msgstr "Публичный API" msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:188 tools/media/apis.py:179 +#: tools/chats/apis.py:188 tools/media/apis.py:181 #: tools/public_api/views/base.py:103 msgid "" "Error occured when create generation. It may cause NSFW-content not allowed, " @@ -1363,6 +1349,24 @@ msgstr "Отсутствует обязательный параметр: 'messa msgid "Model not found" msgstr "Модель не найдена" +#~ 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,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', + ), + ] @@ -1,35 +0,0 @@ -# Generated by Django 5.0.11 on 2025-11-18 13:50 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('payments', '0026_remove_paymentplan_title'), - ] - - operations = [ - migrations.RemoveField( - model_name='paymentplanuserinfo', - name='next_payment_at', - ), - migrations.RemoveField( - model_name='paymentplanuserinfo', - name='plan_schedule', - ), - migrations.CreateModel( - name='RecurringPayment', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('payment_datetime', models.DateTimeField(verbose_name='Payment Datetime')), - ('payment_method', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='recurring_payment', to='payments.userpaymentmethod', verbose_name='Payment Method')), - ('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recurring_payments', to='payments.paymentplan', verbose_name='Plan')), - ], - options={ - 'verbose_name': 'Recurring Payment', - 'verbose_name_plural': 'Recurring Payments', - }, - ), - ] @@ -1,17 +0,0 @@ -# Generated by Django 5.0.11 on 2025-11-19 07:45 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('payments', '0027_remove_paymentplanuserinfo_next_payment_at_and_more'), - ] - - operations = [ - migrations.RemoveField( - model_name='paymentplan', - name='duration', - ), - ] @@ -1,23 +0,0 @@ -# Generated by Django 5.0.11 on 2025-11-26 14:43 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('payments', '0028_remove_paymentplan_duration'), - ] - - operations = [ - migrations.RenameField( - model_name='recurringpayment', - old_name='payment_method', - new_name='method', - ), - migrations.RenameField( - model_name='recurringpayment', - old_name='payment_datetime', - new_name='pay_at', - ), - ] @@ -1,17 +0,0 @@ -# Generated by Django 5.0.11 on 2025-11-28 09:26 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('payments', '0029_rename_payment_method_recurringpayment_method_and_more'), - ] - - operations = [ - migrations.RemoveField( - model_name='paymentplan', - name='is_recurrent', - ), - ] @@ -1,25 +0,0 @@ -# Generated by Django 5.0.11 on 2026-02-03 10:11 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('payments', '0030_remove_paymentplan_is_recurrent'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveField( - model_name='userpaymentmethod', - name='currently_active', - ), - migrations.AlterField( - model_name='userpaymentmethod', - name='user', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='payment_method', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'), - ), - ] @@ -1,19 +1,17 @@ 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.recurring_payment import RecurringPayment from payments.models.payment_plan_feature import PaymentPlanFeature __all__ = ( 'Payment', 'PaymentPlan', 'PaymentPlanUserInfo', - 'UserPaymentMethod', + 'PaymentMethod', 'Invoice', 'PromoCode', 'PromoCodeActivation', - 'RecurringPayment', - 'PaymentPlanFeature' + 'PaymentPlanFeature', ) @@ -2,7 +2,6 @@ from datetime import datetime from django.contrib.auth import get_user_model from django.contrib.postgres.fields import ArrayField -from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.utils.translation import gettext_lazy as _ @@ -35,7 +34,7 @@ class PaymentPlan(BaseModel): return NeuronModel.objects.filter(pk__in=ids) def __str__(self) -> str: - return f'{self.uid}' + return f'План {self.tokens_per_plan:.0f} токенов' class Meta: ordering = ['price'] @@ -57,6 +56,15 @@ class PaymentPlanUserInfo(BaseModel): verbose_name=_('Payment Plan'), ) 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') ) @@ -72,11 +80,8 @@ class PaymentPlanUserInfo(BaseModel): return super().save(force_insert, force_update, using, update_fields) @property - def is_recurring(self): - try: - return self.user.payment_method.recurring_payment - except ObjectDoesNotExist: - return None + def is_recurring(self) -> bool: + return self.method is not None def __str__(self) -> str: return f'{self.user.email or "Ошибка"}' @@ -1,29 +0,0 @@ -from django.db import models -from django.utils.translation import gettext_lazy as _ - -from payments.models import UserPaymentMethod, PaymentPlan - - -class RecurringPayment(models.Model): - method = models.OneToOneField( - UserPaymentMethod, - on_delete=models.CASCADE, - verbose_name=_('Payment Method'), - related_name='recurring_payment' - ) - pay_at = models.DateTimeField(verbose_name=_('Payment Datetime')) - plan = models.ForeignKey( - to=PaymentPlan, - on_delete=models.SET_NULL, - related_name='recurring_payments', - verbose_name=_('Plan'), - null=True, - blank=True, - ) - - def __str__(self) -> str: - return f'Recurring payment ({self.pay_at})' - - class Meta: - verbose_name = _('Recurring Payment') - verbose_name_plural = _('Recurring Payments') @@ -1,23 +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.OneToOneField( - get_user_model(), - on_delete=models.CASCADE, - related_name='payment_method', - 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') @@ -6,6 +6,7 @@ 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 @@ -18,9 +19,15 @@ from ninja.errors import HttpError from authentication.models import CustomUserModel from authentication.security import AsyncAuthBearer, SyncAuthBearer from payments.exceptions.payer_not_found import PayerNotFound -from payments.models import Invoice, Payment, PaymentPlan, PaymentPlanFeature, UserPaymentMethod +from payments.models import ( + Invoice, + Payment, + PaymentPlan, + PaymentPlanFeature, + PaymentMethod, + PaymentPlanUserInfo, +) from authentication.services.email_service import EmailService -from payments.models import RecurringPayment from payments.schema import UserBalance from payments.schemas import ( ExpensesParamsSchema, @@ -57,7 +64,7 @@ async def handle_yookassa_webhook(request): data = orjson.loads(request.body) payment = await PaymentService.handle_payment(data['object']['id']) try: - payer = await CustomUserModel.objects.prefetch_related('payment_plan', 'payment_plan__plan').aget( + 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 @@ -69,8 +76,12 @@ async def handle_yookassa_webhook(request): raise HttpError(400, f'{exc}') try: if payment.status in ('succeeded', 'canceled'): - recurring = await RecurringPayment.objects.filter(method__user=payment_instance.user).afirst() - next_payment_at = recurring.pay_at if recurring else 'Автоплатежи отключены' + 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} токенов', @@ -86,7 +97,7 @@ async def handle_yookassa_webhook(request): @router.post('revoke-recurring-payment', tags=['payments/revoke-recurring-payment']) def revoke_recurring_payment(request): - UserPaymentMethod.objects.filter(user=request.auth).delete() + PaymentMethod.objects.filter(user_plan_info__user=request.auth).delete() return 200, {'detail': _('The recurring payment is successfully cancelled')} @@ -218,22 +229,18 @@ async def create_payment_link(request, body: NewSubscriptionSchema): raise HttpError(400, f'{exc}') -@router.delete('plans', tags=['payments/plans'], auth=AsyncAuthBearer(), response={200: None, 400: str}) -async def cancel_plan_subscription(request): - try: - if request.auth.account_type not in {'regular', 'business_host'}: - raise HttpError(401, 'Unauthorized') - await PaymentPlanService(request.auth).cancel_payment_plan() - except Exception as exc: - raise HttpError(400, f'{exc}') - - @router.post('gitlab-webhook', tags=['payments/gitlab-webhook'], auth=None) async def handle_gitlab_webhook(request): try: data = orjson.loads(request.body)['object_attributes'] - if data['name'] == 'recurring_payments' and not data['active']: - await UserPaymentMethod.objects.all().adelete() + if data['name'] == 'recurring_payments': + is_active = data['active'] + if not is_active: + await PaymentMethod.objects.all().adelete() + 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))) except Exception as exc: logger.error(exc) return 200 @@ -1,25 +1,23 @@ import logging from uuid import UUID -from authentication.models.user import CustomUserModel -from payments.models.user_payment_method import UserPaymentMethod +from authentication.models import CustomUserModel +from payments.models.user_payment_method import PaymentMethod 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__) - payment_method, _ = UserPaymentMethod.objects.update_or_create( - user=self.user, - defaults={'payment_method_id': method_id, 'last_four': last_four, 'card_type': card_type}, + payment_method, _ = 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) return payment_method def delete_payment_method(self): - UserPaymentMethod.objects.filter(user=self.user).delete() + PaymentMethod.objects.filter(user_plan_info__user=self.user).delete() @@ -1,11 +1,8 @@ import logging from decimal import Decimal -from asgiref.sync import sync_to_async - from authentication.models import CustomUserModel -from payments.models import Invoice, PaymentPlan, PaymentPlanUserInfo, RecurringPayment -from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.models import Invoice, PaymentPlan, PaymentPlanUserInfo from payments.services.model_billing_service import ModelBillingService from payments.services.payment_service import PaymentService @@ -21,28 +18,15 @@ class PaymentPlanService: self.user.payment_plan.current_token_balance += amount self.user.payment_plan.save() - def subscribe_user_to_plan(self, plan: PaymentPlan, mode: str = 'set', amount: Decimal = Decimal('0')): - try: - current_balance = self.user.payment_plan.current_token_balance - except PaymentPlanUserInfo.DoesNotExist: - current_balance = Decimal('0') - token_balance = amount or plan.tokens_per_plan + def subscribe_user_to_plan(self, plan: PaymentPlan, amount: Decimal): PaymentPlanUserInfo.objects.update_or_create( user=self.user, defaults={ 'plan': plan, - 'current_token_balance': token_balance if mode == 'set' else current_balance + token_balance, + 'current_token_balance': amount, }, ) - 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() - ) - await plan_info.asave() - await RecurringPayment.objects.filter(method__user=self.user).adelete() - def update_per_token_plan_details(self, payment_amount: Decimal, model=None): ModelBillingService(self.user).charge(payment_amount) if model: @@ -1,5 +1,5 @@ -from datetime import timedelta import logging + from decimal import Decimal from uuid import UUID, uuid4 @@ -10,17 +10,13 @@ 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 lib.unleash.client import web_client from payments.exceptions.full_balance import FullBalanceException -from payments.models import RecurringPayment from payments.models.payment import Payment as PaymentModel -from payments.models.payment_plan import PaymentPlan +from payments.models.payment_plan import PaymentPlan, PaymentPlanUserInfo from payments.services.payment_method_service import PaymentMethodService from payments.services.referral_account import ReferralAccountService @@ -39,7 +35,6 @@ class PaymentService: current_balance = self.user.payment_plan.current_token_balance plan_price = plan.price plan_tokens = plan.tokens_per_plan - tokens_mode = 'set' if current_balance >= plan_tokens and plan == current_plan: raise FullBalanceException if 0 < current_plan.price <= plan.price and self.user.payment_plan.is_recurring: @@ -51,9 +46,11 @@ class PaymentService: Decimal('0.01'), rounding='ROUND_UP' ) balance_sufficient = (plan_tokens - current_balance) <= 0 - plan_tokens = plan_tokens if balance_sufficient else (plan_tokens - current_balance) - tokens_mode = 'set' if balance_sufficient else 'add' - tokens_update = {'mode': tokens_mode, 'amount': str(plan_tokens)} + 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}, @@ -80,7 +77,10 @@ class PaymentService: 'description': str(self.user.uid), 'capture': True, 'save_payment_method': is_recurring, - 'metadata': {'plan_uid': str(plan.uid), **tokens_update}, + 'metadata': { + 'plan_uid': str(plan.uid), + 'buying_tokens': str(plan_tokens), + }, } payment = YookassaPayment.create(payment_data, uuid4()) return payment.confirmation.confirmation_url @@ -94,10 +94,11 @@ class PaymentService: if payment.status == 'waiting_for_capture': self.handle_captured_payment(payment.id) elif payment.status == 'succeeded': - self.handle_succeeded_payment(payment, payment_instance.plan) - PaymentPlanService(self.user).subscribe_user_to_plan( - payment_instance.plan, payment.metadata['mode'], Decimal(payment.metadata['amount']) + buying_tokens = self.calculate_buying_tokens( + Decimal(f'{payment.metadata["buying_tokens"]}'), 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) elif payment.status == 'canceled' and payment.metadata.get('recurring'): @@ -112,6 +113,18 @@ class PaymentService: def handle_captured_payment(self, payment_id: UUID) -> None: YookassaPayment.capture(str(payment_id)) + def calculate_buying_tokens(self, buying_tokens: Decimal, plan: PaymentPlan): + 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 @@ -119,34 +132,37 @@ class PaymentService: and web_client.get_flag_state('recurring_payments', self.user.email) and not plan.individual ): - payment_method = self.save_payment_method(payment.payment_method) - RecurringPayment.objects.update_or_create( - method__user=self.user, - defaults={ - 'method': payment_method, - 'pay_at': timezone.now() + relativedelta(months=1), - 'plan': plan, - }, + 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) ) 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) + ) + else: + PaymentPlanUserInfo.objects.filter(user=self.user).update(next_payment_at=None) PaymentMethodService(self.user).delete_payment_method() def handle_canceled_payment(self, payment: YookassaPaymentResponse) -> None: logger.error(f'Recurrent payment error: {payment.cancellation_details.reason}') - if payment.cancellation_details.reason == 'permission_revoked': - RecurringPayment.objects.filter(method__user=self.user).delete() - else: - recurring = RecurringPayment.objects.filter(method__user=self.user).first() - if recurring: - recurring.pay_at = timezone.now() + timedelta(days=2) - recurring.save() - - def save_payment_method(self, method_data: PaymentDataBankCard): - card = method_data.card - - return PaymentMethodService(self.user).add_payment_method( - method_id=UUID(method_data.id), card_type=card.card_type, last_four=card.last4 + 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() + else: + PaymentMethodService(self.user).delete_payment_method() def save_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: plan = PaymentPlan.objects.get_or_none(uid=payment.metadata.get('plan_uid')) @@ -10,8 +10,6 @@

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

-

Следующая попытка списания будет выполнена: {{ next_payment_at }}.

-

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

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

-

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

+

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

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

-

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

+

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

@@ -16,7 +16,7 @@ from payments.models import ( PaymentPlanUserInfo, PromoCode, PromoCodeActivation, - UserPaymentMethod, RecurringPayment, + PaymentMethod, ) from payments.models.referral_account import ReferralAccount, ReferralInvite @@ -44,9 +44,9 @@ class PaymentAdmin(admin.ModelAdmin): @admin.register(PaymentPlan) class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): list_display = [ + '__str__', 'uid', 'price', - 'tokens_per_plan', 'is_corporate', 'individual', 'is_visible', @@ -56,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', 'plan', 'updated_at', 'next_payment_at'] raw_id_fields = ['user'] search_fields = [ @@ -73,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) @@ -222,7 +222,3 @@ class ReferralAccountAdmin(admin.ModelAdmin): def _accrued_bonuses(self, obj: ReferralAccount): return f'{obj.accrued_bonuses.aggregate(total=Coalesce(Sum("amount"), Decimal(0), output_field=models.DecimalField()))["total"]} токенов' - -@admin.register(RecurringPayment) -class RecurringPaymentAdmin(admin.ModelAdmin): - list_display = ('method', 'pay_at') @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date, datetime from typing import List, Optional from uuid import UUID @@ -45,7 +45,8 @@ class PaymentLinkSchema(Schema): class UserPlanDetailSchema(Schema): uid: UUID plan: PaymentPlanSchema - last_payment_at: date + last_payment_at: datetime + next_payment_at: datetime | None class PromoCodeSchema(ModelSchema): @@ -47,6 +47,7 @@ class UserPlanDetailSerializer(serializers.Serializer): uid = serializers.UUIDField() plan = PaymentPlanSerializer() last_payment_at = serializers.DateField() + next_payment_at = serializers.DateField() current_token_balance = serializers.IntegerField() @@ -1,10 +1,11 @@ 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, UserPaymentMethod +from payments.models import PaymentPlan, PaymentMethod, PaymentPlanUserInfo from payments.services.referral_account import ReferralAccountService @@ -24,4 +25,13 @@ def delete_recurrent_for_individual_plans( sender: Type[PaymentPlan], instance: PaymentPlan, created: bool, **kwargs ): if instance.individual: - UserPaymentMethod.objects.filter(user__payment_plan__plan=instance).delete() \ No newline at end of file + 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: + instance.delete() \ No newline at end of file @@ -10,7 +10,8 @@ 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 RecurringPayment +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 @@ -36,36 +37,51 @@ def withdraw(user_id: UUID, amount: Decimal): @shared_task def execute_recurring_payments() -> None: - overdue_payments = RecurringPayment.objects.filter(pay_at__lte=timezone.now(), plan__isnull=False, plan__individual=False) + overdue_payments = PaymentPlanUserInfo.objects.select_related('user', 'plan', 'method').filter( + next_payment_at__isnull=False, next_payment_at__lte=timezone.now(), plan__individual=False + ) + canceled_recurring_payments = [] for overdue_payment in overdue_payments: - if celery_client.get_flag_state('recurring_payments', overdue_payment.method.user.email): - customer = overdue_payment.method.user - plan = overdue_payment.plan - 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), - 'mode': 'set', - 'amount': str(plan.tokens_per_plan), - }, - } - YookassaPayment.create(payment_data, uuid4()) - else: - overdue_payment.method.delete() \ No newline at end of file + 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) + 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 = free_plan.tokens_per_plan + canceled_recurring_payments.append(overdue_payment) + 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()) + 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'] + ) + PaymentMethod.objects.filter(uid__in=methods_for_delete).delete() \ No newline at end of file