Anna Sirota
ec2ce855b9
This also updates plan variations fixture (and historical migrations) to match what is currently in production.
222 lines
9.5 KiB
Python
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)
|