blender-studio/subscriptions/views/join.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

222 lines
9.5 KiB
Python

"""Views handling subscription management."""
import logging
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction
from django.shortcuts import redirect, get_object_or_404
from django.urls import reverse
from django.views.generic import FormView
import looper.gateways
import looper.middleware
import looper.models
import looper.money
import looper.taxes
from subscriptions.forms import PaymentForm
from subscriptions.middleware import preferred_currency_for_country_code
from subscriptions.queries import should_redirect_to_billing
from subscriptions.signals import subscription_created_needs_payment
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
class JoinView(LoginRequiredMixin, FormView):
"""Fill in billing details and initiate Stripe checkout session."""
template_name = 'subscriptions/join/billing_address.html'
form_class = PaymentForm
def _get_existing_subscription(self):
# Exclude cancelled subscriptions because they cannot transition to active
existing_subscriptions = self.request.user.customer.subscription_set.exclude(
status__in=looper.models.Subscription._CANCELLED_STATUSES
)
return existing_subscriptions.first()
def _set_preferred_currency_and_redirect(self):
# If no country is set in the existing address, use GeoIP's
geoip_country = self.request.session.get(looper.middleware.COUNTRY_CODE_SESSION_KEY)
if geoip_country and (not self.customer or not self.customer.billing_address.country):
country = geoip_country
else:
country = self.customer.billing_address.country
currency = preferred_currency_for_country_code(country)
if self.plan_variation.currency != currency:
# If variation's currency doesn't match, redirect to another plan variation
plan_variation = self.plan_variation.in_other_currency(currency)
self.request.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY] = currency
self.request.session.modified = True
return redirect(
'subscriptions:join-billing-details', plan_variation_id=plan_variation.pk
)
return None # nothing to do, no need to redirect
def dispatch(self, request, *args, **kwargs):
"""Redirect to login or to billing, or prepare plan variation."""
if not request.user.is_authenticated:
return self.handle_no_permission()
if should_redirect_to_billing(request.user):
return redirect('user-settings-billing')
plan_variation_id = kwargs['plan_variation_id']
self.plan_variation = get_object_or_404(
looper.models.PlanVariation,
pk=plan_variation_id,
is_active=True,
)
self.user = request.user
self.customer = self.user.customer
response_redirect = self._set_preferred_currency_and_redirect()
if response_redirect:
return response_redirect
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self, *args, **kwargs):
"""Pass request to the form."""
form_kwargs = super().get_form_kwargs(*args, **kwargs)
form_kwargs.update(
{
'request': self.request,
'plan_variation': self.plan_variation,
'instance': self.customer.billing_address,
}
)
return form_kwargs
def get_context_data(self, **kwargs) -> dict:
"""Add existing subscription to the view and the context."""
return {
**super().get_context_data(**kwargs),
'current_plan_variation': self.plan_variation,
}
@transaction.atomic
def _fetch_or_create_order(self, gateway: looper.models.Gateway) -> looper.models.Order:
subscription = self._get_existing_subscription()
is_new = False
if not subscription:
subscription = looper.models.Subscription(customer=self.customer)
is_new = True
logger.info('%s subscription pk=%r', is_new and 'Created' or 'Updated', subscription.pk)
collection_method = self.plan_variation.collection_method
if collection_method not in gateway.provider.supported_collection_methods:
# FIXME(anna): this breaks plan selector because collection method
# might not match the one selected by the customer.
collection_method = next(iter(gateway.provider.supported_collection_methods))
subscription.plan = self.plan_variation.plan
subscription.customer = self.customer
# Currency must be set before the price, in case it was changed
subscription.currency = self.plan_variation.currency
subscription.price = self.plan_variation.price
subscription.interval_unit = self.plan_variation.interval_unit
subscription.interval_length = self.plan_variation.interval_length
subscription.collection_method = collection_method
# Update the tax info stored on the subscription
subscription.update_tax(save=False)
subscription.save()
if gateway.name == 'bank':
payment_method = self.customer.payment_method_add(None, gateway)
if subscription.payment_method_id != payment_method.pk:
logger.info(
'Switching subscription pk=%d from payment method pk=%d to pk=%d',
*[subscription.pk, subscription.payment_method_id, payment_method.pk],
)
subscription.switch_payment_method(payment_method)
order = subscription.latest_order()
if order and order.status == 'created':
# Update the order to take into account latest changes
if order.payment_method_id != subscription.payment_method_id:
order.switch_payment_method(subscription.payment_method)
order.update()
logger.info(
'Using existing order pk=%d of subscription pk=%d', order.pk, subscription.pk
)
else:
# This is the expected situation: a new subscription won't have any orders yet
order = subscription.generate_order()
logger.info('Created order pk=%d for subscription pk=%d', order.pk, subscription.pk)
return order
def form_invalid(self, form, *args, **kwargs):
"""Temporarily log all validation errors."""
logger.warning('Validation error in JoinView: %s', form.errors)
return super().form_invalid(form, *args, **kwargs)
def form_valid(self, form):
"""Save the billing details and redirect to Stripe's checkout."""
product_type = self.plan_variation.plan.product.type
# Get the tax the same way the template does,
# to detect if it was affected by changes to the billing details
old_taxable = looper.taxes.Taxable.from_request(
self.request, price=self.plan_variation.price, product_type=product_type
)
# Save the billing address
# Because pre-filled country might be kept as is, has_changed() might not return True,
# so we save the form unconditionally
form.save()
msg = 'Pricing has been updated to reflect changes to your billing details'
response_redirect = self._set_preferred_currency_and_redirect()
if response_redirect:
messages.add_message(self.request, messages.INFO, msg)
return response_redirect
# Compare tax before and after the billing address is updated
new_tax = self.customer.get_tax(product_type=product_type)
new_taxable = looper.taxes.Taxable(self.plan_variation.price, *new_tax)
if old_taxable != new_taxable:
# If price has changed, stay on the same page and display a notification
messages.add_message(self.request, messages.INFO, msg)
return self.form_invalid(form)
gateway = form.cleaned_data['gateway']
if not gateway.provider.supports_transactions:
order = self._fetch_or_create_order(gateway)
logger.info(
'Not creating transaction for order pk=%r because gateway %r does '
'not support it',
order.pk,
gateway.name,
)
url = reverse(
'looper:transactionless_checkout_done',
kwargs={'pk': order.pk, 'gateway_name': gateway.name},
)
# Trigger an email with instructions about manual payment:
subscription_created_needs_payment.send(sender=order.subscription)
return redirect(url)
success_url = self.request.build_absolute_uri(
reverse(
'subscriptions:join-done',
kwargs={
'plan_variation_id': self.plan_variation.pk,
'stripe_session_id': 'CHECKOUT_SESSION_ID',
},
)
)
# we have to do it to avoid uri-encoding of curly braces,
# otherwise stripe doesn't do the template substitution
success_url = success_url.replace('CHECKOUT_SESSION_ID', '{CHECKOUT_SESSION_ID}', 1)
cancel_url = self.request.build_absolute_uri(self.request.get_full_path())
session = looper.stripe_utils.create_stripe_checkout_session_for_plan_variation(
self.plan_variation,
self.customer,
success_url,
cancel_url,
unit_amount=new_taxable.price.cents,
)
return redirect(session.url)