Anna Sirota
dd367f1476
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>
244 lines
11 KiB
Python
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)
|