Stripe checkout #104411

Merged
Anna Sirota merged 61 commits from stripe into main 2024-06-17 18:08:41 +02:00
3 changed files with 156 additions and 76 deletions
Showing only changes of commit 0aa1a7afd6 - Show all commits

View File

@ -0,0 +1,59 @@
responses:
- response:
auto_calculate_content_length: false
body: "{\n \"id\": \"cus_QFaxRrT6ZbD9NN\",\n \"object\": \"customer\",\n \"\
address\": null,\n \"balance\": 0,\n \"created\": 1717778124,\n \"currency\"\
: null,\n \"default_source\": null,\n \"delinquent\": false,\n \"description\"\
: null,\n \"discount\": null,\n \"email\": \"my.billing.email@example.com\"\
,\n \"invoice_prefix\": \"046F772E\",\n \"invoice_settings\": {\n \"custom_fields\"\
: null,\n \"default_payment_method\": null,\n \"footer\": null,\n \"\
rendering_options\": null\n },\n \"livemode\": false,\n \"metadata\": {},\n\
\ \"name\": \"New Full Name\",\n \"phone\": null,\n \"preferred_locales\"\
: [],\n \"shipping\": null,\n \"tax_exempt\": \"none\",\n \"test_clock\"\
: null\n}"
content_type: text/plain
method: POST
status: 200
url: https://api.stripe.com/v1/customers
- response:
auto_calculate_content_length: false
body: "{\n \"id\": \"cs_test_a1hoP4Yj4ZmfghAwGoUtWJngVt1XreEVLGAj2n7U5o9BlvqhnDimuA07zh\"\
,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\
allow_promotion_codes\": null,\n \"amount_subtotal\": 990,\n \"amount_total\"\
: 990,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\":\
\ null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\
\ \"cancel_url\": \"http://testserver/join/plan-variation/2/billing/\",\n \
\ \"client_reference_id\": null,\n \"client_secret\": null,\n \"consent\"\
: null,\n \"consent_collection\": null,\n \"created\": 1717778125,\n \"currency\"\
: \"eur\",\n \"currency_conversion\": null,\n \"custom_fields\": [],\n \"\
custom_text\": {\n \"after_submit\": null,\n \"shipping_address\": null,\n\
\ \"submit\": null,\n \"terms_of_service_acceptance\": null\n },\n \"\
customer\": \"cus_QFaxRrT6ZbD9NN\",\n \"customer_creation\": null,\n \"customer_details\"\
: {\n \"address\": null,\n \"email\": \"my.billing.email@example.com\"\
,\n \"name\": null,\n \"phone\": null,\n \"tax_exempt\": \"none\",\n\
\ \"tax_ids\": null\n },\n \"customer_email\": null,\n \"expires_at\"\
: 1717864524,\n \"invoice\": null,\n \"invoice_creation\": {\n \"enabled\"\
: false,\n \"invoice_data\": {\n \"account_tax_ids\": null,\n \"\
custom_fields\": null,\n \"description\": null,\n \"footer\": null,\n\
\ \"issuer\": null,\n \"metadata\": {},\n \"rendering_options\"\
: null\n }\n },\n \"livemode\": false,\n \"locale\": null,\n \"metadata\"\
: {},\n \"mode\": \"payment\",\n \"payment_intent\": null,\n \"payment_link\"\
: null,\n \"payment_method_collection\": \"if_required\",\n \"payment_method_configuration_details\"\
: null,\n \"payment_method_options\": {\n \"card\": {\n \"request_three_d_secure\"\
: \"automatic\"\n }\n },\n \"payment_method_types\": [\n \"card\",\n\
\ \"link\",\n \"paypal\"\n ],\n \"payment_status\": \"unpaid\",\n \"\
phone_number_collection\": {\n \"enabled\": false\n },\n \"recovered_from\"\
: null,\n \"saved_payment_method_options\": {\n \"allow_redisplay_filters\"\
: [\n \"always\"\n ],\n \"payment_method_remove\": null,\n \"\
payment_method_save\": null\n },\n \"setup_intent\": null,\n \"shipping_address_collection\"\
: null,\n \"shipping_cost\": null,\n \"shipping_details\": null,\n \"shipping_options\"\
: [],\n \"status\": \"open\",\n \"submit_type\": \"pay\",\n \"subscription\"\
: null,\n \"success_url\": \"http://testserver/looper/stripe_success/1/{CHECKOUT_SESSION_ID}\"\
,\n \"total_details\": {\n \"amount_discount\": 0,\n \"amount_shipping\"\
: 0,\n \"amount_tax\": 0\n },\n \"ui_mode\": \"hosted\",\n \"url\": \"\
https://checkout.stripe.com/c/pay/cs_test_a1hoP4Yj4ZmfghAwGoUtWJngVt1XreEVLGAj2n7U5o9BlvqhnDimuA07zh#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl\"\
\n}"
content_type: text/plain
method: POST
status: 200
url: https://api.stripe.com/v1/checkout/sessions

View File

@ -1,5 +1,4 @@
"""Views handling subscription management."""
from decimal import Decimal
import logging
from django.contrib import messages
@ -43,6 +42,24 @@ class JoinView(LoginRequiredMixin, FormView):
)
return existing_subscriptions.first()
def _set_preferred_currency_and_redirect(self):
# If no country is set in the existing address, use GeoIP's
geoip_country = self.request.session.get(looper.middleware.COUNTRY_CODE_SESSION_KEY)
if geoip_country and (not self.customer or not self.customer.billing_address.country):
country = geoip_country
else:
country = self.customer.billing_address.country
currency = preferred_currency_for_country_code(country)
if self.plan_variation.currency != currency:
# If variation's currency doesn't match, redirect to another plan variation
plan_variation = self.plan_variation.in_other_currency(currency)
self.request.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY] = currency
self.request.session.modified = True
return redirect(
'subscriptions:join-billing-details', plan_variation_id=plan_variation.pk
)
return None # nothing to do, no need to redirect
def dispatch(self, request, *args, **kwargs):
"""Redirect to login or to billing, or prepare plan variation."""
if not request.user.is_authenticated:
@ -62,6 +79,10 @@ class JoinView(LoginRequiredMixin, FormView):
self.user = request.user
self.customer = self.user.customer
self.subscription = self._get_existing_subscription()
response_redirect = self._set_preferred_currency_and_redirect()
if response_redirect:
return response_redirect
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self, *args, **kwargs):
@ -164,27 +185,21 @@ class JoinView(LoginRequiredMixin, FormView):
form.save()
msg = 'Pricing has been updated to reflect changes to your billing details'
new_country = self.customer.billing_address.country
new_currency = preferred_currency_for_country_code(new_country)
# Compare currency before and after the billing address is updated
if self.plan_variation.currency != new_currency:
# If currency has changed, find a matching plan variation for this new currency
plan_variation = self.plan_variation.in_other_currency(new_currency)
self.request.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY] = new_currency
response_redirect = self._set_preferred_currency_and_redirect()
if response_redirect:
messages.add_message(self.request, messages.INFO, msg)
return redirect(
'subscriptions:join-billing-details', plan_variation_id=plan_variation.pk
)
return response_redirect
# Compare tax before and after the billing address is updated
new_tax = self.customer.get_tax(product_type=product_type)
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)
gateway = self.gateway_from_form(form)
price_cents = int(Decimal(form.cleaned_data['price']) * 100)
price_cents = new_taxable.price.cents
subscription = self._get_or_create_subscription(gateway)
# Update the tax info stored on the subscription
subscription.update_tax()

View File

@ -1,13 +1,15 @@
from typing import Tuple
from unittest.mock import patch
import os
import unittest
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from freezegun import freeze_time
import responses
# from responses import _recorder
from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4, SINGAPORE_IPV4
from looper.money import Money
import looper.models
@ -41,7 +43,7 @@ def _get_default_variation(currency='USD'):
@freeze_time('2023-05-19 11:41:11')
class TestGETBillingDetailsView(BaseSubscriptionTestCase):
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})
@ -65,7 +67,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
)
def test_get_displays_total_and_billing_details_to_logged_in_nl(self):
user = create_customer_with_billing_address(vat_number='', country='NL')
customer = create_customer_with_billing_address(vat_number='', country='NL')
user = customer.user
self.client.force_login(user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
@ -76,7 +79,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
self._assert_total_default_variation_selected_tax_21_eur(response)
def test_get_displays_total_and_billing_details_to_logged_in_de(self):
user = create_customer_with_billing_address(vat_number='', country='DE')
customer = create_customer_with_billing_address(vat_number='', country='DE')
user = customer.user
self.client.force_login(user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
@ -86,9 +90,10 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
self._assert_total_default_variation_selected_tax_19_eur(response)
def test_get_displays_total_and_billing_details_to_logged_in_us(self):
user = create_customer_with_billing_address(
customer = create_customer_with_billing_address(
vat_number='', country='US', region='NY', postal_code='12001'
)
user = customer.user
self.client.force_login(user)
response = self.client.get(self.url_usd)
@ -99,16 +104,23 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
self._assert_total_default_variation_selected_usd(response)
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
def test_get_detects_country_us_sets_preferred_currency_usd_invalid_variation(self):
user = create_customer_with_billing_address()
def test_get_detects_country_us_sets_preferred_currency_usd_and_redirects(self):
customer = create_customer_with_billing_address()
user = customer.user
self.client.force_login(user)
self.assertTrue(looper.middleware.PREFERRED_CURRENCY_SESSION_KEY not in self.client.session)
response = self.client.get(self.url, REMOTE_ADDR=USA_IPV4)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], self.url_usd)
self.assertEqual(
self.client.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY], 'USD'
)
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
def test_get_detects_country_us_sets_preferred_currency_usd(self):
user = create_customer_with_billing_address()
customer = create_customer_with_billing_address()
user = customer.user
self.client.force_login(user)
response = self.client.get(self.url_usd, REMOTE_ADDR=USA_IPV4)
@ -125,7 +137,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
def test_get_detects_country_sg_sets_preferred_currency_eur(self):
user = create_customer_with_billing_address()
customer = create_customer_with_billing_address()
user = customer.user
self.client.force_login(user)
response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4)
@ -142,7 +155,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
def test_get_detects_country_nl_sets_preferred_currency_eur_displays_correct_vat(self):
user = create_customer_with_billing_address()
customer = create_customer_with_billing_address()
user = customer.user
self.client.force_login(user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
@ -158,12 +172,15 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
@freeze_time('2023-05-19 11:41:11')
class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
@responses.activate
class TestPOSTJoinView(BaseSubscriptionTestCase):
url_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1})
url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2})
responses._add_from_file('stripe_create_checkout_session.yaml')
def test_post_updates_billing_address_and_customer_renders_next_form_de(self):
user = create_customer_with_billing_address(vat_number='', country='DE')
customer = create_customer_with_billing_address(vat_number='', country='DE')
user = customer.user
self.client.force_login(user)
selected_variation = (
@ -197,33 +214,21 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
self.assertContains(response, 'Manual ')
self.assertContains(response, '/ <span class="x-price-period">1 year</span>', html=True)
# @_recorder.record(file_path='stripe_create_checkout_session.yaml')
def test_post_has_correct_price_field_value(self):
self.client.force_login(self.user)
default_variation = _get_default_variation('EUR')
data = required_address_data
response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response['Location'],
reverse(
'subscriptions:join-confirm-and-pay',
kwargs={'plan_variation_id': default_variation.pk},
),
)
# Follow the redirect to avoid "Couldn't retrieve content: Response code was 302 (expected 200)"
response = self.client.get(response['Location'])
# Check that we are no longer on the billing details page
self._assert_payment_form_displayed(response)
self._assert_total_default_variation_selected_tax_21_eur(response)
# The hidden price field must also be set to a matching amount
self.assertContains(
response,
'<input type="hidden" name="price" value="9.90" class="form-control" id="id_price">',
html=True,
'https://checkout.stripe.com/c/pay/cs_test_a1hoP4Yj4ZmfghAwGoUtWJngVt1XreEVLGAj2'
'n7U5o9BlvqhnDimuA07zh#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8Zkx'
'sUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp'
'%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabH'
'FgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl',
)
def test_post_updates_billing_address_and_customer_applies_reverse_charged_tax(self):
@ -240,8 +245,8 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertEqual(self.user.customer.vat_number, 'DE260543043')
address = self.user.customer.billing_address
self.assertEqual(address.vat_number, 'DE260543043')
self.assertEqual(address.full_name, 'New Full Name')
self.assertEqual(address.postal_code, '11111')
self.assertEqual(address.country, 'DE')
@ -269,9 +274,10 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
)
def test_post_changing_address_from_with_region_to_without_region_clears_region(self):
user = create_customer_with_billing_address(
customer = create_customer_with_billing_address(
vat_number='', country='US', region='NY', postal_code='12001'
)
user = customer.user
self.client.force_login(user)
response = self.client.get(self.url_usd)
@ -304,22 +310,10 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
self.assertEqual(user.customer.billing_address.country, 'DE')
self.assertEqual(user.customer.billing_address.postal_code, '11111')
@freeze_time('2023-05-19 11:41:11')
class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
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-confirm-and-pay',
kwargs={'plan_variation_id': plan_variation.pk},
),
plan_variation,
)
def test_plan_variation_does_not_match_detected_currency_usd_euro_ip(self):
url, _ = self._get_url_for(currency='USD', price=11900)
user = create_customer_with_billing_address(country='NL')
customer = create_customer_with_billing_address(country='NL')
user = customer.user
self.client.force_login(user)
data = required_address_data
@ -329,7 +323,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
def test_plan_variation_matches_detected_currency_eur_non_eea_ip(self):
url, _ = self._get_url_for(currency='EUR', price=990)
user = create_customer_with_billing_address()
customer = create_customer_with_billing_address()
user = customer.user
self.client.force_login(user)
data = required_address_data
@ -341,7 +336,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
def test_billing_address_country_takes_precedence_over_geo_ip(self):
url, _ = self._get_url_for(currency='EUR', price=990)
user = create_customer_with_billing_address(country='NL')
customer = create_customer_with_billing_address(country='NL')
user = customer.user
self.client.force_login(user)
data = required_address_data
@ -352,7 +348,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
def test_invalid_missing_required_fields(self):
url, _ = self._get_url_for(currency='EUR', price=990)
user = create_customer_with_billing_address(country='NL')
customer = create_customer_with_billing_address(country='NL')
user = customer.user
self.client.force_login(user)
data = required_address_data
@ -371,7 +368,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
def test_invalid_price_does_not_match_selected_plan_variation(self):
url, selected_variation = self._get_url_for(currency='EUR', price=990)
user = create_customer_with_billing_address(country='NL')
customer = create_customer_with_billing_address(country='NL')
user = customer.user
self.client.force_login(user)
data = {
@ -391,7 +389,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
def test_invalid_bank_transfer_cannot_be_selected_for_automatic_payments(self):
url, selected_variation = self._get_url_for(currency='EUR', price=990)
user = create_customer_with_billing_address(country='NL')
customer = create_customer_with_billing_address(country='NL')
user = customer.user
self.client.force_login(user)
data = {
@ -423,7 +422,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
)
@responses.activate
def test_pay_with_bank_transfer_creates_order_subscription_on_hold(self):
user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
user = customer.user
self.client.force_login(user)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
@ -500,9 +500,10 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
def test_pay_with_bank_transfer_creates_order_subscription_on_hold_shows_reverse_charged_price(
self,
):
user = create_customer_with_billing_address(
customer = create_customer_with_billing_address(
country='ES', full_name='Jane Doe', vat_number='DE260543043'
)
user = customer.user
self.client.force_login(user)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
@ -584,7 +585,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
@responses.activate
def test_pay_with_credit_card_creates_order_subscription_active(self):
url, selected_variation = self._get_url_for(currency='EUR', price=990)
user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
user = customer.user
self.client.force_login(user)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
@ -636,7 +638,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
price=9000,
plan__name='Automatic renewal, 15 seats',
)
user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
user = customer.user
self.client.force_login(user)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
@ -655,7 +658,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self._assert_done_page_displayed(response)
subscription = user.customer.subscription_set.first()
subscription = customer.subscription_set.first()
order = subscription.latest_order()
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.price, Money('EUR', 9000))
@ -670,7 +673,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self._assert_team_subscription_activated_email_is_sent(subscription)
def test_pay_with_credit_card_creates_order_subscription_active_business_de(self):
user = create_customer_with_billing_address(country='DE', vat_number='DE260543043')
customer = create_customer_with_billing_address(country='DE', vat_number='DE260543043')
user = customer.user
self.client.force_login(user)
url, selected_variation = self._get_url_for(
@ -716,15 +720,17 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self.assertIsNotNone(order.number)
class TestJoinConfirmAndPayLoggedInUserOnlyView(BaseSubscriptionTestCase):
url = reverse('subscriptions:join-confirm-and-pay', kwargs={'plan_variation_id': 8})
class TestJoinViewLoggedInUserOnly(TestCase):
url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2})
def test_get_anonymous_403(self):
def test_get_anonymous_redirects_to_login_with_next(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], '/oauth/login?next=/join/plan-variation/2/billing/')
def test_join_post_anonymous_403(self):
def test_post_anonymous_redirects_to_login_with_next(self):
response = self.client.post(self.url)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], '/oauth/login?next=/join/plan-variation/2/billing/')