Stripe checkout #104411
@ -714,3 +714,19 @@ button,
|
||||
&.rounded-lg
|
||||
.plyr
|
||||
border-radius: var(--border-radius-lg)
|
||||
|
||||
|
||||
/* Simple CSS-only tooltips. */
|
||||
[data-tooltip]
|
||||
&:hover
|
||||
&:before, &:after
|
||||
display: block
|
||||
position: absolute
|
||||
color: var(--color-text-primary)
|
||||
&:before
|
||||
border-radius: var(--spacer-1)
|
||||
content: attr(title)
|
||||
background-color: var(--color-bg-primary-subtle)
|
||||
margin-top: var(--spacer)
|
||||
padding: var(--spacer)
|
||||
font-size: var(--fs-sm)
|
||||
|
@ -155,6 +155,12 @@ class PaymentForm(BillingAddressForm):
|
||||
but are still used by the payment flow.
|
||||
"""
|
||||
|
||||
gateway = looper.form_fields.GatewayChoiceField(
|
||||
queryset=looper.models.Gateway.objects.filter(name__in={'stripe', 'bank'}).order_by(
|
||||
'-is_default'
|
||||
)
|
||||
)
|
||||
|
||||
# These are used when a payment fails, so that the next attempt to pay can reuse
|
||||
# the already-created subscription and order.
|
||||
subscription_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
|
||||
|
@ -3,7 +3,6 @@ from typing import Set
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
import alphabetic_timestamp as ats
|
||||
@ -30,43 +29,6 @@ def timebased_order_number():
|
||||
return ats.base36.now(time_unit=ats.TimeUnit.milliseconds).upper()
|
||||
|
||||
|
||||
@receiver(django_signals.post_save, sender=User)
|
||||
def create_customer(sender, instance: User, created, raw, **kwargs):
|
||||
"""Create Customer on User creation."""
|
||||
from looper.models import Customer
|
||||
|
||||
if raw:
|
||||
return
|
||||
|
||||
if not created:
|
||||
return
|
||||
|
||||
my_log = logger.getChild('create_customer')
|
||||
try:
|
||||
customer = instance.customer
|
||||
except Customer.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
my_log.debug(
|
||||
'Newly created User %d already has a Customer %d, not creating new one',
|
||||
instance.pk,
|
||||
customer.pk,
|
||||
)
|
||||
billing_address = customer.billing_address
|
||||
my_log.info('Creating new billing address due to creation of user %s', instance.pk)
|
||||
if not billing_address.pk:
|
||||
billing_address.email = instance.email
|
||||
billing_address.save()
|
||||
return
|
||||
|
||||
my_log.info('Creating new Customer due to creation of user %s', instance.pk)
|
||||
with transaction.atomic():
|
||||
customer = Customer.objects.create(user=instance)
|
||||
billing_address = customer.billing_address
|
||||
billing_address.email = instance.email
|
||||
billing_address.save()
|
||||
|
||||
|
||||
@receiver(django_signals.pre_save, sender=Order)
|
||||
def _set_order_number(sender, instance: Order, **kwargs):
|
||||
if instance.pk or instance.number or instance.is_legacy:
|
||||
|
@ -43,7 +43,12 @@
|
||||
<div class="row mt-3">
|
||||
<div class="col text-end">
|
||||
{% if user.is_authenticated %}
|
||||
<button class="btn btn-primary w-100" id="submit-button" type="submit">{{ button_text|default:"Continue" }}</button>
|
||||
{% with gw=form.gateway.field.queryset.first %}
|
||||
<button class="btn btn-primary w-100" id="submit-button" type="submit"
|
||||
{% if gw %}name="gateway" value="{{ gw.name }}"{% endif %}>
|
||||
{{ button_text|default:"Continue" }}
|
||||
</button>
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<a class="btn btn-primary w-100 x-sign-in" href="{% url 'oauth:login' %}">Sign in with Blender ID</a>
|
||||
{% endif %}
|
||||
|
@ -30,7 +30,6 @@
|
||||
{% 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">
|
||||
@ -54,6 +53,17 @@
|
||||
</div>
|
||||
|
||||
{% include 'subscriptions/components/total.html' with button_text="Continue to Payment" %}
|
||||
|
||||
{% with gw_bank=form.gateway.field.queryset.last %}
|
||||
{% if current_plan_variation.collection_method == "manual" and gw_bank.name == "bank" %}
|
||||
<div class="row mt-3">
|
||||
<div class="col text-end">
|
||||
<button class="btn" type="submit" name="gateway" value="bank">Pay via Bank Transfer</button>
|
||||
<span data-tooltip title="{{ gw_bank.form_description|striptags }}" class="text-left"><i class="i-info text-warning"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
</form>
|
||||
|
@ -1,3 +1,4 @@
|
||||
from typing import Tuple
|
||||
import os
|
||||
|
||||
from django.core import mail
|
||||
@ -8,6 +9,8 @@ import factory
|
||||
import responses
|
||||
|
||||
from looper.tests.factories import create_customer_with_billing_address
|
||||
import looper.models
|
||||
|
||||
import users.tests.util as util
|
||||
|
||||
|
||||
@ -22,6 +25,16 @@ def _write_mail(mail, index=0):
|
||||
|
||||
|
||||
class BaseSubscriptionTestCase(TestCase):
|
||||
def _get_url_for(self, **filter_params) -> Tuple[str, looper.models.PlanVariation]:
|
||||
plan_variation = looper.models.PlanVariation.objects.active().get(**filter_params)
|
||||
return (
|
||||
reverse(
|
||||
'subscriptions:join-billing-details',
|
||||
kwargs={'plan_variation_id': plan_variation.pk},
|
||||
),
|
||||
plan_variation,
|
||||
)
|
||||
|
||||
@factory.django.mute_signals(signals.pre_save, signals.post_save)
|
||||
def setUp(self):
|
||||
# Allow requests to Braintree Sandbox
|
||||
@ -83,12 +96,15 @@ class BaseSubscriptionTestCase(TestCase):
|
||||
self._assert_continue_to_payment_displayed(response)
|
||||
self.assertContains(response, 'id_street_address')
|
||||
self.assertContains(response, 'id_full_name')
|
||||
self.assertContains(response, 'name="gateway" value="stripe"')
|
||||
self.assertNotContains(response, 'name="gateway" value="bank"')
|
||||
|
||||
def _assert_payment_form_displayed(self, response):
|
||||
self.assertNotContains(response, 'Pricing has been updated')
|
||||
self.assertNotContains(response, 'Continue to Payment')
|
||||
self.assertContains(response, 'payment method')
|
||||
self.assertContains(response, 'Confirm and Pay')
|
||||
def _assert_billing_details_form_with_pay_via_bank_displayed(self, response):
|
||||
self._assert_continue_to_payment_displayed(response)
|
||||
self.assertContains(response, 'id_street_address')
|
||||
self.assertContains(response, 'id_full_name')
|
||||
self.assertContains(response, 'name="gateway" value="stripe"')
|
||||
self.assertContains(response, 'name="gateway" value="bank"')
|
||||
|
||||
def _assert_pricing_has_been_updated(self, response):
|
||||
self.assertContains(response, 'Pricing has been updated')
|
||||
|
@ -213,6 +213,7 @@ class TestBillingAddressForm(BaseSubscriptionTestCase):
|
||||
|
||||
class TestPaymentForm(BaseSubscriptionTestCase):
|
||||
required_payment_form_data = {
|
||||
'gateway': 'bank',
|
||||
'plan_variation_id': 1,
|
||||
}
|
||||
|
||||
@ -240,6 +241,7 @@ class TestPaymentForm(BaseSubscriptionTestCase):
|
||||
'country': ['This field is required.'],
|
||||
'email': ['This field is required.'],
|
||||
'full_name': ['This field is required.'],
|
||||
'gateway': ['This field is required.'],
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -29,7 +29,7 @@ class JoinView(LoginRequiredMixin, FormView):
|
||||
"""Fill in billing details and initiate Stripe checkout session."""
|
||||
|
||||
# FIXME(anna): this view uses some functionality of CheckoutStripeView,
|
||||
# but cannot directly inherit from them, since JoinView supports creating only one subscription.
|
||||
# but cannot directly inherit it, since JoinView supports creating only one subscription.
|
||||
_fetch_or_create_order = CheckoutStripeView._fetch_or_create_order
|
||||
|
||||
template_name = 'subscriptions/join/billing_address.html'
|
||||
@ -75,7 +75,6 @@ class JoinView(LoginRequiredMixin, FormView):
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
self.gateway = looper.models.Gateway.default()
|
||||
self.user = request.user
|
||||
self.customer = self.user.customer
|
||||
self.subscription = self._get_existing_subscription()
|
||||
@ -96,13 +95,6 @@ class JoinView(LoginRequiredMixin, FormView):
|
||||
)
|
||||
return form_kwargs
|
||||
|
||||
def get_initial(self) -> dict:
|
||||
"""Prefill default payment gateway, country and selected plan options."""
|
||||
return {
|
||||
**super().get_initial(),
|
||||
'gateway': self.gateway.name,
|
||||
}
|
||||
|
||||
def get_context_data(self, **kwargs) -> dict:
|
||||
"""Add existing subscription to the view and the context."""
|
||||
return {
|
||||
@ -112,9 +104,8 @@ class JoinView(LoginRequiredMixin, FormView):
|
||||
}
|
||||
|
||||
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)
|
||||
"""Use Stripe by default, but allow bank transfer payments for manual plan variations."""
|
||||
self.gateway = looper.models.Gateway.objects.get(name=form.cleaned_data['gateway'])
|
||||
return self.gateway
|
||||
|
||||
def _get_or_create_subscription(
|
||||
@ -125,7 +116,8 @@ class JoinView(LoginRequiredMixin, FormView):
|
||||
if not subscription:
|
||||
subscription = looper.models.Subscription(customer=self.customer)
|
||||
is_new = True
|
||||
logger.debug('Creating an new subscription for %s, %s', gateway)
|
||||
args = [self.customer.pk, gateway]
|
||||
logger.debug('Creating a new subscription for customer pk=%s, %s', *args)
|
||||
collection_method = self.plan_variation.collection_method
|
||||
supported = set(gateway.provider.supported_collection_methods)
|
||||
if collection_method not in supported:
|
||||
@ -163,7 +155,7 @@ class JoinView(LoginRequiredMixin, FormView):
|
||||
|
||||
def form_invalid(self, form, *args, **kwargs):
|
||||
"""Temporarily log all validation errors."""
|
||||
logger.exception('Validation error in ConfirmAndPayView: %s, %s', form.errors, form.data)
|
||||
logger.exception('Validation error in JoinView: %s, %s', form.errors, form.data)
|
||||
return super().form_invalid(form, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
@ -210,6 +202,21 @@ class JoinView(LoginRequiredMixin, FormView):
|
||||
messages.warning(self.request, msg)
|
||||
return self.form_invalid(form)
|
||||
|
||||
if not gateway.provider.supports_transactions:
|
||||
logger.info(
|
||||
'Not creating transaction for order pk=%r because gateway %r does '
|
||||
'not support it',
|
||||
order.pk,
|
||||
gateway.name,
|
||||
)
|
||||
url = reverse(
|
||||
'looper:transactionless_checkout_done',
|
||||
kwargs={'pk': order.pk, 'gateway_name': gateway.name},
|
||||
)
|
||||
# Trigger an email with instructions about manual payment:
|
||||
subscription_created_needs_payment.send(sender=subscription)
|
||||
return redirect(url)
|
||||
|
||||
success_url = self.request.build_absolute_uri(
|
||||
reverse(
|
||||
'looper:stripe_success',
|
||||
@ -221,10 +228,6 @@ class JoinView(LoginRequiredMixin, FormView):
|
||||
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)
|
||||
|
||||
session = looper.stripe_utils.create_stripe_checkout_session_for_order(
|
||||
order,
|
||||
success_url,
|
||||
|
@ -39,15 +39,27 @@ full_billing_address_data = {
|
||||
# **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database.
|
||||
|
||||
|
||||
def _get_default_variation(currency='USD'):
|
||||
return looper.models.Plan.objects.first().variation_for_currency(currency)
|
||||
|
||||
|
||||
@freeze_time('2023-05-19 11:41:11')
|
||||
class TestGETJoinView(BaseSubscriptionTestCase):
|
||||
url_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1})
|
||||
url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2})
|
||||
|
||||
def test_pay_via_bank_transfer_button_is_shown_for_manual_plan_variation(self):
|
||||
customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
|
||||
user = customer.user
|
||||
self.client.force_login(user)
|
||||
|
||||
url, selected_variation = self._get_url_for(
|
||||
currency='EUR',
|
||||
interval_length=1,
|
||||
interval_unit='month',
|
||||
plan__name='Manual renewal',
|
||||
)
|
||||
response = self.client.get(url, REMOTE_ADDR=EURO_IPV4)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self._assert_billing_details_form_with_pay_via_bank_displayed(response)
|
||||
|
||||
def test_get_prefills_full_name_and_billing_email_from_user(self):
|
||||
user = UserFactory(full_name="Jane До", email='jane.doe@example.com')
|
||||
self.client.force_login(user)
|
||||
|
@ -30,7 +30,7 @@ full_billing_address_data = {
|
||||
}
|
||||
|
||||
|
||||
class TestSettingsBillingAddress(BaseSubscriptionTestCase):
|
||||
class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
|
||||
url = reverse('subscriptions:billing-address')
|
||||
|
||||
def test_saves_full_billing_address(self):
|
||||
|
@ -4,6 +4,7 @@ import logging
|
||||
from actstream.models import Action
|
||||
from anymail.signals import tracking
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import pre_save, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils.dateparse import parse_datetime
|
||||
@ -68,6 +69,44 @@ def _sync_is_subscribed_to_newsletter(sender: object, instance: User, **kwargs):
|
||||
tasks.handle_is_subscribed_to_newsletter(pk=instance.pk)
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_customer(sender, instance: User, created, raw, **kwargs):
|
||||
"""Create Customer on User creation."""
|
||||
from looper.models import Customer
|
||||
|
||||
if raw:
|
||||
return
|
||||
|
||||
if not created:
|
||||
return
|
||||
|
||||
try:
|
||||
customer = instance.customer
|
||||
except Customer.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
logger.debug(
|
||||
'Newly created User %d already has a Customer %d, not creating new one',
|
||||
instance.pk,
|
||||
customer.pk,
|
||||
)
|
||||
billing_address = customer.billing_address
|
||||
logger.info('Creating new billing address due to creation of user %s', instance.pk)
|
||||
if not billing_address.pk:
|
||||
billing_address.email = instance.email
|
||||
billing_address.full_name = instance.full_name
|
||||
billing_address.save()
|
||||
return
|
||||
|
||||
logger.info('Creating new Customer due to creation of user %s', instance.pk)
|
||||
with transaction.atomic():
|
||||
customer = Customer.objects.create(user=instance)
|
||||
billing_address = customer.billing_address
|
||||
billing_address.email = instance.email
|
||||
billing_address.full_name = instance.full_name
|
||||
billing_address.save()
|
||||
|
||||
|
||||
@receiver(tracking)
|
||||
def _handle_mailgun_tracking_event(sender, event, esp_name, **kwargs):
|
||||
event_type = event.event_type
|
||||
|
@ -5,8 +5,6 @@ from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
import responses
|
||||
|
||||
from common.tests.factories.users import UserFactory
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@ -145,9 +143,7 @@ def mock_mailgun_responses() -> None:
|
||||
|
||||
def create_admin_log_user() -> User:
|
||||
"""Create the admin user used for logging."""
|
||||
admin_user, _ = User.objects.get_or_create(
|
||||
id=1, email='admin@blender.studio', is_staff=True, is_superuser=True
|
||||
admin_user, _ = User.objects.update_or_create(
|
||||
id=1, defaults={'email': 'admin@blender.studio', 'is_staff': True, 'is_superuser': True}
|
||||
)
|
||||
# Reset ID sequence to avoid clashing with an already used ID 1
|
||||
UserFactory.reset_sequence(100, force=True)
|
||||
return admin_user
|
||||
|
Loading…
Reference in New Issue
Block a user