Stripe checkout #104411

Merged
Anna Sirota merged 61 commits from stripe into main 2024-06-17 18:08:41 +02:00
12 changed files with 154 additions and 416 deletions
Showing only changes of commit 8a6ac223d6 - Show all commits

View File

@ -49,8 +49,6 @@ MAILGUN_API_KEY=
MAILGUN_WEBHOOK_SIGNING_KEY= MAILGUN_WEBHOOK_SIGNING_KEY=
MAILGUN_WEBHOOK_SECRET= MAILGUN_WEBHOOK_SECRET=
GOOGLE_RECAPTCHA_SITE_KEY=
GOOGLE_RECAPTCHA_SECRET_KEY=
GOOGLE_ANALYTICS_TRACKING_ID= GOOGLE_ANALYTICS_TRACKING_ID=
STRIPE_API_PUBLISHABLE_KEY= STRIPE_API_PUBLISHABLE_KEY=

View File

@ -30,5 +30,4 @@ def extra_context(request: HttpRequest) -> Dict[str, str]:
'ADMIN_MAIL': settings.ADMIN_MAIL, 'ADMIN_MAIL': settings.ADMIN_MAIL,
'STORE_PRODUCT_URL': settings.STORE_PRODUCT_URL, 'STORE_PRODUCT_URL': settings.STORE_PRODUCT_URL,
'STORE_MANAGE_URL': settings.STORE_MANAGE_URL, 'STORE_MANAGE_URL': settings.STORE_MANAGE_URL,
'GOOGLE_RECAPTCHA_SITE_KEY': settings.GOOGLE_RECAPTCHA_SITE_KEY,
} }

View File

@ -54,6 +54,4 @@ MAILGUN_API_KEY=
MAILGUN_WEBHOOK_SIGNING_KEY= MAILGUN_WEBHOOK_SIGNING_KEY=
MAILGUN_WEBHOOK_SECRET= MAILGUN_WEBHOOK_SECRET=
GOOGLE_RECAPTCHA_SITE_KEY=
GOOGLE_RECAPTCHA_SECRET_KEY=
GOOGLE_ANALYTICS_TRACKING_ID= GOOGLE_ANALYTICS_TRACKING_ID=

View File

@ -638,8 +638,6 @@ if MAILGUN_SENDER_DOMAIN:
GEOIP2_DB = _get('GEOIP2_DB') GEOIP2_DB = _get('GEOIP2_DB')
GOOGLE_ANALYTICS_TRACKING_ID = _get('GOOGLE_ANALYTICS_TRACKING_ID') GOOGLE_ANALYTICS_TRACKING_ID = _get('GOOGLE_ANALYTICS_TRACKING_ID')
GOOGLE_RECAPTCHA_SECRET_KEY = _get('GOOGLE_RECAPTCHA_SECRET_KEY')
GOOGLE_RECAPTCHA_SITE_KEY = _get('GOOGLE_RECAPTCHA_SITE_KEY')
S3DIRECT_DESTINATIONS = { S3DIRECT_DESTINATIONS = {
'default': { 'default': {
@ -692,7 +690,7 @@ STRIPE_OFF_SESSION_PAYMENT_METHOD_TYPES = [
'paypal', 'paypal',
] ]
STRIPE_CHECKOUT_SUBMIT_TYPE = 'donate' STRIPE_CHECKOUT_SUBMIT_TYPE = 'pay'
# Maximum number of attempts for failing background tasks # Maximum number of attempts for failing background tasks
MAX_ATTEMPTS = 3 MAX_ATTEMPTS = 3

View File

@ -8,6 +8,7 @@ from django.forms.fields import Field
from localflavor.administrative_areas import ADMINISTRATIVE_AREAS from localflavor.administrative_areas import ADMINISTRATIVE_AREAS
from localflavor.generic.validators import validate_country_postcode from localflavor.generic.validators import validate_country_postcode
from looper.middleware import COUNTRY_CODE_SESSION_KEY
from stdnum.eu import vat from stdnum.eu import vat
import localflavor.exceptions import localflavor.exceptions
import looper.form_fields import looper.form_fields
@ -46,9 +47,11 @@ REQUIRED_FIELDS = {
class BillingAddressForm(forms.ModelForm): class BillingAddressForm(forms.ModelForm):
"""Fill in billing address and prepare for intitiating Stripe checkout session."""
class Meta: class Meta:
model = looper.models.Address model = looper.models.Address
fields = looper.models.Address.PUBLIC_FIELDS exclude = ['category', 'customer', 'tax_exempt']
# What kind of choices are allowed depends on the selected country # What kind of choices are allowed depends on the selected country
# and is not yet known when the form is rendered. # and is not yet known when the form is rendered.
@ -71,8 +74,24 @@ class BillingAddressForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Load additional model data from Customer and set form placeholders.""" """Load additional model data from Customer and set form placeholders."""
self.request = kwargs.pop('request')
self.customer = self.request.user.customer
self.plan_variation = kwargs.pop('plan_variation')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Only preset country when it's not already selected by the customer
geoip_country = self.request.session.get(COUNTRY_CODE_SESSION_KEY)
if geoip_country and (not self.instance.country):
self.initial['country'] = geoip_country
# Only set initial values if they aren't already saved to the billing address.
# Initial values always override form data, which leads to confusing issues with views.
if not self.instance.full_name:
# Fall back to user's full name, if no full name set already in the billing address:
if self.request.user.full_name:
self.initial['full_name'] = self.request.user.full_name
# Set placeholder values on all form fields # Set placeholder values on all form fields
for field_name, field in self.fields.items(): for field_name, field in self.fields.items():
placeholder = BILLING_DETAILS_PLACEHOLDERS.get(field_name) placeholder = BILLING_DETAILS_PLACEHOLDERS.get(field_name)
@ -152,7 +171,7 @@ class PaymentForm(BillingAddressForm):
but are still used by the payment flow. but are still used by the payment flow.
""" """
gateway = looper.form_fields.GatewayChoiceField() # Price value is a decimal number in major units of selected currency.
price = forms.CharField(widget=forms.HiddenInput(), required=True) price = forms.CharField(widget=forms.HiddenInput(), required=True)
# These are used when a payment fails, so that the next attempt to pay can reuse # These are used when a payment fails, so that the next attempt to pay can reuse
@ -161,10 +180,6 @@ class PaymentForm(BillingAddressForm):
order_pk = forms.CharField(widget=forms.HiddenInput(), required=False) order_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
class AutomaticPaymentForm(PaymentForm):
pass
class SelectPlanVariationForm(forms.Form): class SelectPlanVariationForm(forms.Form):
"""Form used in the plan selector.""" """Form used in the plan selector."""

View File

@ -78,7 +78,11 @@ def _set_order_number(sender, instance: Order, **kwargs):
@receiver(subscription_created_needs_payment) @receiver(subscription_created_needs_payment)
def _on_subscription_created_needs_payment(sender: looper.models.Subscription, **kwargs): def _on_subscription_created_needs_payment(sender: looper.models.Subscription, **kwargs):
tasks.send_mail_bank_transfer_required(subscription_id=sender.pk) tasks.send_mail_bank_transfer_required(subscription_id=sender.pk)
users.tasks.grant_blender_id_role(pk=sender.user_id, role='cloud_has_subscription') user = sender.customer.user
if not user:
logger.error('Cannot grand role to an account-less customer pk=%s', sender.customer_id)
return
users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription')
@receiver(looper.signals.subscription_activated) @receiver(looper.signals.subscription_activated)
@ -89,8 +93,12 @@ def _on_subscription_status_changed(sender: looper.models.Subscription, **kwargs
@receiver(looper.signals.subscription_activated) @receiver(looper.signals.subscription_activated)
def _on_subscription_status_activated(sender: looper.models.Subscription, **kwargs): def _on_subscription_status_activated(sender: looper.models.Subscription, **kwargs):
users.tasks.grant_blender_id_role(pk=sender.user_id, role='cloud_has_subscription') user = sender.customer.user
users.tasks.grant_blender_id_role(pk=sender.user_id, role='cloud_subscriber') if not user:
logger.error('Cannot grand role to an account-less customer pk=%s', sender.customer_id)
return
users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription')
users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_subscriber')
if not hasattr(sender, 'team'): if not hasattr(sender, 'team'):
return return

View File

@ -30,13 +30,14 @@
{% include "subscriptions/components/billing_address_form.html" %} {% include "subscriptions/components/billing_address_form.html" %}
</fieldset> </fieldset>
<p class="mb-0 text-muted x-sm">Required fields are marked with (*).</p> <p class="mb-0 text-muted x-sm">Required fields are marked with (*).</p>
{{ form.price }}
</section> </section>
</div> </div>
<div class="border-bottom border-1 mb-4 pb-2"> <div class="border-bottom border-1 mb-4 pb-2">
<div class="row"> <div class="row">
{% if messages %} {% if messages %}
{% for message in messages %} {% for message in messages %}
<p {% if message.tags %} class="alert alert-sm alert-success alert-{{ message.tags }}" {% endif %}> <p {% if message.tags %} class="alert alert-sm alert-{{ message.tags }}" {% endif %}>
{{ message }} {{ message }}
</p> </p>
{% endfor %} {% endfor %}

View File

@ -1,73 +0,0 @@
{% extends 'checkout/checkout_base.html' %}
{% load common_extras %}
{% load looper %}
{% load pipeline %}
{% block content %}
<div class="mb-3">
<h2>Payment</h2>
<div class="d-flex justify-content-between">
<p class="mb-0 small">Step 3: Select a payment method.</p>
<p class=" mb-0 small">3 of 3</p>
</div>
</div>
<form id="payment-form" class="checkout-form" method="post" action="{% url 'subscriptions:join-confirm-and-pay' plan_variation_id=current_plan_variation.pk %}"
data-looper-payment-form="true" data-braintree-client-token="{{ client_token }}">
{% csrf_token %}
{% url 'subscriptions:join-billing-details' plan_variation_id=current_plan_variation.pk as billing_url %}
{% if current_plan_variation.plan.team_properties %}
{% url "subscriptions:join-team" current_plan_variation.pk as plan_url %}
{% else %}
{% url "subscriptions:join" current_plan_variation.pk as plan_url %}
{% endif %}
{% with form|add_form_classes as form %}
<div>
<div>
<fieldset>
{% include "subscriptions/components/payment_form.html" %}
</fieldset>
{% if GOOGLE_RECAPTCHA_SITE_KEY %}
<div id="recaptcha" class="g-recaptcha" data-sitekey="{{ GOOGLE_RECAPTCHA_SITE_KEY }}" data-size="invisible">
</div>
{% endif %}
</div>
<div class="border-bottom border-1 mb-4 pb-2">
<div class="row">
{% if messages %}
{% for message in messages %}
<p {% if message.tags %} class="alert alert-sm alert-success alert-{{ message.tags }}" {% endif %}>
{{ message }}
</p>
{% endfor %}
{% endif %}
</div>
{% include "subscriptions/components/selected_plan_info.html" with back_url=plan_url %}
<div class="row">
<div class="col-auto">
<p class="fw-bold mb-0 small">Billing:</p>
</div>
<div class="col text-end">
<fieldset>
{% include "subscriptions/components/billing_address_form_readonly.html"%}
</fieldset>
<p class="mb-0 small text-muted">(<a href="{{ billing_url }}" class="text-muted" >Change</a>)</p>
</div>
</div>
</div>
{% include 'subscriptions/components/total.html' with button_text="Confirm and Pay" %}
</div>
{% endwith %}
</form>
{% endblock content %}
{% block scripts %}
{% javascript "subscriptions" %}
{% include "looper/scripts.html" with with_recaptcha=True %}
{% endblock scripts %}

View File

@ -1,52 +0,0 @@
{% extends "checkout/checkout_base_empty.html" %}
{% load looper %}
{% load pipeline %}
{% load common_extras %}
{% block content %}
<div>
<section class="checkout">
<div class="mx-auto">
<a class="float-end text-muted" href="{% url 'user-settings-billing' %}">Back to subscription settings</a>
<div class="alert alert-sm">
Your {% include "subscriptions/components/info_with_status.html" %}. It will be activated as soon as the outstanding amount is paid
</div>
<div>
<h2>Paying for Order #{{ order.display_number }}</h2>
<div class="d-flex justify-content-between">
<p class="mb-0 small">billed on {{ order.created_at|date }}</p>
<p class="mb-0 small">{{ order.price.with_currency_symbol }}</p>
</div>
</div>
<form class="checkout-form" id="payment-form" method="post"
data-looper-payment-form="true"
data-braintree-client-token="{{ client_token }}">{% csrf_token %}
<div>
<div>
<section class="checkout-form-fields mb-n2">
{% with form|add_form_classes as form %}
<a class="text-muted float-end small" href="{% url 'subscriptions:billing-address' %}">Change billing details</a>
<fieldset class="mb-2">
{% include "subscriptions/components/billing_address_form_readonly.html" %}
</fieldset>
<fieldset>
{% include "subscriptions/components/payment_form.html" %}
</fieldset>
{% endwith %}
</section>
</div>
{# will be enabled when payment gateways are initialized successfully #}
<div class="m-2">
<button id="submit-button" class="btn btn-block btn-lg btn-success" type="submit" aria-disabled="true" disabled>
Pay {{ order.price.with_currency_symbol }}
</button>
</div>
</div>
</form>
</div>
</section>
</div>
{% javascript "subscriptions" %}
{% include "looper/scripts.html" %}
{% endblock %}

View File

@ -1,63 +0,0 @@
{% extends "settings/base.html" %}
{% load common_extras %}
{% load pipeline %}
{% block settings %}
<p class="subtitle">Settings: Subscription #{{ subscription.pk }}</p>
<h1 class="mb-3">Change Payment Method</h1>
<div class="settings-billing">
<div>
<div class="alert alert-{% if subscription.status == 'active' %}primary{% else %}warning{% endif %}" role="alert">
<span>
Your {{ subscription.plan.name }} subscription is currently
<span class="fw-bolder">{{ subscription.get_status_display|lower }}</span>.
</span>
</div>
{% if current_payment_method %}
<p>
<strong>{{ current_payment_method.recognisable_name }}</strong> is used as payment method.
Feel free to change it below.
</p>
{% else %}
<div class="alert alert-danger" role="alert">
<i class="material-icons icon-inline">warning_amber</i>
You subscription is using an unsupported payment method,
please use the form below to change it.
</div>
{% endif %}
</div>
<form id="payment-form" class="checkout-form" method="post"
data-looper-payment-form="true"
data-braintree-client-token="{{ client_token }}">{% csrf_token %}
{% with form|add_form_classes as form %}
<section>
<a class="float-end text-muted" href="{% url 'subscriptions:billing-address' %}"><i class="fa fa-angle-left pe-3"></i>change billing details</a>
<fieldset class="checkout-form-billing-address-readonly">
{% include "subscriptions/components/billing_address_form_readonly.html" %}
</fieldset>
<fieldset>
{% include "subscriptions/components/payment_form.html" %}
</fieldset>
</section>
<div class="row mt-3">
<div class="col">
<a class="btn" href="{% url 'subscriptions:manage' subscription_id=subscription.pk %}">Cancel</a>
</div>
<div class="col-auto">
<button id="submit-button" class="btn btn-success" type="submit" aria-disabled="true" disabled>
<i class="fa fa-check"></i>
Switch Payment method
</button>
</div>
</div>
{% endwith %}
</form>
</div>
{% endblock settings %}
{% block scripts %}
{{ block.super }}
{% javascript "subscriptions" %}
{% include "looper/scripts.html" %}
{% endblock scripts %}

View File

@ -2,7 +2,7 @@ from django.urls import path, re_path
from looper.views import settings as looper_settings from looper.views import settings as looper_settings
from subscriptions.views.join import BillingDetailsView, ConfirmAndPayView from subscriptions.views.join import JoinView
from subscriptions.views.select_plan_variation import ( from subscriptions.views.select_plan_variation import (
SelectPlanVariationView, SelectPlanVariationView,
SelectTeamPlanVariationView, SelectTeamPlanVariationView,
@ -26,14 +26,9 @@ urlpatterns = [
), ),
path( path(
'join/plan-variation/<int:plan_variation_id>/billing/', 'join/plan-variation/<int:plan_variation_id>/billing/',
BillingDetailsView.as_view(), JoinView.as_view(),
name='join-billing-details', name='join-billing-details',
), ),
path(
'join/plan-variation/<int:plan_variation_id>/confirm/',
ConfirmAndPayView.as_view(),
name='join-confirm-and-pay',
),
path( path(
'subscription/<int:subscription_id>/manage/', 'subscription/<int:subscription_id>/manage/',
settings.ManageSubscriptionView.as_view(), settings.ManageSubscriptionView.as_view(),

View File

@ -2,22 +2,21 @@
from decimal import Decimal from decimal import Decimal
import logging import logging
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction from django.db import transaction
from django.shortcuts import redirect, get_object_or_404 from django.shortcuts import redirect, get_object_or_404
from django.urls import reverse
from django.views.generic import FormView from django.views.generic import FormView
from looper.middleware import COUNTRY_CODE_SESSION_KEY
from looper.views.checkout_braintree import AbstractPaymentView, CheckoutView
import looper.gateways import looper.gateways
import looper.middleware import looper.middleware
import looper.models import looper.models
import looper.money import looper.money
import looper.taxes import looper.taxes
from looper.views.checkout_stripe import CheckoutStripeView
from subscriptions.forms import BillingAddressForm, PaymentForm, AutomaticPaymentForm from subscriptions.forms import PaymentForm
from subscriptions.middleware import preferred_currency_for_country_code from subscriptions.middleware import preferred_currency_for_country_code
from subscriptions.queries import should_redirect_to_billing from subscriptions.queries import should_redirect_to_billing
from subscriptions.signals import subscription_created_needs_payment from subscriptions.signals import subscription_created_needs_payment
@ -27,21 +26,15 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
class _JoinMixin: class JoinView(LoginRequiredMixin, FormView):
customer: looper.models.Customer """Fill in billing details and initiate Stripe checkout session."""
# FIXME(anna): this view uses some functionality of AbstractPaymentView, # FIXME(anna): this view uses some functionality of CheckoutStripeView,
# but cannot directly inherit from them, since JoinView supports creating only one subscription. # but cannot directly inherit from them, since JoinView supports creating only one subscription.
get_currency = AbstractPaymentView.get_currency _fetch_or_create_order = CheckoutStripeView._fetch_or_create_order
get_client_token = AbstractPaymentView.get_client_token
client_token_session_key = AbstractPaymentView.client_token_session_key
erase_client_token = AbstractPaymentView.erase_client_token
@property template_name = 'subscriptions/join/billing_address.html'
def session_key_prefix(self) -> str: form_class = PaymentForm
"""Separate client tokens by currency code."""
currency = self.get_currency()
return f'PAYMENT_GATEWAY_CLIENT_TOKEN_{currency}'
def _get_existing_subscription(self): def _get_existing_subscription(self):
# Exclude cancelled subscriptions because they cannot transition to active # Exclude cancelled subscriptions because they cannot transition to active
@ -51,87 +44,114 @@ class _JoinMixin:
return existing_subscriptions.first() return existing_subscriptions.first()
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
"""Set customer for authenticated user, same as AbstractPaymentView does.""" """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'] plan_variation_id = kwargs['plan_variation_id']
self.plan_variation = get_object_or_404( self.plan_variation = get_object_or_404(
looper.models.PlanVariation, looper.models.PlanVariation,
pk=plan_variation_id, pk=plan_variation_id,
is_active=True, is_active=True,
currency=self.get_currency(),
) )
if not getattr(self, 'gateway', None):
self.gateway = looper.models.Gateway.default() self.gateway = looper.models.Gateway.default()
self.user = self.request.user self.user = request.user
self.customer = None
self.subscription = None
if self.user.is_authenticated:
self.customer = self.user.customer self.customer = self.user.customer
self.subscription = self._get_existing_subscription() self.subscription = self._get_existing_subscription()
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self) -> dict: def get_form_kwargs(self, *args, **kwargs):
"""Pass extra parameters to the form.""" """Pass request to the form."""
form_kwargs = super().get_form_kwargs() form_kwargs = super().get_form_kwargs(*args, **kwargs)
if self.user.is_authenticated: form_kwargs.update(
return { {
**form_kwargs, 'request': self.request,
'plan_variation': self.plan_variation,
'instance': self.customer.billing_address, 'instance': self.customer.billing_address,
} }
)
return form_kwargs return form_kwargs
def get(self, request, *args, **kwargs):
"""Redirect to the Store if subscriptions are not enabled."""
if should_redirect_to_billing(request.user):
return redirect('user-settings-billing')
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
"""Redirect anonymous users to login."""
if request.user.is_anonymous:
return redirect('{}?next={}'.format(settings.LOGIN_URL, request.path))
if request.user.is_authenticated:
if self.subscription and self.subscription.status in self.subscription._ACTIVE_STATUSES:
return redirect('user-settings-billing')
return super().post(request, *args, **kwargs)
class BillingDetailsView(_JoinMixin, LoginRequiredMixin, FormView):
"""Display billing details form and save them as billing Address and Customer."""
template_name = 'subscriptions/join/billing_address.html'
form_class = BillingAddressForm
customer: looper.models.Customer
def get_initial(self) -> dict: def get_initial(self) -> dict:
"""Prefill default payment gateway, country and selected plan options.""" """Prefill default payment gateway, country and selected plan options."""
initial = super().get_initial() product_type = self.plan_variation.plan.product.type
# Only preset country when it's not already selected by the customer customer_tax = self.customer.get_tax(product_type=product_type)
geoip_country = self.request.session.get(COUNTRY_CODE_SESSION_KEY) taxable = looper.taxes.Taxable(self.plan_variation.price, *customer_tax)
if geoip_country and (not self.customer or not self.customer.billing_address.country): return {
initial['country'] = geoip_country **super().get_initial(),
'price': taxable.price.decimals_string,
# Only set initial values if they aren't already saved to the billing address. 'gateway': self.gateway.name,
# Initial values always override form data, which leads to confusing issues with views. }
if not (self.customer and self.customer.billing_address.full_name):
# Fall back to user's full name, if no full name set already in the billing address:
if self.request.user.full_name:
initial['full_name'] = self.request.user.full_name
return initial
def get_context_data(self, **kwargs) -> dict: def get_context_data(self, **kwargs) -> dict:
"""Add an extra form and gateway's client token.""" """Add existing subscription to the view and the context."""
return { return {
**super().get_context_data(**kwargs), **super().get_context_data(**kwargs),
'current_plan_variation': self.plan_variation, 'current_plan_variation': self.plan_variation,
'subscription': self.subscription, 'subscription': self.subscription,
} }
def gateway_from_form(self, form) -> looper.models.Gateway:
"""TODO support the usual bank transfer payments."""
name = 'bank' in form and 'bank' or 'stripe'
self.gateway = looper.models.Gateway.objects.get(name=name)
return self.gateway
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()
is_new = True
logger.debug('Creating an new subscription for %s, %s', gateway)
collection_method = self.plan_variation.collection_method
supported = set(gateway.provider.supported_collection_methods)
if collection_method not in supported:
# FIXME(anna): this breaks plan selector because collection method
# might not match the one selected by the customer.
collection_method = supported.pop()
with transaction.atomic():
subscription.plan = self.plan_variation.plan
subscription.user = self.user
# 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()
# 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 ConfirmAndPayView: %s', form.errors)
return super().form_invalid(form, *args, **kwargs)
def form_valid(self, form): def form_valid(self, form):
"""Save the billing details and pass the data to the payment form.""" """Save the billing details and redirect to Stripe's checkout."""
product_type = self.plan_variation.plan.product.type product_type = self.plan_variation.plan.product.type
# Get the tax the same way the template does, # Get the tax the same way the template does,
# to detect if it was affected by changes to the billing details # to detect if it was affected by changes to the billing details
@ -161,136 +181,11 @@ class BillingDetailsView(_JoinMixin, LoginRequiredMixin, FormView):
new_taxable = looper.taxes.Taxable(self.plan_variation.price, *new_tax) new_taxable = looper.taxes.Taxable(self.plan_variation.price, *new_tax)
if old_taxable != new_taxable: if old_taxable != new_taxable:
# If price has changed, stay on the same page and display a notification # 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) return self.form_invalid(form)
return redirect(
'subscriptions:join-confirm-and-pay', plan_variation_id=self.plan_variation.pk
)
class ConfirmAndPayView(_JoinMixin, LoginRequiredMixin, FormView):
"""Display the payment form and handle the payment flow."""
raise_exception = True
template_name = 'subscriptions/join/payment_method.html'
form_class = PaymentForm
log = logger
gateway: looper.models.Gateway
# FIXME(anna): this view uses some functionality of AbstractPaymentView/CheckoutView,
# but cannot directly inherit from them.
gateway_from_form = AbstractPaymentView.gateway_from_form
_check_customer_ip_address = AbstractPaymentView._check_customer_ip_address
_check_payment_method_nonce = CheckoutView._check_payment_method_nonce
_check_recaptcha = CheckoutView._check_recaptcha
_charge_if_supported = CheckoutView._charge_if_supported
_fetch_or_create_order = CheckoutView._fetch_or_create_order
def get_form_class(self):
"""Override the payment form based on the selected plan variation, before validation."""
if self.plan_variation.collection_method == 'automatic':
return AutomaticPaymentForm
return PaymentForm
def get_initial(self) -> dict:
"""Prefill default payment gateway, country and selected plan options."""
product_type = self.plan_variation.plan.product.type
customer_tax = self.customer.get_tax(product_type=product_type)
taxable = looper.taxes.Taxable(self.plan_variation.price, *customer_tax)
return {
**super().get_initial(),
'price': taxable.price.decimals_string,
'gateway': self.gateway.name,
}
def get_context_data(self, **kwargs) -> dict:
"""Add an extra form and gateway's client token."""
currency = self.get_currency()
ctx = {
**super().get_context_data(**kwargs),
'current_plan_variation': self.plan_variation,
'subscription': self.subscription,
}
return ctx
def _get_or_create_subscription(
self, gateway: looper.models.Gateway, payment_method: looper.models.PaymentMethod
) -> looper.models.Subscription:
subscription = self._get_existing_subscription()
is_new = False
if not subscription:
subscription = looper.models.Subscription()
is_new = True
self.log.debug('Creating an new subscription for %s, %s', gateway, payment_method)
collection_method = self.plan_variation.collection_method
supported = set(gateway.provider.supported_collection_methods)
if collection_method not in supported:
# FIXME(anna): this breaks plan selector because collection method
# might not match the one selected by the customer.
collection_method = supported.pop()
with transaction.atomic():
subscription.plan = self.plan_variation.plan
subscription.user = self.user
subscription.payment_method = payment_method
# 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()
# 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,
)
self.log.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',
)
self.log.debug('%s subscription pk=%r', is_new and 'Created' or 'Updated', subscription.pk)
return subscription
def form_invalid(self, form):
"""Temporarily log all validation errors."""
logger.exception('Validation error in ConfirmAndPayView: %s', form.errors)
return super().form_invalid(form)
def form_valid(self, form):
"""Handle valid form data.
Confirm and Pay view doesn't update the billing address,
only displays it for use by payment flow and validates it on submit.
The billing address is assumed to be saved at the previous step.
"""
assert self.request.method == 'POST'
response = self._check_recaptcha(form)
if response:
return response
response = self._check_customer_ip_address(form)
if response:
return response
gateway = self.gateway_from_form(form) gateway = self.gateway_from_form(form)
payment_method = self._check_payment_method_nonce(form, gateway)
if payment_method is None:
return self.form_invalid(form)
price_cents = int(Decimal(form.cleaned_data['price']) * 100) price_cents = int(Decimal(form.cleaned_data['price']) * 100)
subscription = self._get_or_create_subscription(gateway, payment_method) subscription = self._get_or_create_subscription(gateway)
# Update the tax info stored on the subscription # Update the tax info stored on the subscription
subscription.update_tax() subscription.update_tax()
@ -300,12 +195,31 @@ class ConfirmAndPayView(_JoinMixin, LoginRequiredMixin, FormView):
# Make sure we are charging what we've displayed # Make sure we are charging what we've displayed
price = looper.money.Money(order.price.currency, price_cents) price = looper.money.Money(order.price.currency, price_cents)
if order.price != price: if order.price != price:
form.add_error('', 'Payment failed: please reload the page and try again') logger.error("Order price %s doesn't match form price %s", order.price, price)
msg = 'Please reload the page and try again'
form.add_error('price', msg)
messages.warning(self.request, msg)
return self.form_invalid(form) return self.form_invalid(form)
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())
if not gateway.provider.supports_transactions: if not gateway.provider.supports_transactions:
# Trigger an email with instructions about manual payment: # Trigger an email with instructions about manual payment:
subscription_created_needs_payment.send(sender=subscription) subscription_created_needs_payment.send(sender=subscription)
response = self._charge_if_supported(form, gateway, order) session = looper.stripe_utils.create_stripe_checkout_session_for_order(
return response order,
success_url,
cancel_url,
payment_intent_metadata={'order_id': order.pk},
)
return redirect(session.url)