@@ -1,4 +1,5 @@ import logging +import smtplib from datetime import datetime from typing import Any, Sequence @@ -47,6 +48,8 @@ class EmailService: ) except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.exception.Timeout): raise DomainNotFound + except smtplib.SMTPRecipientsRefused: + raise DomainNotFound except Exception as exc: logger.exception(exc) raise Exception(_('Error occured when proceed email sending')) @@ -194,4 +194,76 @@ PATH_PREFETCH_MAP = { *_gen_only('payment_plan__plan', 'uid', 'price'), ), }, + '/api/v1/payments/restore-subscription': { + 'select': ( + 'host_account', + 'host_account__company_companyipwhitelist', + 'business_account', + 'business_account__parent_company', + 'business_account__parent_company__company_companyipwhitelist', + 'payment_plan', + 'payment_plan__plan', + ), + 'prefetch': ( + Prefetch( + 'payment_plan__methods', + queryset=PaymentMethod.objects.filter(active=True) + .order_by('-primary', '-created_at') + .only('uid', 'created_at', 'payment_method_id', 'primary', 'active', 'user_plan_info_id'), + to_attr='active_methods', + ), + ), + 'only': ( + 'uid', + 'email', + 'is_staff', + 'is_superuser', + *_gen_only('host_account', 'uid'), + *_gen_only('host_account__company_companyipwhitelist', 'uid', 'is_enabled'), + *_gen_only('business_account', 'account_privileges'), + *_gen_only('business_account__parent_company', 'uid'), + *_gen_only( + 'business_account__parent_company__company_companyipwhitelist', + 'uid', + 'is_enabled', + ), + *_gen_only('payment_plan', 'uid'), + *_gen_only( + 'payment_plan__plan', + 'uid', + 'price', + 'tokens_per_plan', + ), + ), + }, + '/api/v1/payments/restore-subscription/blocked': { + 'select': ( + 'host_account', + 'host_account__company_companyipwhitelist', + 'business_account', + 'business_account__parent_company', + 'business_account__parent_company__company_companyipwhitelist', + 'payment_plan', + ), + 'only': ( + 'uid', + 'email', + 'is_staff', + 'is_superuser', + *_gen_only('host_account', 'uid'), + *_gen_only('host_account__company_companyipwhitelist', 'uid', 'is_enabled'), + *_gen_only('business_account', 'account_privileges'), + *_gen_only('business_account__parent_company', 'uid'), + *_gen_only( + 'business_account__parent_company__company_companyipwhitelist', + 'uid', + 'is_enabled', + ), + *_gen_only( + 'payment_plan', + 'uid', + 'recovery_locked_at', + ), + ), + }, } @@ -97,24 +97,7 @@ def invalid_password_error_handler(request, exc: InvalidPassword): return api.create_response(request, {'message': _('Wrong password')}, status=401) -urlpatterns = ( - [ - path('admin/', admin.site.urls), - path('api/v1/ml_models/', include('ml_model.urls', namespace='ml_model')), - path('api/v1/auth/', include('authentication.urls')), - path('api/v1/payments/', include('payments.urls')), - path('api/v1/reports/', include('reports.urls')), - path('api/v1/chats/', include('tools.chats.urls')), - path('api/v1/media/', include('tools.media.urls')), - path('api/v1/public/', include('tools.public_api.urls')), - path('api/v1/api/', api.urls), - path('api/v1/', compatibility_api.urls), - path('api/v1/v2/', compatibility_api_v2.urls), - path('public/', public_api.urls), - ] - + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) - + public_urlpatterns -) +urlpatterns = [] if settings.DEBUG: urlpatterns += [ @@ -137,3 +120,22 @@ if settings.DEBUG: compatibility_api.docs_url = '/docs' compatibility_api_v2.docs_url = '/docs' public_api.docs_url = '/docs' + +urlpatterns += ( + [ + path('admin/', admin.site.urls), + path('api/v1/ml_models/', include('ml_model.urls', namespace='ml_model')), + path('api/v1/auth/', include('authentication.urls')), + path('api/v1/payments/', include('payments.urls')), + path('api/v1/reports/', include('reports.urls')), + path('api/v1/chats/', include('tools.chats.urls')), + path('api/v1/media/', include('tools.media.urls')), + path('api/v1/public/', include('tools.public_api.urls')), + path('api/v1/api/', api.urls), + path('api/v1/', compatibility_api.urls), + path('api/v1/v2/', compatibility_api_v2.urls), + path('public/', public_api.urls), + ] + + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + + public_urlpatterns +) \ No newline at end of file @@ -0,0 +1,4 @@ +from lib.exporters.base import BaseExporter +from lib.exporters.excel import ExcelExporter + +__all__ = ['BaseExporter', 'ExcelExporter'] @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + +from django.http import HttpResponse + + +class BaseExporter(ABC): + @abstractmethod + def export(self) -> HttpResponse: + raise NotImplementedError @@ -0,0 +1,38 @@ +from collections.abc import Iterable, Sequence + +from django.http import HttpResponse +from openpyxl import Workbook +from openpyxl.utils import get_column_letter + +from lib.exporters.base import BaseExporter + + +class ExcelExporter(BaseExporter): + CONTENT_TYPE = 'application/ms-excel' + SHEET_TITLE = 'Sheet' + COLUMN_WIDTH = 150 / 7 + + def get_headers(self) -> Sequence: + raise NotImplementedError + + def get_rows(self) -> Iterable[Sequence]: + raise NotImplementedError + + def get_filename(self) -> str: + raise NotImplementedError + + def export(self) -> HttpResponse: + wb = Workbook() + sheet = wb.active + sheet.title = self.SHEET_TITLE + sheet.append(list(self.get_headers())) + for row in self.get_rows(): + sheet.append(list(row)) + + for index in range(1, sheet.max_column + 1): + sheet.column_dimensions[get_column_letter(index)].width = self.COLUMN_WIDTH + + response = HttpResponse(content_type=self.CONTENT_TYPE) + response['Content-Disposition'] = f'attachment; filename={self.get_filename()}' + wb.save(response) + return response @@ -1365,6 +1365,18 @@ msgstr "У вас нет активной подписки для отмены" msgid "The recurring payment is successfully cancelled" msgstr "Автоплатежи успешно отключены" +#: payments/exceptions/subscription_recovery.py +msgid "Active payment method not found" +msgstr "Активный способ оплаты не найден" + +#: payments/exceptions/subscription_recovery.py +msgid "Subscription recovery is already in progress" +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,11 @@ +from django.utils.translation import gettext as _ + + +class ActivePaymentMethodNotFound(Exception): + def __str__(self) -> str: + return _('Active payment method not found') + + +class DuplicateRecoveryAttempt(Exception): + def __str__(self) -> str: + return _('Subscription recovery is already in progress') @@ -0,0 +1,4 @@ +from payments.exporters.invoice_daily_by_model import InvoiceDailyByModelExporter +from payments.exporters.payment_daily import PaymentDailyExporter + +__all__ = ['InvoiceDailyByModelExporter', 'PaymentDailyExporter'] @@ -0,0 +1,51 @@ +from collections import defaultdict +from datetime import date, datetime, time, timedelta + +from django.db.models import Count +from django.db.models.functions import TruncDate +from django.utils import timezone + +from lib.exporters import ExcelExporter +from payments.models import Invoice + + +class InvoiceDailyByModelExporter(ExcelExporter): + SHEET_TITLE = 'Invoices by model' + + def __init__(self, date_from: date, date_to: date): + self.date_from = date_from + self.date_to = date_to + self._days = [date_from + timedelta(days=offset) for offset in range((date_to - date_from).days + 1)] + + def get_headers(self): + return ['Модель', *[day.strftime('%d.%m.%Y') for day in self._days]] + + def get_filename(self) -> str: + return f'invoices_by_model_daily_{self.date_from}_{self.date_to}.xlsx' + + def get_rows(self): + tz = timezone.get_current_timezone() + start = timezone.make_aware(datetime.combine(self.date_from, time.min), tz) + end = timezone.make_aware( + datetime.combine(self.date_to + timedelta(days=1), time.min), + tz, + ) + + rows = ( + Invoice.objects.filter( + created_at__gte=start, + created_at__lt=end, + model__isnull=False, + ) + .annotate(day=TruncDate('created_at', tzinfo=tz)) + .values('model__slug', 'day') + .annotate(count=Count('id')) + .order_by('model__slug', 'day') + ) + + matrix: dict[str, dict[date, int]] = defaultdict(lambda: {day: 0 for day in self._days}) + for row in rows: + matrix[row['model__slug']][row['day']] = row['count'] + + for slug in sorted(matrix): + yield slug, *[matrix[slug][day] for day in self._days] @@ -0,0 +1,49 @@ +from datetime import date, datetime, time, timedelta +from decimal import Decimal + +from django.db import models +from django.db.models import Count, Sum +from django.db.models.functions import Coalesce, TruncDate +from django.utils import timezone + +from lib.exporters import ExcelExporter +from payments.models import Payment + + +class PaymentDailyExporter(ExcelExporter): + SHEET_TITLE = 'Payments by day' + + def __init__(self, date_from: date, date_to: date): + self.date_from = date_from + self.date_to = date_to + + def get_headers(self): + return ['Дата', 'Кол-во платежей', 'Сумма'] + + def get_filename(self) -> str: + return f'payments_daily_{self.date_from}_{self.date_to}.xlsx' + + def get_rows(self): + tz = timezone.get_current_timezone() + start = timezone.make_aware(datetime.combine(self.date_from, time.min), tz) + end = timezone.make_aware( + datetime.combine(self.date_to + timedelta(days=1), time.min), + tz, + ) + + rows = ( + Payment.objects.filter( + status=Payment.SUCCEEDED, + created_at__gte=start, + created_at__lt=end, + ) + .annotate(day=TruncDate('created_at', tzinfo=tz)) + .values('day') + .annotate( + count=Count('uid'), + total=Coalesce(Sum('amount'), Decimal(0), output_field=models.DecimalField()), + ) + .order_by('day') + ) + for row in rows: + yield row['day'].strftime('%d.%m.%Y'), row['count'], row['total'] @@ -0,0 +1,28 @@ +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_payment_id', + field=models.UUIDField( + blank=True, + null=True, + verbose_name='Last recovery payment id', + ), + ), + migrations.AddField( + model_name='paymentplanuserinfo', + name='recovery_locked_at', + field=models.DateTimeField( + blank=True, + null=True, + verbose_name='Recovery locked at', + ), + ), + ] @@ -0,0 +1,3 @@ +from payments.mixins.admin import DateRangeExportAdminMixin + +__all__ = ['DateRangeExportAdminMixin'] @@ -0,0 +1,52 @@ +from datetime import date, timedelta + +from django.contrib.admin import helpers +from django.http import HttpRequest, HttpResponse +from django.template.response import TemplateResponse +from django.utils import timezone + +from lib.exporters import BaseExporter + + +class DateRangeExportAdminMixin: + TEMPLATE = 'admin/payments/export_daily.html' + + def export_by_date_range( + self, + request: HttpRequest, + queryset, + *, + exporter_class: type[BaseExporter], + action_name: str, + title: str, + ) -> HttpResponse: + date_from = request.POST.get('date_from') or (timezone.localdate() - timedelta(days=6)).isoformat() + date_to = request.POST.get('date_to') or timezone.localdate().isoformat() + error = None + + if 'apply' in request.POST: + try: + parsed_from = date.fromisoformat(date_from) + parsed_to = date.fromisoformat(date_to) + except ValueError: + error = 'Некорректный формат даты.' + else: + if parsed_from > parsed_to: + error = 'Дата «С» не может быть позже «По».' + else: + return exporter_class(parsed_from, parsed_to).export() + + return TemplateResponse( + request, + self.TEMPLATE, + { + **self.admin_site.each_context(request), + 'title': title, + 'queryset': queryset, + 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, + 'action_name': action_name, + 'date_from': date_from, + 'date_to': date_to, + 'error': error, + }, + ) @@ -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,18 @@ 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')) + # FIXME: вынести в абстракцию попыток + last_recovery_payment_id = models.UUIDField( + blank=True, + null=True, + verbose_name=_('Last recovery payment id'), + ) + # FIXME: вынести в абстракцию попыток + recovery_locked_at = models.DateTimeField( + blank=True, + null=True, + verbose_name=_('Recovery locked at'), + ) current_token_balance = models.DecimalField( max_digits=100, decimal_places=10, verbose_name=_('Current balance') ) @@ -1,31 +1,26 @@ -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, + DuplicateRecoveryAttempt, ) +from payments.models import Invoice, Payment, PaymentMethod, PaymentPlan, PaymentPlanFeature from payments.schema import UserBalance from payments.schemas import ( ExpensesParamsSchema, @@ -33,11 +28,13 @@ from payments.schemas import ( NewSubscriptionSchema, PaymentLinkSchema, PaymentPlanSchema, + RestoreSubscriptionLockSchema, + RestoreSubscriptionSchema, ) 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 router = Router(auth=SyncAuthBearer(), tags=['payments']) @@ -114,6 +111,44 @@ def revoke_recurring_payment(request): return 200, {'detail': _('The recurring payment is successfully cancelled')} +@router.post( + 'restore-subscription', + tags=['payments/restore-subscription'], + response=RestoreSubscriptionSchema, +) +def restore_subscription(request): + payment_service = PaymentService(request.auth) + try: + payment = payment_service.restore_subscription() + except ActivePaymentMethodNotFound as exc: + raise HttpError(400, str(exc)) from exc + except DuplicateRecoveryAttempt as exc: + raise HttpError(403, str(exc)) 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 + + try: + if result := payment_service.yookassa_payment_polling(payment): + return RestoreSubscriptionSchema(**result) + if result := payment_service.db_payment_polling(payment.id): + return RestoreSubscriptionSchema(**result) + return RestoreSubscriptionSchema(ok=True, external=False, internal=False) + finally: + payment_service.release_subscription_recovery_lock() + + +@router.get( + 'restore-subscription/blocked', + tags=['payments/restore-subscription'], + response=RestoreSubscriptionLockSchema, +) +def get_restore_subscription_blocked(request): + return RestoreSubscriptionLockSchema( + blocked=PaymentService(request.auth).is_subscription_recovery_blocked(), + ) + + @router.get('expenses', tags=['payments/expenses'], response=list[ExpensesSchema]) def list_expenses(request, data: ExpensesParamsSchema = Query(...)): try: @@ -1,6 +1,7 @@ +import hashlib import logging -from datetime import timedelta - +import time +from datetime import datetime, timedelta from decimal import Decimal from uuid import UUID, uuid4 @@ -13,8 +14,13 @@ 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, + DuplicateRecoveryAttempt, +) 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 @@ -24,6 +30,8 @@ logger = logging.getLogger(__name__) class PaymentService: Configuration.account_id = settings.YOOKASSA_ACCOUNT_ID Configuration.secret_key = settings.YOOKASSA_SECRET_KEY + RECOVERY_LOCK_TTL = timedelta(minutes=2) + RECOVERY_BACKOFF_SECONDS = (0.25, 0.75, 1.0, 3.0) def __init__(self, user: CustomUserModel): self.user = user @@ -58,6 +66,126 @@ class PaymentService: ) return payment.confirmation.confirmation_url + def restore_subscription(self) -> YookassaPaymentResponse: + payment_method, generation = self._reserve_subscription_recovery() + plan = self.user.payment_plan.plan + try: + # FIXME: вынести сборку payload создания платежа в общий метод с create_payment_link + payment = YookassaPayment.create( + { + 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'payment_method_id': payment_method.payment_method_id, + 'receipt': { + 'customer': {'email': self.user.email}, + 'items': [ + { + 'description': str(plan), + 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'vat_code': 1, + 'quantity': '1', + } + ], + }, + 'description': str(self.user.uid), + 'capture': True, + 'metadata': { + 'plan_uid': str(plan.uid), + 'recovery_generation': generation, + }, + }, + idempotency_key=hashlib.sha256( + f'recovery:{self.user.uid}:{plan.uid}:{generation}'.encode() + ).hexdigest(), + ) + except Exception: + self.release_subscription_recovery_lock() + raise + logger.info( + 'Subscription recovery payment created: payment_id=%s email=%s method_uid=%s status=%s', + payment.id, + self.user.email, + payment_method.uid, + payment.status, + ) + return payment + + def yookassa_payment_polling(self, payment: YookassaPaymentResponse) -> dict[str, bool] | None: + current = payment + for delay in (0, *self.RECOVERY_BACKOFF_SECONDS): + if delay: + time.sleep(delay) + current = YookassaPayment.find_one(str(payment.id)) + if current.status == PaymentModel.SUCCEEDED: + return None + if current.status == PaymentModel.CANCELLED: + self._close_recovery_generation(current) + return {'ok': False, 'external': False, 'internal': False} + return {'ok': False, 'external': True, 'internal': False} + + def db_payment_polling(self, payment_id: UUID | str) -> dict[str, bool] | None: + for delay in (0, *self.RECOVERY_BACKOFF_SECONDS): + if delay: + time.sleep(delay) + if PaymentModel.objects.filter(uid=payment_id, status=PaymentModel.SUCCEEDED).exists(): + return None + return {'ok': False, 'external': False, 'internal': True} + + def is_subscription_recovery_blocked(self) -> bool: + locked_at = self.user.payment_plan.recovery_locked_at + return bool(locked_at) and not self._is_recovery_lock_stale(locked_at) + + def release_subscription_recovery_lock(self) -> None: + PaymentPlanUserInfo.objects.filter(pk=self.user.payment_plan.pk).update(recovery_locked_at=None) + + def _reserve_subscription_recovery(self) -> tuple[PaymentMethod, str]: + active_methods = self.user.payment_plan.active_methods + if not active_methods: + raise ActivePaymentMethodNotFound + + with transaction.atomic(): + info = PaymentPlanUserInfo.objects.select_for_update().get(pk=self.user.payment_plan.pk) + if info.recovery_locked_at and not self._is_recovery_lock_stale(info.recovery_locked_at): + logger.info('Duplicate subscription recovery skipped: email=%s', self.user.email) + raise DuplicateRecoveryAttempt + + info.recovery_locked_at = timezone.now() + info.save(update_fields=['recovery_locked_at']) + generation = str(info.last_recovery_payment_id or 'none') + + return active_methods[0], generation + + @classmethod + def _is_recovery_lock_stale(cls, locked_at: datetime) -> bool: + return locked_at <= timezone.now() - cls.RECOVERY_LOCK_TTL + + def _close_recovery_generation(self, payment: YookassaPaymentResponse) -> None: + # FIXME: улучшить механизм синхронизации, сделать механизм уведомлений в системе для + # отслеживания ивента пополнения баланса + metadata = payment.metadata or {} + payment_generation = metadata.get('recovery_generation') + if payment_generation is None: + return + if payment.status not in {PaymentModel.SUCCEEDED, PaymentModel.CANCELLED}: + return + + payment_generation = str(payment_generation) + try: + expected_last = None if payment_generation == 'none' else UUID(payment_generation) + except ValueError: + return + + updated = PaymentPlanUserInfo.objects.filter( + pk=self.user.payment_plan.pk, + last_recovery_payment_id=expected_last, + ).update(last_recovery_payment_id=UUID(str(payment.id))) + if not updated: + logger.info( + 'Stale recovery generation close skipped: payment_id=%s email=%s generation=%s', + payment.id, + self.user.email, + payment_generation, + ) + def do_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: from payments.services.payment_plan_service import PaymentPlanService @@ -65,6 +193,7 @@ class PaymentService: payment_instance, should_process = self.save_payment(payment) if not should_process: return payment_instance + self._close_recovery_generation(payment) logger.info( 'Processing payment webhook: payment_id=%s email=%s status=%s', payment.id, @@ -0,0 +1,34 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +
+{% endblock %} @@ -0,0 +1,407 @@ +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import patch +from uuid import UUID, uuid4 + +from django.utils import timezone + +from core import tests as core_tests +from payments.models import Payment, PaymentMethod, PaymentPlan, PaymentPlanUserInfo +from payments.services.payment_service import PaymentService + + +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, payment_id: str | None = None) -> SimpleNamespace: + return SimpleNamespace( + amount=SimpleNamespace(value=self.plan.price), + description=str(self.user.uid), + id=payment_id or str(uuid4()), + metadata={'plan_uid': str(self.plan.uid), 'recovery': True}, + status=status, + ) + + def _create_local_payment(self, payment_id: str) -> Payment: + return Payment.objects.create( + uid=payment_id, + user=self.user, + amount=self.plan.price, + plan=self.plan, + status=Payment.SUCCEEDED, + description=str(self.user.uid), + ) + + def _apply_recovery_webhook(self, payment: SimpleNamespace) -> None: + PaymentService(self.user)._close_recovery_generation(payment) + + 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.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_authorized_status_code(self, create_payment_mock, sleep_mock) -> None: + yookassa_payment = self._payment('succeeded') + create_payment_mock.return_value = yookassa_payment + self._create_local_payment(yookassa_payment.id) + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'ok': True, 'external': False, 'internal': False}) + sleep_mock.assert_not_called() + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_uses_primary_active_payment_method_first(self, create_payment_mock, sleep_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]) + yookassa_payment = self._payment('succeeded') + create_payment_mock.return_value = yookassa_payment + self._create_local_payment(yookassa_payment.id) + + self.post() + + payment_data = create_payment_mock.call_args.args[0] + self.assertEqual(payment_data['payment_method_id'], self.payment_method.payment_method_id) + self.assertEqual(payment_data['metadata']['recovery'], True) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_uses_most_recent_active_payment_method(self, create_payment_mock, sleep_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]) + yookassa_payment = self._payment('succeeded') + create_payment_mock.return_value = yookassa_payment + self._create_local_payment(yookassa_payment.id) + + 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.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_returns_not_ok_when_payment_is_canceled(self, create_payment_mock, sleep_mock) -> None: + create_payment_mock.return_value = self._payment('canceled') + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'ok': False, 'external': False, 'internal': False}) + sleep_mock.assert_not_called() + self.user.payment_plan.refresh_from_db() + self.assertIsNotNone(self.user.payment_plan.last_recovery_payment_id) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.find_one') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_returns_not_ok_when_payment_stays_pending( + self, + create_payment_mock, + find_one_mock, + sleep_mock, + ) -> None: + pending_payment = self._payment('pending') + create_payment_mock.return_value = pending_payment + find_one_mock.return_value = pending_payment + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'ok': False, 'external': True, 'internal': False}) + self.assertEqual(sleep_mock.call_count, 4) + self.assertEqual(find_one_mock.call_count, 4) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.find_one') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_returns_ok_when_pending_becomes_succeeded_and_local_payment_exists( + self, + create_payment_mock, + find_one_mock, + sleep_mock, + ) -> None: + pending_payment = self._payment('pending') + succeeded_payment = self._payment('succeeded', payment_id=pending_payment.id) + create_payment_mock.return_value = pending_payment + find_one_mock.return_value = succeeded_payment + self._create_local_payment(pending_payment.id) + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'ok': True, 'external': False, 'internal': False}) + sleep_mock.assert_called_once_with(0.25) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_returns_internal_true_when_local_payment_is_missing( + self, + create_payment_mock, + sleep_mock, + ) -> None: + create_payment_mock.return_value = self._payment('succeeded') + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'ok': False, 'external': False, 'internal': True}) + self.assertEqual(sleep_mock.call_count, 4) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_returns_forbidden_when_recovery_locked( + self, + create_payment_mock, + sleep_mock, + ) -> None: + self.user.payment_plan.recovery_locked_at = timezone.now() + self.user.payment_plan.save(update_fields=['recovery_locked_at']) + + response = self.post() + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json(), {'detail': 'Subscription recovery is already in progress'}) + create_payment_mock.assert_not_called() + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_releases_lock_after_request( + self, + create_payment_mock, + sleep_mock, + ) -> None: + yookassa_payment = self._payment('succeeded') + create_payment_mock.return_value = yookassa_payment + self._create_local_payment(yookassa_payment.id) + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.user.payment_plan.refresh_from_db() + self.assertIsNone(self.user.payment_plan.recovery_locked_at) + self.assertIsNone(self.user.payment_plan.last_recovery_payment_id) + + self._apply_recovery_webhook(yookassa_payment) + self.user.payment_plan.refresh_from_db() + self.assertEqual(self.user.payment_plan.last_recovery_payment_id, UUID(str(yookassa_payment.id))) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_allows_request_when_recovery_lock_is_stale( + self, + create_payment_mock, + sleep_mock, + ) -> None: + yookassa_payment = self._payment('succeeded') + create_payment_mock.return_value = yookassa_payment + self._create_local_payment(yookassa_payment.id) + self.user.payment_plan.recovery_locked_at = timezone.now() - timedelta(minutes=2) + self.user.payment_plan.save(update_fields=['recovery_locked_at']) + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'ok': True, 'external': False, 'internal': False}) + self.user.payment_plan.refresh_from_db() + self.assertIsNone(self.user.payment_plan.recovery_locked_at) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_retries_same_idempotency_key_when_local_is_missing( + self, + create_payment_mock, + sleep_mock, + ) -> None: + yookassa_payment = self._payment('succeeded') + create_payment_mock.return_value = yookassa_payment + + first_response = self.post() + self.user.payment_plan.refresh_from_db() + self.assertIsNone(self.user.payment_plan.last_recovery_payment_id) + self.assertIsNone(self.user.payment_plan.recovery_locked_at) + + self._create_local_payment(yookassa_payment.id) + second_response = self.post() + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(first_response.json(), {'ok': False, 'external': False, 'internal': True}) + self.assertEqual(second_response.status_code, 200) + self.assertEqual(second_response.json(), {'ok': True, 'external': False, 'internal': False}) + self.assertEqual(create_payment_mock.call_count, 2) + self.assertEqual( + create_payment_mock.call_args_list[0].kwargs['idempotency_key'], + create_payment_mock.call_args_list[1].kwargs['idempotency_key'], + ) + self.user.payment_plan.refresh_from_db() + self.assertIsNone(self.user.payment_plan.last_recovery_payment_id) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_webhook_closes_generation_after_internal_true( + self, + create_payment_mock, + sleep_mock, + ) -> None: + first_payment = self._payment('succeeded') + second_payment = self._payment('succeeded') + create_payment_mock.side_effect = [first_payment, second_payment] + + first_response = self.post() + self.assertEqual(first_response.json(), {'ok': False, 'external': False, 'internal': True}) + + self._create_local_payment(first_payment.id) + self._apply_recovery_webhook(first_payment) + self.user.payment_plan.refresh_from_db() + self.assertEqual(self.user.payment_plan.last_recovery_payment_id, UUID(str(first_payment.id))) + + self._create_local_payment(second_payment.id) + second_response = self.post() + + self.assertEqual(second_response.status_code, 200) + self.assertEqual(second_response.json(), {'ok': True, 'external': False, 'internal': False}) + self.assertEqual(create_payment_mock.call_count, 2) + self.assertNotEqual( + create_payment_mock.call_args_list[0].kwargs['idempotency_key'], + create_payment_mock.call_args_list[1].kwargs['idempotency_key'], + ) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_duplicate_webhook_skips_recovery_close( + self, + create_payment_mock, + sleep_mock, + ) -> None: + yookassa_payment = self._payment('succeeded') + create_payment_mock.return_value = yookassa_payment + self._create_local_payment(yookassa_payment.id) + + self.post() + self._apply_recovery_webhook(yookassa_payment) + self.user.payment_plan.refresh_from_db() + self.assertEqual(self.user.payment_plan.last_recovery_payment_id, UUID(str(yookassa_payment.id))) + + PaymentPlanUserInfo.objects.filter(pk=self.user.payment_plan.pk).update( + last_recovery_payment_id=None + ) + self.user.payment_plan.refresh_from_db() + self.assertIsNone(self.user.payment_plan.last_recovery_payment_id) + + PaymentService(self.user).do_payment(yookassa_payment) + self.user.payment_plan.refresh_from_db() + self.assertIsNone(self.user.payment_plan.last_recovery_payment_id) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_creates_new_payment_when_previous_recovery_already_applied( + self, + create_payment_mock, + sleep_mock, + ) -> None: + first_payment = self._payment('succeeded') + second_payment = self._payment('succeeded') + create_payment_mock.side_effect = [first_payment, second_payment] + self._create_local_payment(first_payment.id) + + first_response = self.post() + self._apply_recovery_webhook(first_payment) + self._create_local_payment(second_payment.id) + second_response = self.post() + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(first_response.json(), {'ok': True, 'external': False, 'internal': False}) + self.assertEqual(second_response.status_code, 200) + self.assertEqual(second_response.json(), {'ok': True, 'external': False, 'internal': False}) + self.assertEqual(create_payment_mock.call_count, 2) + self.assertNotEqual( + create_payment_mock.call_args_list[0].kwargs['idempotency_key'], + create_payment_mock.call_args_list[1].kwargs['idempotency_key'], + ) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_creates_new_payment_after_canceled_recovery( + self, + create_payment_mock, + sleep_mock, + ) -> None: + canceled_payment = self._payment('canceled') + succeeded_payment = self._payment('succeeded') + create_payment_mock.side_effect = [canceled_payment, succeeded_payment] + self._create_local_payment(succeeded_payment.id) + + first_response = self.post() + self.user.payment_plan.refresh_from_db() + self.assertEqual(self.user.payment_plan.last_recovery_payment_id, UUID(str(canceled_payment.id))) + self.assertIsNone(self.user.payment_plan.recovery_locked_at) + + second_response = self.post() + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(first_response.json(), {'ok': False, 'external': False, 'internal': False}) + self.assertEqual(second_response.status_code, 200) + self.assertEqual(second_response.json(), {'ok': True, 'external': False, 'internal': False}) + self.assertEqual(create_payment_mock.call_count, 2) + self.assertNotEqual( + create_payment_mock.call_args_list[0].kwargs['idempotency_key'], + create_payment_mock.call_args_list[1].kwargs['idempotency_key'], + ) + + def test_restore_subscription_blocked_endpoint(self) -> None: + response = self.get(endpoint='/api/v1/payments/restore-subscription/blocked') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'blocked': False}) + + self.user.payment_plan.recovery_locked_at = timezone.now() + self.user.payment_plan.save(update_fields=['recovery_locked_at']) + + response = self.get(endpoint='/api/v1/payments/restore-subscription/blocked') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'blocked': True}) @@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _ from ordered_model.admin import OrderedInlineModelAdminMixin, OrderedModelAdmin from authentication.admin import CustomUserModelAdmin +from payments.mixins import DateRangeExportAdminMixin from payments.models import ( Invoice, Payment, @@ -18,15 +19,17 @@ from payments.models import ( PromoCodeActivation, PaymentMethod, ) +from payments.exporters import InvoiceDailyByModelExporter, PaymentDailyExporter from payments.models.attempt import PaymentAttempt from payments.models.referral_account import ReferralAccount, ReferralInvite @admin.register(Payment) -class PaymentAdmin(admin.ModelAdmin): +class PaymentAdmin(DateRangeExportAdminMixin, admin.ModelAdmin): list_display = ['uid', '_user', 'created_at', 'status'] raw_id_fields = ['user'] date_hierarchy = 'created_at' + actions = ['export_daily_totals'] search_fields = [ 'uid', @@ -41,6 +44,16 @@ class PaymentAdmin(admin.ModelAdmin): return _('Missing') return str(obj.user) + @admin.action(description='Выгрузить суммы платежей по дням') + def export_daily_totals(self, request, queryset): + return self.export_by_date_range( + request, + queryset, + exporter_class=PaymentDailyExporter, + action_name='export_daily_totals', + title='Выгрузить суммы платежей по дням', + ) + @admin.register(PaymentPlan) class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): @@ -151,18 +164,16 @@ class PaymentAttemptAdmin(admin.ModelAdmin): @admin.register(Invoice) -class InvoiceAdmin(admin.ModelAdmin): +class InvoiceAdmin(DateRangeExportAdminMixin, admin.ModelAdmin): list_display = ['_user', '_model'] - raw_id_fields = ['user', 'message', 'model'] - search_fields = [ 'user__email', 'user__host_account__company_name', 'user__business_account__parent_company__company_name', ] - search_help_text = _('You can search by user email, exacted company name') + actions = ['export_daily_by_model'] @admin.display(description=_('User')) def _user(self, obj): @@ -174,6 +185,16 @@ class InvoiceAdmin(admin.ModelAdmin): def _model(self, obj): return str(obj.model) + @admin.action(description='Выгрузить кол-во инвойсов по моделям и дням') + def export_daily_by_model(self, request, queryset): + return self.export_by_date_range( + request, + queryset, + exporter_class=InvoiceDailyByModelExporter, + action_name='export_daily_by_model', + title='Выгрузить кол-во инвойсов по моделям и дням', + ) + @admin.register(PromoCode) class PromoCodeAdmin(admin.ModelAdmin): @@ -49,6 +49,16 @@ class PaymentLinkSchema(Schema): payment_url: str +class RestoreSubscriptionSchema(Schema): + ok: bool + external: bool + internal: bool + + +class RestoreSubscriptionLockSchema(Schema): + blocked: bool + + class UserPlanDetailSchema(Schema): uid: UUID plan: PaymentPlanSchema @@ -84,5 +94,3 @@ class ExpensesParamsSchema(Schema): class ExpensesSchema(Schema): source: str amount: condecimal(max_digits=10, decimal_places=2) - -