Stripe checkout #104411
@ -49,8 +49,6 @@ MAILGUN_API_KEY=
|
||||
MAILGUN_WEBHOOK_SIGNING_KEY=
|
||||
MAILGUN_WEBHOOK_SECRET=
|
||||
|
||||
GOOGLE_RECAPTCHA_SITE_KEY=
|
||||
GOOGLE_RECAPTCHA_SECRET_KEY=
|
||||
GOOGLE_ANALYTICS_TRACKING_ID=
|
||||
|
||||
STRIPE_API_PUBLISHABLE_KEY=
|
||||
|
@ -30,5 +30,4 @@ def extra_context(request: HttpRequest) -> Dict[str, str]:
|
||||
'ADMIN_MAIL': settings.ADMIN_MAIL,
|
||||
'STORE_PRODUCT_URL': settings.STORE_PRODUCT_URL,
|
||||
'STORE_MANAGE_URL': settings.STORE_MANAGE_URL,
|
||||
'GOOGLE_RECAPTCHA_SITE_KEY': settings.GOOGLE_RECAPTCHA_SITE_KEY,
|
||||
}
|
||||
|
@ -54,6 +54,4 @@ MAILGUN_API_KEY=
|
||||
MAILGUN_WEBHOOK_SIGNING_KEY=
|
||||
MAILGUN_WEBHOOK_SECRET=
|
||||
|
||||
GOOGLE_RECAPTCHA_SITE_KEY=
|
||||
GOOGLE_RECAPTCHA_SECRET_KEY=
|
||||
GOOGLE_ANALYTICS_TRACKING_ID=
|
||||
|
@ -638,8 +638,6 @@ if MAILGUN_SENDER_DOMAIN:
|
||||
GEOIP2_DB = _get('GEOIP2_DB')
|
||||
|
||||
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 = {
|
||||
'default': {
|
||||
@ -692,7 +690,7 @@ STRIPE_OFF_SESSION_PAYMENT_METHOD_TYPES = [
|
||||
'paypal',
|
||||
]
|
||||
|
||||
STRIPE_CHECKOUT_SUBMIT_TYPE = 'donate'
|
||||
STRIPE_CHECKOUT_SUBMIT_TYPE = 'pay'
|
||||
|
||||
# Maximum number of attempts for failing background tasks
|
||||
MAX_ATTEMPTS = 3
|
||||
|
@ -8,6 +8,7 @@ from django.forms.fields import Field
|
||||
|
||||
from localflavor.administrative_areas import ADMINISTRATIVE_AREAS
|
||||
from localflavor.generic.validators import validate_country_postcode
|
||||
from looper.middleware import COUNTRY_CODE_SESSION_KEY
|
||||
from stdnum.eu import vat
|
||||
import localflavor.exceptions
|
||||
import looper.form_fields
|
||||
@ -46,9 +47,11 @@ REQUIRED_FIELDS = {
|
||||
|
||||
|
||||
class BillingAddressForm(forms.ModelForm):
|
||||
"""Fill in billing address and prepare for intitiating Stripe checkout session."""
|
||||
|
||||
class Meta:
|
||||
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
|
||||
# and is not yet known when the form is rendered.
|
||||
@ -71,8 +74,24 @@ class BillingAddressForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""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)
|
||||
|
||||
# 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
|
||||
for field_name, field in self.fields.items():
|
||||
placeholder = BILLING_DETAILS_PLACEHOLDERS.get(field_name)
|
||||
@ -152,7 +171,7 @@ class PaymentForm(BillingAddressForm):
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
class AutomaticPaymentForm(PaymentForm):
|
||||
pass
|
||||
|
||||
|
||||
class SelectPlanVariationForm(forms.Form):
|
||||
"""Form used in the plan selector."""
|
||||
|
||||
|
@ -78,7 +78,11 @@ def _set_order_number(sender, instance: Order, **kwargs):
|
||||
@receiver(subscription_created_needs_payment)
|
||||
def _on_subscription_created_needs_payment(sender: looper.models.Subscription, **kwargs):
|
||||
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)
|
||||
@ -89,8 +93,12 @@ def _on_subscription_status_changed(sender: looper.models.Subscription, **kwargs
|
||||
|
||||
@receiver(looper.signals.subscription_activated)
|
||||
def _on_subscription_status_activated(sender: looper.models.Subscription, **kwargs):
|
||||
users.tasks.grant_blender_id_role(pk=sender.user_id, role='cloud_has_subscription')
|
||||
users.tasks.grant_blender_id_role(pk=sender.user_id, role='cloud_subscriber')
|
||||
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')
|
||||
users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_subscriber')
|
||||
|
||||
if not hasattr(sender, 'team'):
|
||||
return
|
||||
|
@ -30,13 +30,14 @@
|
||||
{% include "subscriptions/components/billing_address_form.html" %}
|
||||
</fieldset>
|
||||
<p class="mb-0 text-muted x-sm">Required fields are marked with (*).</p>
|
||||
{{ form.price }}
|
||||
</section>
|
||||
</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 %}>
|
||||
<p {% if message.tags %} class="alert alert-sm alert-{{ message.tags }}" {% endif %}>
|
||||
{{ message }}
|
||||
</p>
|
||||
{% endfor %}
|
||||
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
@ -2,7 +2,7 @@ from django.urls import path, re_path
|
||||
|
||||
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 (
|
||||
SelectPlanVariationView,
|
||||
SelectTeamPlanVariationView,
|
||||
@ -26,14 +26,9 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
'join/plan-variation/<int:plan_variation_id>/billing/',
|
||||
BillingDetailsView.as_view(),
|
||||
JoinView.as_view(),
|
||||
name='join-billing-details',
|
||||
),
|
||||
path(
|
||||
'join/plan-variation/<int:plan_variation_id>/confirm/',
|
||||
ConfirmAndPayView.as_view(),
|
||||
name='join-confirm-and-pay',
|
||||
),
|
||||
path(
|
||||
'subscription/<int:subscription_id>/manage/',
|
||||
settings.ManageSubscriptionView.as_view(),
|
||||
|
@ -2,22 +2,21 @@
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
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
|
||||
|
||||
from looper.middleware import COUNTRY_CODE_SESSION_KEY
|
||||
from looper.views.checkout_braintree import AbstractPaymentView, CheckoutView
|
||||
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 BillingAddressForm, PaymentForm, AutomaticPaymentForm
|
||||
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
|
||||
@ -27,21 +26,15 @@ logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class _JoinMixin:
|
||||
customer: looper.models.Customer
|
||||
class JoinView(LoginRequiredMixin, FormView):
|
||||
"""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.
|
||||
get_currency = AbstractPaymentView.get_currency
|
||||
get_client_token = AbstractPaymentView.get_client_token
|
||||
client_token_session_key = AbstractPaymentView.client_token_session_key
|
||||
erase_client_token = AbstractPaymentView.erase_client_token
|
||||
_fetch_or_create_order = CheckoutStripeView._fetch_or_create_order
|
||||
|
||||
@property
|
||||
def session_key_prefix(self) -> str:
|
||||
"""Separate client tokens by currency code."""
|
||||
currency = self.get_currency()
|
||||
return f'PAYMENT_GATEWAY_CLIENT_TOKEN_{currency}'
|
||||
template_name = 'subscriptions/join/billing_address.html'
|
||||
form_class = PaymentForm
|
||||
|
||||
def _get_existing_subscription(self):
|
||||
# Exclude cancelled subscriptions because they cannot transition to active
|
||||
@ -51,87 +44,114 @@ class _JoinMixin:
|
||||
return existing_subscriptions.first()
|
||||
|
||||
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']
|
||||
self.plan_variation = get_object_or_404(
|
||||
looper.models.PlanVariation,
|
||||
pk=plan_variation_id,
|
||||
is_active=True,
|
||||
currency=self.get_currency(),
|
||||
)
|
||||
if not getattr(self, 'gateway', None):
|
||||
self.gateway = looper.models.Gateway.default()
|
||||
self.user = self.request.user
|
||||
self.customer = None
|
||||
self.subscription = None
|
||||
if self.user.is_authenticated:
|
||||
self.customer = self.user.customer
|
||||
self.subscription = self._get_existing_subscription()
|
||||
|
||||
self.gateway = looper.models.Gateway.default()
|
||||
self.user = request.user
|
||||
self.customer = self.user.customer
|
||||
self.subscription = self._get_existing_subscription()
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_form_kwargs(self) -> dict:
|
||||
"""Pass extra parameters to the form."""
|
||||
form_kwargs = super().get_form_kwargs()
|
||||
if self.user.is_authenticated:
|
||||
return {
|
||||
**form_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(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:
|
||||
"""Prefill default payment gateway, country and selected plan options."""
|
||||
initial = super().get_initial()
|
||||
# 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.customer or not self.customer.billing_address.country):
|
||||
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.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
|
||||
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."""
|
||||
"""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 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):
|
||||
"""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
|
||||
# Get the tax the same way the template does,
|
||||
# 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)
|
||||
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)
|
||||
|
||||
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)
|
||||
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)
|
||||
subscription = self._get_or_create_subscription(gateway, payment_method)
|
||||
subscription = self._get_or_create_subscription(gateway)
|
||||
# Update the tax info stored on the subscription
|
||||
subscription.update_tax()
|
||||
|
||||
@ -300,12 +195,31 @@ class ConfirmAndPayView(_JoinMixin, LoginRequiredMixin, FormView):
|
||||
# Make sure we are charging what we've displayed
|
||||
price = looper.money.Money(order.price.currency, price_cents)
|
||||
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)
|
||||
|
||||
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:
|
||||
# Trigger an email with instructions about manual payment:
|
||||
subscription_created_needs_payment.send(sender=subscription)
|
||||
|
||||
response = self._charge_if_supported(form, gateway, order)
|
||||
return response
|
||||
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)
|
||||
|
Loading…
Reference in New Issue
Block a user