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 141 additions and 74 deletions
Showing only changes of commit aea40f57d7 - Show all commits

View File

@ -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)

View File

@ -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)

View File

@ -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:

View File

@ -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 %}

View File

@ -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>

View File

@ -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')

View File

@ -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.'],
},
)

View File

@ -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,

View File

@ -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)

View File

@ -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):

View File

@ -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

View File

@ -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