blender-studio/subscriptions/tasks.py
Anna Sirota ec2ce855b9 Stripe: add webhooks; only create subscription on successful payment
This also updates plan variations fixture (and historical migrations) to
match what is currently in production.
2024-06-18 18:51:19 +02:00

371 lines
14 KiB
Python

"""Subscriptions tasks, such as sending of the emails."""
from typing import Tuple, Dict, Any
import logging
from background_task import background
from background_task.tasks import TaskSchedule
from django.conf import settings
from django.template import loader
import django.core.mail
from looper.stripe_utils import (
create_active_subscription_from_payment_intent,
upsert_subscription_payment_method_from_setup_intent,
process_payment_intent_for_subscription_order,
)
import looper.models
import looper.signals
from blog.models import Post
from common import mailgun
from common.queries import get_latest_trainings_and_production_lessons
from emails.util import get_template_context, absolute_url, is_noreply
import subscriptions.queries as queries
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
def _construct_subscription_mail(mail_name: str, context: Dict[str, Any]) -> Tuple[str, str, str]:
"""Construct a mail about a subscription.
:return: tuple (html, text, subject)
"""
base_path = 'subscriptions/emails'
subj_tmpl, html_tmpl, txt_tmpl = (
f'{base_path}/{mail_name}_subject.txt',
f'{base_path}/{mail_name}.html',
f'{base_path}/{mail_name}.txt',
)
subject: str = loader.render_to_string(subj_tmpl, context)
context['subject'] = subject.strip()
email_body_html = loader.render_to_string(html_tmpl, context)
email_body_txt = loader.render_to_string(txt_tmpl, context)
return email_body_html, email_body_txt, context['subject']
@background()
def send_mail_bank_transfer_required(subscription_id: int):
"""Send out an email notifying about the required bank transfer payment."""
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
customer = subscription.customer
user = customer.user
email = customer.billing_address.email or user.email
assert (
email
), f'Cannot send notification about bank payment for subscription {subscription.pk}: no email'
if is_noreply(email):
logger.debug(
'Not sending subscription-bank-info notification to no-reply address %s', email
)
return
# An Unsubscribe record will prevent this message from being delivered by Mailgun.
# This records might have been previously created for an existing account.
mailgun.delete_unsubscribe_record(email)
logger.debug('Sending subscription-bank-info notification to %s', email)
order = subscription.latest_order()
assert order, "Can't send a notificaton about bank transfer without an existing order"
context = {
'user': user,
'subscription': subscription,
'order': order,
**get_template_context(),
}
mail_name = 'bank_transfer_required'
email_body_html, email_body_txt, subject = _construct_subscription_mail(mail_name, context)
django.core.mail.send_mail(
subject,
message=email_body_txt,
html_message=email_body_html,
from_email=None, # just use the configured default From-address.
recipient_list=[email],
fail_silently=False,
)
logger.info('Sent notification about bank transfer to %s', email)
@background()
def send_mail_subscription_status_changed(subscription_id: int):
"""Send out an email notifying about the activated subscription."""
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
customer = subscription.customer
user = customer.user
email = customer.billing_address.email or user.email
assert email, f'Cannot send notification about subscription {subscription.pk} status: no email'
if is_noreply(email):
raise
logger.debug('Not sending subscription-changed notification to no-reply address %s', email)
return
# An Unsubscribe record will prevent this message from being delivered by Mailgun.
# This records might have been previously created for an existing account.
mailgun.delete_unsubscribe_record(email)
logger.debug('Sending subscription-changed notification to %s', email)
if subscription.status == 'active':
verb = 'activated'
else:
verb = 'deactivated'
context = {
'user': user,
'subscription': subscription,
'verb': verb,
**get_template_context(),
}
mail_name = f'subscription_{verb}'
email_body_html, email_body_txt, subject = _construct_subscription_mail(mail_name, context)
django.core.mail.send_mail(
subject,
message=email_body_txt,
html_message=email_body_html,
from_email=None, # just use the configured default From-address.
recipient_list=[email],
fail_silently=False,
)
logger.info('Sent subscription-changed notification to %s', email)
@background()
def send_mail_automatic_payment_performed(order_id: int, transaction_id: int):
"""Send out an email notifying about the soft-failed payment."""
order = looper.models.Order.objects.get(pk=order_id)
transaction = looper.models.Transaction.objects.get(pk=transaction_id)
customer = order.customer
user = customer.user
email = customer.billing_address.email or user.email
logger.debug('Sending %r notification to %s', order.status, email)
# An Unsubscribe record will prevent this message from being delivered by Mailgun.
# This records might have been previously created for an existing account.
mailgun.delete_unsubscribe_record(email)
subscription = order.subscription
pay_url = absolute_url('subscriptions:pay-existing-order', kwargs={'order_id': order.pk})
receipt_url = absolute_url('subscriptions:receipt', kwargs={'order_id': order.pk})
context = {
'user': user,
'email': email,
'order': order,
'subscription': subscription,
'pay_url': pay_url,
'receipt_url': receipt_url,
'failure_message': transaction.failure_message,
'payment_method': transaction.payment_method.recognisable_name,
'maximum_collection_attemps': settings.LOOPER_CLOCK_MAX_AUTO_ATTEMPTS,
**get_template_context(),
}
mail_name = f'payment_{order.status}'
email_body_html, email_body_txt, subject = _construct_subscription_mail(mail_name, context)
django.core.mail.send_mail(
subject,
message=email_body_txt,
html_message=email_body_html,
from_email=None, # just use the configured default From-address.
recipient_list=[email],
fail_silently=False,
)
logger.info('Sent %r notification to %s', order.status, email)
@background()
def send_mail_managed_subscription_notification(subscription_id: int):
"""Send out an email notifying a manager about an expiring managed subscription."""
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
email = settings.LOOPER_MANAGER_MAIL
logger.debug(
'Notifying %s about managed subscription %r passing its next_payment date',
email,
subscription.pk,
)
customer = subscription.customer
user = customer.user
admin_url = absolute_url(
'admin:looper_subscription_change',
kwargs={'object_id': subscription.id},
)
context = {
'user': user,
'subscription': subscription,
'admin_url': admin_url,
**get_template_context(),
}
mail_name = 'managed_notification'
email_body_html, email_body_txt, subject = _construct_subscription_mail(mail_name, context)
django.core.mail.send_mail(
subject,
message=email_body_txt,
html_message=email_body_html,
from_email=None, # just use the configured default From-address.
recipient_list=[email],
fail_silently=False,
)
logger.info(
'Notified %s about managed subscription %r passing its next_payment date',
email,
subscription.pk,
)
@background()
def send_mail_subscription_expired(subscription_id: int):
"""Send out an email notifying about an expired subscription."""
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
customer = subscription.customer
user = customer.user
assert (
subscription.status == 'expired'
), f'Expected expired, got "{subscription.status} (pk={subscription_id})"'
# Only send a "subscription expired" email when there are no other active subscriptions
if queries.has_active_subscription(user):
logger.error(
'Not sending subscription-expired notification: pk=%s has other active subscriptions',
user.pk,
)
return
# We use account's email here, that email is most likely the same as Blender ID's email
# they'll use to login into the platform
email = user.email
assert email, f'Cannot send notification about subscription {subscription.pk} status: no email'
if is_noreply(email):
raise
logger.debug('Not sending subscription-expired notification to no-reply address %s', email)
return
# An Unsubscribe record will prevent this message from being delivered by Mailgun.
# This records might have been previously created for an existing account.
mailgun.delete_unsubscribe_record(email)
logger.debug('Sending subscription-expired notification to %s', email)
context = {
'user': user,
'subscription': subscription,
'latest_trainings': get_latest_trainings_and_production_lessons(),
'latest_posts': Post.objects.filter(is_published=True)[:5],
**get_template_context(),
}
mail_name = 'subscription_expired'
email_body_html, email_body_txt, subject = _construct_subscription_mail(mail_name, context)
django.core.mail.send_mail(
subject,
message=email_body_txt,
html_message=email_body_html,
from_email=None, # just use the configured default From-address.
recipient_list=[email],
fail_silently=False,
)
logger.info('Sent subscription-expired notification to %s', email)
@background()
def send_mail_no_payment_method(order_id: int):
"""Notify about [soft-]failed automatic collection of a subscription w/o a payment method."""
order = looper.models.Order.objects.get(pk=order_id)
assert (
order.collection_method == 'automatic'
), 'send_mail_no_payment_method expects automatic order'
assert (
order.subscription.collection_method == 'automatic'
), 'send_mail_no_payment_method expects automatic subscription'
assert 'fail' in order.status, f'Unexpected order pk={order_id} status: {order.status}'
customer = order.customer
user = customer.user
email = customer.billing_address.email or user.email
logger.debug('Sending %r notification to %s', order.status, email)
# An Unsubscribe record will prevent this message from being delivered by Mailgun.
# This records might have been previously created for an existing account.
mailgun.delete_unsubscribe_record(email)
subscription = order.subscription
pay_url = absolute_url('subscriptions:pay-existing-order', kwargs={'order_id': order.pk})
receipt_url = absolute_url('subscriptions:receipt', kwargs={'order_id': order.pk})
context = {
'user': user,
'email': email,
'order': order,
'subscription': subscription,
'pay_url': pay_url,
'receipt_url': receipt_url,
'failure_message': 'Unsupported payment method, please update subscription',
'payment_method': 'Unknown',
'maximum_collection_attemps': settings.LOOPER_CLOCK_MAX_AUTO_ATTEMPTS,
**get_template_context(),
}
mail_name = f'payment_{order.status}'
email_body_html, email_body_txt, subject = _construct_subscription_mail(mail_name, context)
django.core.mail.send_mail(
subject,
message=email_body_txt,
html_message=email_body_html,
from_email=None, # just use the configured default From-address.
recipient_list=[email],
fail_silently=False,
)
logger.info('Sent %r notification to %s', order.status, email)
# Delay handling of this event to avoid racing with the synchronous success page
@background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING, 'run_at': 30})
def handle_payment_intent_succeeded(payload: str):
"""Handle payload of payment_intent.succeeded event."""
gateway = looper.models.Gateway.objects.get(name='stripe')
event = gateway.provider.construct_event(payload)
payment_intent_id = event.data.object.id
payment_intent = gateway.provider.retrieve_payment_intent(payment_intent_id)
metadata = payment_intent.get('metadata', {})
plan_variation_id = metadata.get('plan_variation_id', None)
order_id = metadata.get('order_id', None)
if plan_variation_id:
create_active_subscription_from_payment_intent(payload, plan_variation_id)
elif order_id:
order = looper.models.Order.objects.get(pk=order_id)
trans = process_payment_intent_for_subscription_order(payment_intent, order)
logger.info(
'Created transaction pk=%d for order pk=%d from payment intent %s',
trans.pk,
order.pk,
payment_intent_id,
)
else:
logger.error(
'Payment intent %s has neither plan_variation_id nor order_id in metadata',
payment_intent_id,
)
# Delay handling of this event to avoid racing with the synchronous success page
@background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING, 'run_at': 30})
def handle_setup_intent_succeeded(payload: str):
"""Handle payload of setup_intent.succeeded event."""
return upsert_subscription_payment_method_from_setup_intent(payload)