Stripe checkout #104411
@ -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=
|
||||||
|
@ -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,
|
|
||||||
}
|
}
|
||||||
|
@ -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=
|
||||||
|
@ -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
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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 %}
|
||||||
|
@ -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 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(),
|
||||||
|
@ -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.customer = self.user.customer
|
||||||
self.subscription = None
|
self.subscription = self._get_existing_subscription()
|
||||||
if self.user.is_authenticated:
|
|
||||||
self.customer = self.user.customer
|
|
||||||
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)
|
||||||
|
Loading…
Reference in New Issue
Block a user