@@ -1368,6 +1368,22 @@ msgstr "У вас нет активной подписки для отмены" msgid "The recurring payment is successfully cancelled" msgstr "Автоплатежи успешно отключены" +#: payments/routes/v1.py +msgid "Active payment method not found" +msgstr "Активный способ оплаты не найден" + +#: payments/routes/v1.py +msgid "Payment could not be completed" +msgstr "Не удалось провести платёж" + +#: payments/routes/v1.py +msgid "Payment was acquired, but we could not process it, please wait" +msgstr "Платёж прошёл, но мы не смогли его обработать, пожалуйста, подождите" + +#: payments/routes/v1.py +msgid "Payment could not be completed, please try again later" +msgstr "Не удалось провести платёж, пожалуйста, попробуйте позже" + #: payments/routes/v1.py:144 msgid "Expenses" msgstr "Затраты" @@ -0,0 +1,10 @@ +class ActivePaymentMethodNotFound(Exception): + pass + + +class PaymentNotAcquired(Exception): + pass + + +class PaymentProcessingFailed(Exception): + pass @@ -0,0 +1,21 @@ +# Generated by Django 5.0 on 2026-07-29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('payments', '0034_add_flux_payment_features'), + ] + + operations = [ + migrations.AddField( + model_name='paymentplanuserinfo', + name='last_recovery_attempt_at', + field=models.DateTimeField( + blank=True, + null=True, + verbose_name='Last recovery attempt at', + ), + ), + ] @@ -1,7 +1,6 @@ from datetime import datetime from django.contrib.auth import get_user_model -from django.contrib.postgres.fields import ArrayField from django.db import models from django.utils.translation import gettext_lazy as _ @@ -50,6 +49,11 @@ 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')) + last_recovery_attempt_at = models.DateTimeField( + blank=True, + null=True, + verbose_name=_('Last recovery attempt at'), + ) current_token_balance = models.DecimalField( max_digits=100, decimal_places=10, verbose_name=_('Current balance') ) @@ -1,31 +1,27 @@ -import orjson - import calendar import logging from collections import defaultdict from datetime import date, timedelta from decimal import Decimal -from django.db.models.aggregates import Count -from django.utils.translation import gettext_lazy as _ - +import orjson from dateutil.relativedelta import relativedelta from django.db.models import CharField, F, Func, Prefetch, Q, Sum, Value +from django.db.models.aggregates import Count 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.exceptions.business_host_exceptions.access_denied import AccessDenied from authentication.models import CustomUserModel from authentication.security import SyncAuthBearer -from authentication.exceptions.business_host_exceptions.access_denied import AccessDenied -from payments.models import ( - Invoice, - Payment, - PaymentPlan, - PaymentPlanFeature, - PaymentMethod, +from payments.exceptions.subscription_recovery import ( + ActivePaymentMethodNotFound, + PaymentNotAcquired, + PaymentProcessingFailed, ) +from payments.models import Invoice, Payment, PaymentMethod, PaymentPlan, PaymentPlanFeature from payments.schema import UserBalance from payments.schemas import ( ExpensesParamsSchema, @@ -36,8 +32,12 @@ from payments.schemas import ( ) 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 +from payments.typing import ( + IntervalStrategyEnum, + SourceStrategyEnum, + SubscriptionRecoveryStatusEnum, +) router = Router(auth=SyncAuthBearer(), tags=['payments']) @@ -114,6 +114,37 @@ def revoke_recurring_payment(request): return 200, {'detail': _('The recurring payment is successfully cancelled')} +@router.post( + 'restore-subscription', + tags=['payments/restore-subscription'], + response={200: None, 202: None, 204: None}, +) +def restore_subscription(request): + try: + restored = PaymentService(request.auth).restore_subscription() + except ActivePaymentMethodNotFound as exc: + raise HttpError(400, _('Active payment method not found')) from exc + except PaymentNotAcquired as exc: + raise HttpError(400, _('Payment could not be completed')) from exc + except PaymentProcessingFailed as exc: + raise HttpError( + 500, + _('Payment was acquired, but we could not process it, please wait'), + ) from exc + except Exception as exc: + logger.exception('Subscription recovery failed: email=%s', request.auth.email) + + raise HttpError(500, _('Payment could not be completed, please try again later')) from exc + + if restored == SubscriptionRecoveryStatusEnum.PENDING: + return 202, None + + if restored == SubscriptionRecoveryStatusEnum.DUPLICATE: + return 204, None + + return 200, None + + @router.get('expenses', tags=['payments/expenses'], response=list[ExpensesSchema]) def list_expenses(request, data: ExpensesParamsSchema = Query(...)): try: @@ -1,7 +1,5 @@ -import hashlib import logging from datetime import timedelta - from decimal import Decimal from uuid import UUID, uuid4 @@ -14,13 +12,22 @@ from yookassa.domain.response import PaymentResponse as YookassaPaymentResponse from authentication.models import CustomUserModel from authentication.services.email_service import EmailService +from payments.exceptions.subscription_recovery import ( + ActivePaymentMethodNotFound, + PaymentNotAcquired, + PaymentProcessingFailed, +) from payments.models.payment import Payment as PaymentModel from payments.models.payment_plan import PaymentPlan, PaymentPlanUserInfo +from payments.models.user_payment_method import PaymentMethod from payments.services.payment_method_service import PaymentMethodService from payments.services.referral_account import ReferralAccountService +from payments.typing import SubscriptionRecoveryStatusEnum logger = logging.getLogger(__name__) +SUBSCRIPTION_RECOVERY_COOLDOWN = timedelta(minutes=5) + class PaymentService: Configuration.account_id = settings.YOOKASSA_ACCOUNT_ID @@ -59,6 +66,92 @@ class PaymentService: ) return payment.confirmation.confirmation_url + def restore_subscription(self) -> SubscriptionRecoveryStatusEnum: + payment_method = self._reserve_subscription_recovery() + if payment_method is None: + return SubscriptionRecoveryStatusEnum.DUPLICATE + + plan = payment_method.user_plan_info.plan + receipt_data = { + 'customer': {'email': self.user.email}, + 'items': [ + { + 'description': str(plan), + 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'vat_code': 1, + 'quantity': '1', + } + ], + } + payment_data = { + 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'payment_method_id': payment_method.payment_method_id, + 'receipt': receipt_data, + 'description': str(self.user.uid), + 'capture': True, + 'metadata': { + 'recurring': True, + 'plan_uid': str(plan.uid), + }, + } + payment = YookassaPayment.create(payment_data, idempotency_key=uuid4()) + if payment.status == PaymentModel.CANCELLED: + raise PaymentNotAcquired + + if payment.status in (PaymentModel.PENDING, PaymentModel.WAITING_FOR_CAPTURE): + return SubscriptionRecoveryStatusEnum.PENDING + + if payment.status != PaymentModel.SUCCEEDED: + raise PaymentNotAcquired + + try: + self.do_payment(payment) + except Exception as exc: + logger.exception( + 'Subscription recovery payment acquired but not processed: payment_id=%s email=%s', + payment.id, + self.user.email, + ) + + raise PaymentProcessingFailed from exc + + logger.info( + 'Subscription restored: payment_id=%s email=%s method_uid=%s', + payment.id, + self.user.email, + payment_method.uid, + ) + + return SubscriptionRecoveryStatusEnum.SUCCEEDED + + def _reserve_subscription_recovery(self) -> PaymentMethod | None: + with transaction.atomic(): + user_plan_info = ( + PaymentPlanUserInfo.objects.select_for_update().select_related('plan').get(user=self.user) + ) + recovery_available_at = timezone.now() - SUBSCRIPTION_RECOVERY_COOLDOWN + if ( + user_plan_info.last_recovery_attempt_at + and user_plan_info.last_recovery_attempt_at > recovery_available_at + ): + logger.info('Duplicate subscription recovery skipped: email=%s', self.user.email) + + return None + + payment_method = ( + user_plan_info.methods.filter(active=True).order_by('-primary', '-created_at').first() + ) + if payment_method is None: + raise ActivePaymentMethodNotFound + + user_plan_info.last_recovery_attempt_at = timezone.now() + user_plan_info.save(update_fields=['last_recovery_attempt_at']) + + user_plan_info.primary_methods = [payment_method] + self.user.payment_plan = user_plan_info + + return payment_method + def do_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: from payments.services.payment_plan_service import PaymentPlanService @@ -0,0 +1,216 @@ +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +from django.utils import timezone + +from core import tests as core_tests +from payments.models import PaymentMethod, PaymentPlan + + +class RestoreSubscriptionAPITest(core_tests.BaseAuthorizedAPITest): + ENDPOINT = '/api/v1/payments/restore-subscription' + + @classmethod + def setUpTestData(cls) -> None: + PaymentPlan.objects.update_or_create(price=0, tokens_per_plan=10, defaults={}) + super().setUpTestData() + + @classmethod + def setup_test_data(cls) -> None: + cls.plan = PaymentPlan.objects.create(price=1000, tokens_per_plan=100) + cls.user.payment_plan.plan = cls.plan + cls.user.payment_plan.save() + cls.payment_method = PaymentMethod.objects.create( + user_plan_info=cls.user.payment_plan, + gateway=PaymentMethod.GatewayChoices.BANK_CARD, + payment_method_id=uuid4(), + metadata={}, + active=True, + primary=True, + ) + + def _payment(self, status: str) -> SimpleNamespace: + return SimpleNamespace( + amount=SimpleNamespace(value=self.plan.price), + description=str(self.user.uid), + id=str(uuid4()), + metadata={ + 'recurring': True, + 'plan_uid': str(self.plan.uid), + }, + status=status, + ) + + def test_unauthorized_status_code(self) -> None: + response = self.client.post(self.ENDPOINT) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {'detail': 'Unauthorized'}) + + @patch('payments.services.payment_service.PaymentService.do_payment') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_authorized_status_code(self, create_payment_mock, do_payment_mock) -> None: + create_payment_mock.return_value = self._payment('succeeded') + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'') + do_payment_mock.assert_called_once_with(create_payment_mock.return_value) + + @patch('payments.services.payment_service.PaymentService.do_payment') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_uses_primary_active_payment_method_first( + self, + create_payment_mock, + do_payment_mock, + ) -> None: + newer_payment_method = PaymentMethod( + user_plan_info=self.user.payment_plan, + gateway=PaymentMethod.GatewayChoices.BANK_CARD, + payment_method_id=uuid4(), + metadata={}, + active=True, + primary=False, + ) + PaymentMethod.objects.bulk_create([newer_payment_method]) + create_payment_mock.return_value = self._payment('succeeded') + + self.post() + + payment_data = create_payment_mock.call_args.args[0] + self.assertEqual(payment_data['payment_method_id'], self.payment_method.payment_method_id) + + @patch('payments.services.payment_service.PaymentService.do_payment') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_uses_most_recent_active_payment_method( + self, + create_payment_mock, + do_payment_mock, + ) -> None: + PaymentMethod.objects.filter(pk=self.payment_method.pk).update(primary=False) + newer_payment_method = PaymentMethod( + user_plan_info=self.user.payment_plan, + gateway=PaymentMethod.GatewayChoices.BANK_CARD, + payment_method_id=uuid4(), + metadata={}, + active=True, + primary=False, + ) + PaymentMethod.objects.bulk_create([newer_payment_method]) + create_payment_mock.return_value = self._payment('succeeded') + + self.post() + + payment_data = create_payment_mock.call_args.args[0] + self.assertEqual(payment_data['payment_method_id'], newer_payment_method.payment_method_id) + + def test_returns_bad_request_without_active_payment_method(self) -> None: + PaymentMethod.objects.update(active=False) + + response = self.post() + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {'detail': 'Active payment method not found'}) + + @patch('payments.services.payment_service.PaymentService.do_payment') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_returns_bad_request_when_payment_is_canceled( + self, + create_payment_mock, + do_payment_mock, + ) -> None: + create_payment_mock.return_value = self._payment('canceled') + + response = self.post() + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {'detail': 'Payment could not be completed'}) + do_payment_mock.assert_not_called() + + @patch('payments.services.payment_service.PaymentService.do_payment') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_returns_accepted_when_payment_is_pending( + self, + create_payment_mock, + do_payment_mock, + ) -> None: + create_payment_mock.return_value = self._payment('pending') + + response = self.post() + + self.assertEqual(response.status_code, 202) + self.assertEqual(response.content, b'') + do_payment_mock.assert_not_called() + + @patch('payments.services.payment_service.PaymentService.do_payment') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_returns_server_error_when_acquired_payment_is_not_processed( + self, + create_payment_mock, + do_payment_mock, + ) -> None: + create_payment_mock.return_value = self._payment('succeeded') + do_payment_mock.side_effect = RuntimeError + + with self.assertLogs('payments.services.payment_service', level='ERROR'): + response = self.post() + + self.assertEqual(response.status_code, 500) + self.assertEqual( + response.json(), + {'detail': 'Payment was acquired, but we could not process it, please wait'}, + ) + + @patch('payments.services.payment_service.PaymentService.do_payment') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_skips_duplicate_request_for_five_minutes( + self, + create_payment_mock, + do_payment_mock, + ) -> None: + create_payment_mock.return_value = self._payment('succeeded') + + first_response = self.post() + second_response = self.post() + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 204) + self.assertEqual(second_response.content, b'') + create_payment_mock.assert_called_once() + + @patch('payments.services.payment_service.PaymentService.do_payment') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_skips_duplicate_after_acquired_payment_processing_error( + self, + create_payment_mock, + do_payment_mock, + ) -> None: + create_payment_mock.return_value = self._payment('succeeded') + do_payment_mock.side_effect = RuntimeError + + with self.assertLogs('payments.services.payment_service', level='ERROR'): + first_response = self.post() + second_response = self.post() + + self.assertEqual(first_response.status_code, 500) + self.assertEqual(second_response.status_code, 204) + create_payment_mock.assert_called_once() + + @patch('payments.services.payment_service.PaymentService.do_payment') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_allows_request_after_five_minutes( + self, + create_payment_mock, + do_payment_mock, + ) -> None: + create_payment_mock.return_value = self._payment('succeeded') + self.user.payment_plan.last_recovery_attempt_at = timezone.now() - timedelta(minutes=5) + self.user.payment_plan.save(update_fields=['last_recovery_attempt_at']) + + response = self.post() + + self.assertEqual(response.status_code, 200) + create_payment_mock.assert_called_once() @@ -15,3 +15,9 @@ class SourceStrategyEnum(str, Enum): MONTHS = 'months' YEARS = 'years' BUDGET = 'budget' + + +class SubscriptionRecoveryStatusEnum(str, Enum): + SUCCEEDED = 'succeeded' + PENDING = 'pending' + DUPLICATE = 'duplicate'