@@ -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
Здравствуйте, {{ greeting }}!
+ +Нам не удалось продлить вашу подписку на платформе AIR. Вы можете вновь оформить подписку самостоятельно,
+ перейдя по ссылке
+ https://app.air.fail
Спасибо, что остаётесь с нами!
+ +С уважением,
+ Команда AIR
Подписка отменена. Доступ ко всем возможностям сохранится до окончания оплаченного периода. Никаких дополнительных - списаний не будет.
-Спасибо, что воспользовались нашим маркетплейсом нейросетей.
+Здравствуйте, {{ greeting }}!
+ +Подписка успешно отменена.
+ +Что это значит для вас:
+— Доступ ко всем функциям платформы сохранится до окончания оплаченного периода;
+ — Никаких дополнительных списаний производится не будет.
Спасибо, что остаётесь с нами!
+ +С уважением,
+ Команда AIR
Здравствуйте, {{ greeting }}!
+ +Напоминаем, что срок действия вашей подписки на платформе AIR подошел к концу. Согласно условиям вашего тарифного + плана, подписка продлена автоматически до {{ next_payment_at }}.
+ +Что это значит для вас:
+— Доступ ко всем функциям платформы сохранится без перерыва;
+ — Вам не нужно предпринимать никаких действий — продление произойдёт автоматически;
+ — Условия тарифа остаются прежними.
Спасибо, что остаётесь с нами!
+ +С уважением,
+ Команда AIR