@@ -1,6 +1,5 @@ import logging from datetime import datetime -from decimal import Decimal from typing import Any, Sequence import dns.resolver @@ -8,6 +7,7 @@ from django.conf import settings from django.core.mail import EmailMessage, send_mail from django.template import TemplateDoesNotExist from django.template.loader import get_template +from django.utils import timezone from django.utils.html import strip_tags from django.utils.safestring import SafeString from django.utils.translation import gettext_lazy as _ @@ -166,22 +166,65 @@ class EmailService: ) @classmethod - def send_revoke_recurring_email(cls, email: str) -> None: + def _subscription_greeting(cls, user: CustomUserModel) -> str: + if user.account_type == 'business_host': + return user.email + return f'{user.first_name} {user.last_name}'.strip() + + @classmethod + def send_revoke_recurring_email(cls, user: CustomUserModel) -> None: html_message = cls._render_letter_template( - template_name='payments/revoke_recurring_email', context={} + template_name='payments/revoke_recurring_email', + context={'greeting': cls._subscription_greeting(user)}, ) - cls.send_email( - 'Отмена подписки на платформе AIR', html_message, (email,) + cls.send_email('Отмена подписки на платформе AIR', html_message, (user.email,)) + logger.info('Revoke recurring email sent: email=%s', user.email) + + @classmethod + def send_failed_subscription_renewal_email(cls, user: CustomUserModel) -> None: + html_message = cls._render_letter_template( + template_name='payments/failed_subscription_renewal_email', + context={'greeting': cls._subscription_greeting(user)}, + ) + cls.send_email('Не удалось продлить подписку на AIR', html_message, (user.email,)) + logger.info('Failed subscription renewal email sent: email=%s', user.email) + + @classmethod + def send_account_deleted_email(cls, user: CustomUserModel, *, subscription_cancelled: bool) -> None: + html_message = cls._render_letter_template( + template_name='payments/account_deleted_email', + context={ + 'greeting': cls._subscription_greeting(user), + 'subscription_cancelled': subscription_cancelled, + }, + ) + cls.send_email('Аккаунт успешно удален', html_message, (user.email,)) + logger.info( + 'Account deleted email sent: email=%s subscription_cancelled=%s', + user.email, + subscription_cancelled, ) - logger.info('Revoke recurring email sent: email=%s', email) @classmethod def send_failed_recurring_charge_email(cls, email: str) -> None: html_message = cls._render_letter_template( template_name='payments/failed_recurring_charge_email', context={} ) - cls.send_email( - 'Не удалось списать оплату', html_message, (email,) - ) + cls.send_email('Не удалось списать оплату', html_message, (email,)) logger.info('Failed recurring charge email sent: email=%s', email) + @classmethod + def send_successful_recurring_email(cls, user: CustomUserModel, next_payment_at: datetime) -> None: + html_message = cls._render_letter_template( + template_name='payments/successful_recurring_email', + context={ + 'greeting': cls._subscription_greeting(user), + 'next_payment_at': timezone.localtime(next_payment_at).strftime('%d.%m.%Y'), + }, + ) + cls.send_email( + 'Ваша подписка на AIR продлена автоматически', + html_message, + (user.email,), + ) + logger.info('Successful recurring email sent: email=%s', user.email) @@ -4,7 +4,7 @@ from uuid import UUID from django.contrib.auth import authenticate, login, logout from django.db.models import Q, QuerySet -from django.db.transaction import atomic +from django.db.transaction import atomic, on_commit from django.utils.translation import gettext_lazy as _ from rest_framework.request import Request @@ -279,7 +279,7 @@ class UserService: if self.user.account_type in ('business_admin', 'business_security', 'business_account'): self.user.business_account.delete() - PaymentMethodService(self.user).deactivate_payment_methods() + deactivated_count = PaymentMethodService(self.user).deactivate_payment_methods(notify=None) free_plan = PaymentPlanSelector(self.user).get_free_plan( corporate=self.user.payment_plan.plan.is_corporate ) @@ -293,6 +293,17 @@ class UserService: self.user.is_confirmed = False self.user.save() + user = self.user + subscription_cancelled = deactivated_count > 0 + + def _send_account_deleted_email(): + try: + EmailService.send_account_deleted_email(user, subscription_cancelled=subscription_cancelled) + except Exception: + logger.exception('Failed to send account deleted email') + + on_commit(_send_account_deleted_email) + @classmethod def exists_in_whitelist(cls, request: Request) -> bool: return PolicyWhitelist.objects.filter(emails__contains=[request.query_params['email']]).exists() @@ -103,7 +103,7 @@ def handle_yookassa_webhook(request): @router.post('revoke-recurring-payment', tags=['payments/revoke-recurring-payment']) def revoke_recurring_payment(request): - deactivate_count = PaymentMethodService(request.auth).deactivate_payment_methods() + deactivate_count = PaymentMethodService(request.auth).deactivate_payment_methods(notify='revoke') logger.info( 'Recurring payment revoked by user: email=%s deactivated_methods=%s', request.auth.email, @@ -1,4 +1,5 @@ import logging +from typing import Literal from django.conf import settings from django.db import transaction @@ -87,19 +88,26 @@ class PaymentMethodService: return True return False - def deactivate_payment_methods(self) -> int: + def deactivate_payment_methods( + self, *, notify: Literal['revoke', 'failed_renewal'] | None = 'failed_renewal' + ) -> int: + # FIXME deactivated = ( PaymentMethod.objects.filter(user_plan_info=self.user.payment_plan) .filter(Q(active=True) | Q(primary=True)) .update(active=False, primary=False) ) - if deactivated: + if deactivated and notify is not None: + user = self.user def _send(): try: - EmailService.send_revoke_recurring_email(self.user.email) + if notify == 'revoke': + EmailService.send_revoke_recurring_email(user) + else: + EmailService.send_failed_subscription_renewal_email(user) except Exception: - logger.exception('Failed to send revoke recurring email') + logger.exception('Failed to send subscription deactivation email') transaction.on_commit(_send) return deactivated @@ -80,7 +80,7 @@ class PaymentPlanService: plan = pp.plan if plan.price <= 0: return False - if plan.individual or plan.is_corporate or pp.next_payment_at is None: + if plan.individual or pp.next_payment_at is None: return True if hasattr(pp, 'primary_methods'): @@ -109,4 +109,4 @@ class PaymentPlanService: self.user.plan if self.has_full_access() else PaymentPlanSelector(self.user).get_free_plan(corporate=self.user.plan.is_corporate) - ) \ No newline at end of file + ) @@ -1,4 +1,3 @@ -import hashlib import logging from datetime import timedelta @@ -73,10 +72,9 @@ class PaymentService: payment.status, ) if payment.status == 'succeeded': - buying_tokens = self._calculate_buying_tokens( - payment_instance.plan, payment.metadata.get('recurring', False) - ) - self._handle_succeeded_payment(payment, payment_instance.plan) + is_recurring = bool(payment.metadata.get('recurring', False)) + buying_tokens = self._calculate_buying_tokens(payment_instance.plan, is_recurring) + self._handle_succeeded_payment(payment, payment_instance.plan, is_recurring) 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) @@ -98,12 +96,14 @@ class PaymentService: return plan.tokens_per_plan return self.user.payment_plan.current_token_balance + plan.tokens_per_plan - def _handle_succeeded_payment(self, payment: YookassaPaymentResponse, plan: PaymentPlan) -> None: + def _handle_succeeded_payment( + self, payment: YookassaPaymentResponse, plan: PaymentPlan, recurring: bool = False + ) -> None: + # FIXME if payment.payment_method.saved and not plan.individual: payment_method = PaymentMethodService(self.user).add_payment_method(payment.payment_method) - PaymentPlanUserInfo.objects.filter(user=self.user).update( - next_payment_at=timezone.now() + timedelta(days=30) - ) + next_payment_at = timezone.now() + timedelta(days=30) + PaymentPlanUserInfo.objects.filter(user=self.user).update(next_payment_at=next_payment_at) logger.info( 'Recurring payment method saved: email=%s method_uid=%s next_payment_at_set=true', self.user.email, @@ -111,24 +111,35 @@ class PaymentService: ) else: if not plan.individual: - PaymentPlanUserInfo.objects.filter(user=self.user).update( - next_payment_at=timezone.now() + timedelta(days=30) - ) + next_payment_at = timezone.now() + timedelta(days=30) + PaymentPlanUserInfo.objects.filter(user=self.user).update(next_payment_at=next_payment_at) logger.info( 'Recurring schedule updated without saved method: email=%s next_payment_at_set=true', self.user.email, ) else: + next_payment_at = None PaymentPlanUserInfo.objects.filter(user=self.user).update(next_payment_at=None) logger.info( 'Recurring schedule cleared: email=%s reason=individual_plan', self.user.email, ) - PaymentMethodService(self.user).deactivate_payment_methods() + PaymentMethodService(self.user).deactivate_payment_methods(notify=None) logger.info( 'Recurring payment methods deactivated after succeeded payment: email=%s', self.user.email ) + if recurring and next_payment_at is not None: + user = self.user + + def _send_successful_recurring_email(): + try: + EmailService.send_successful_recurring_email(user, next_payment_at) + except Exception: + logger.exception('Failed to send successful recurring email') + + transaction.on_commit(_send_successful_recurring_email) + def _handle_canceled_payment(self, payment: YookassaPaymentResponse) -> None: from payments.services.payment_plan_service import PaymentPlanService @@ -0,0 +1,21 @@ + + + + + Аккаунт успешно удален + + +

Здравствуйте, {{ greeting }}!

+ + {% if subscription_cancelled %} +

Аккаунт успешно удален, подписка была отменена автоматически.

+ {% else %} +

Аккаунт успешно удален.

+ {% endif %} + +

Спасибо, что были с нами!

+ +

С уважением,
+ Команда AIR

+ + @@ -0,0 +1,19 @@ + + + + + Не удалось продлить подписку на AIR + + +

Здравствуйте, {{ greeting }}!

+ +

Нам не удалось продлить вашу подписку на платформе AIR. Вы можете вновь оформить подписку самостоятельно, + перейдя по ссылке
+ https://app.air.fail

+ +

Спасибо, что остаётесь с нами!

+ +

С уважением,
+ Команда AIR

+ + @@ -1,12 +1,21 @@ - + Отмена подписки на платформе AIR -

Подписка отменена. Доступ ко всем возможностям сохранится до окончания оплаченного периода. Никаких дополнительных - списаний не будет.

-

Спасибо, что воспользовались нашим маркетплейсом нейросетей.

+

Здравствуйте, {{ greeting }}!

+ +

Подписка успешно отменена.

+ +

Что это значит для вас:

+

— Доступ ко всем функциям платформы сохранится до окончания оплаченного периода;
+ — Никаких дополнительных списаний производится не будет.

+ +

Спасибо, что остаётесь с нами!

+ +

С уважением,
+ Команда AIR

- \ No newline at end of file + @@ -0,0 +1,23 @@ + + + + + Ваша подписка на AIR продлена автоматически + + +

Здравствуйте, {{ greeting }}!

+ +

Напоминаем, что срок действия вашей подписки на платформе AIR подошел к концу. Согласно условиям вашего тарифного + плана, подписка продлена автоматически до {{ next_payment_at }}.

+ +

Что это значит для вас:

+

— Доступ ко всем функциям платформы сохранится без перерыва;
+ — Вам не нужно предпринимать никаких действий — продление произойдёт автоматически;
+ — Условия тарифа остаются прежними.

+ +

Спасибо, что остаётесь с нами!

+ +

С уважением,
+ Команда AIR

+ + @@ -30,4 +30,4 @@ def clear_recurrent_on_individual_plan_assignment( if not instance.plan.individual: return PaymentPlanUserInfo.objects.filter(pk=instance.pk).update(next_payment_at=None) - PaymentMethodService(instance.user).deactivate_payment_methods() \ No newline at end of file + PaymentMethodService(instance.user).deactivate_payment_methods(notify=None) \ No newline at end of file @@ -50,7 +50,6 @@ def execute_recurring_payments() -> None: next_payment_at__lte=timezone.now(), plan__price__gt=0, plan__individual=False, - plan__is_corporate=False, user__is_deleted=False, methods__primary=True, methods__active=True, @@ -133,7 +132,6 @@ def revoke_recurring_payments() -> None: next_payment_at__lte=timezone.now(), plan__price__gt=0, plan__individual=False, - plan__is_corporate=False, ) .exclude(methods__primary=True, methods__active=True) .distinct() @@ -141,24 +139,28 @@ def revoke_recurring_payments() -> None: ) canceled_count = 0 - - free_regular_plan = PaymentPlan.objects.get(price=0, is_corporate=False) - qs_iter = qs.values_list('uid', flat=True).iterator(chunk_size=CHUNK_SIZE) - while uids := list(islice(qs_iter, CHUNK_SIZE)): - with transaction.atomic(): - canceled_count += ( - qs.filter(uid__in=uids) - .exclude( - methods__primary=True, - methods__active=True, - ) - .distinct() - .update( - next_payment_at=None, - plan_id=free_regular_plan.pk, - current_token_balance=0, + free_plans = { + False: PaymentPlan.objects.get(price=0, is_corporate=False), + True: PaymentPlan.objects.get(price=0, is_corporate=True), + } + for is_corporate, free_plan in free_plans.items(): + corp_qs = qs.filter(plan__is_corporate=is_corporate) + qs_iter = corp_qs.values_list('uid', flat=True).iterator(chunk_size=CHUNK_SIZE) + while uids := list(islice(qs_iter, CHUNK_SIZE)): + with transaction.atomic(): + canceled_count += ( + corp_qs.filter(uid__in=uids) + .exclude( + methods__primary=True, + methods__active=True, + ) + .distinct() + .update( + next_payment_at=None, + plan_id=free_plan.pk, + current_token_balance=0, + ) ) - ) - logger.info('Revoke recurring finished: free_regular=%s', canceled_count) + logger.info('Revoke recurring finished: canceled=%s', canceled_count) finally: cache.delete(lock_key)