blender-studio/subscriptions/views/join.py
Anna Sirota dd367f1476 Stripe checkout (#104411)
The goal of this change is the following:

* make Stripe a default payment gateway:
	* for newly created subscriptions;
	* for paying for existing orders;
    * for changing payment method on existing subscriptions;

Reviewed-on: #104411
Reviewed-by: Oleg-Komarov <oleg-komarov@noreply.localhost>
2024-06-17 18:08:39 +02:00

244 lines
11 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 looper.views.checkout_stripe import CheckoutStripeView
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
import subscriptions.models
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
class JoinView(LoginRequiredMixin, FormView):
"""Fill in billing details and initiate Stripe checkout session."""
# FIXME(anna): this view uses some functionality of CheckoutStripeView,
# but cannot directly inherit it, since JoinView supports creating only one subscription.
_fetch_or_create_order = CheckoutStripeView._fetch_or_create_order
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
self.subscription = self._get_existing_subscription()
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,
'subscription': self.subscription,
}
def _get_or_create_subscription(
self, gateway: looper.models.Gateway
) -> looper.models.Subscription:
subscription = self.subscription
is_new = False
if not subscription:
subscription = looper.models.Subscription(customer=self.customer)
is_new = True
logger_args = [self.customer.pk, gateway]
logger.debug('Creating a new subscription for customer pk=%s, %s', *logger_args)
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))
with transaction.atomic():
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
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)
# Configure the team if this is a team plan
if hasattr(subscription.plan, 'team_properties'):
team_properties = subscription.plan.team_properties
team, team_is_new = subscriptions.models.Team.objects.get_or_create(
subscription=subscription,
seats=team_properties.seats,
)
logger.info(
'%s a team for subscription pk=%r, seats: %s',
team_is_new and 'Created' or 'Updated',
subscription.pk,
team.seats and team.seats or 'unlimited',
)
logger.debug('%s subscription pk=%r', is_new and 'Created' or 'Updated', subscription.pk)
return subscription
def form_invalid(self, form, *args, **kwargs):
"""Temporarily log all validation errors."""
logger.exception('Validation error in JoinView: %s, %s', form.errors, form.data)
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']
price_cents = new_taxable.price.cents
subscription = self._get_or_create_subscription(gateway)
# Update the tax info stored on the subscription
subscription.update_tax()
order = self._fetch_or_create_order(form, subscription)
# 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()
# Make sure we are charging what we've displayed
price = looper.money.Money(order.price.currency, price_cents)
if order.price != price:
logger.error("Order price %s doesn't match form price %s", order.price, price)
msg = 'Please reload the page and try again'
messages.warning(self.request, msg)
return self.form_invalid(form)
if not gateway.provider.supports_transactions:
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=subscription)
return redirect(url)
success_url = self.request.build_absolute_uri(
reverse(
'looper:stripe_success',
kwargs={'pk': order.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_order(
order,
success_url,
cancel_url,
payment_intent_metadata={'order_id': order.pk},
)
return redirect(session.url)