diff --git a/.env.example b/.env.example
index 955d37fe..476a0f00 100644
--- a/.env.example
+++ b/.env.example
@@ -49,6 +49,8 @@ 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=
+STRIPE_API_SECRET_KEY=
+STRIPE_ENDPOINT_SECRET=
diff --git a/characters/tests/test_characters.py b/characters/tests/test_characters.py
index a468aa07..2c1af2c4 100644
--- a/characters/tests/test_characters.py
+++ b/characters/tests/test_characters.py
@@ -174,7 +174,7 @@ class TestCharacterVersionDownload(TestCase):
def test_can_download_non_free_when_subscribed(self):
user = UserFactory()
- SubscriptionFactory(user=user, status='active')
+ SubscriptionFactory(customer=user.customer, status='active')
character_version = CharacterVersionFactory(is_free=False)
self.client.force_login(user)
@@ -222,7 +222,7 @@ class TestCharacterShowcaseDownload(TestCase):
def test_can_download_non_free_when_subscribed(self):
user = UserFactory()
- SubscriptionFactory(user=user, status='active')
+ SubscriptionFactory(customer=user.customer, status='active')
character_showcase = CharacterShowcaseFactory(is_free=False)
self.client.force_login(user)
diff --git a/common/context_processors.py b/common/context_processors.py
index caccb012..921a05ab 100644
--- a/common/context_processors.py
+++ b/common/context_processors.py
@@ -28,5 +28,4 @@ def extra_context(request: HttpRequest) -> Dict[str, str]:
},
'canonical_url': request.build_absolute_uri(request.path),
'ADMIN_MAIL': settings.ADMIN_MAIL,
- 'GOOGLE_RECAPTCHA_SITE_KEY': settings.GOOGLE_RECAPTCHA_SITE_KEY,
}
diff --git a/common/static/common/styles/studio/_custom.sass b/common/static/common/styles/studio/_custom.sass
index 3eb2fc43..2ad270d8 100644
--- a/common/static/common/styles/studio/_custom.sass
+++ b/common/static/common/styles/studio/_custom.sass
@@ -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)
diff --git a/common/tests/factories/subscriptions.py b/common/tests/factories/subscriptions.py
index 5fb2a7cd..db56328e 100644
--- a/common/tests/factories/subscriptions.py
+++ b/common/tests/factories/subscriptions.py
@@ -1,53 +1,10 @@
-from django.contrib.auth import get_user_model
-from django.db.models import signals
-
from factory.django import DjangoModelFactory
import factory
-import looper.models
+from looper.tests.factories import SubscriptionFactory
-from common.tests.factories.users import UserFactory
from subscriptions.models import Team
-User = get_user_model()
-
-
-class PaymentMethodFactory(DjangoModelFactory):
- class Meta:
- model = looper.models.PaymentMethod
-
- gateway_id = factory.LazyAttribute(lambda _: looper.models.Gateway.objects.first().pk)
-
- user = factory.SubFactory('common.tests.factories.users.UserFactory')
-
-
-class SubscriptionFactory(DjangoModelFactory):
- class Meta:
- model = looper.models.Subscription
-
- plan_id = factory.LazyAttribute(lambda _: looper.models.Plan.objects.first().pk)
- payment_method = factory.SubFactory(PaymentMethodFactory)
-
- user = factory.SubFactory('common.tests.factories.users.UserFactory')
-
-
-class OrderFactory(DjangoModelFactory):
- class Meta:
- model = looper.models.Order
-
- user = factory.SubFactory('common.tests.factories.users.UserFactory')
- subscription = factory.SubFactory(SubscriptionFactory)
- payment_method = factory.SubFactory(PaymentMethodFactory)
-
-
-class TransactionFactory(DjangoModelFactory):
- class Meta:
- model = looper.models.Transaction
-
- user = factory.SubFactory('common.tests.factories.users.UserFactory')
- order = factory.SubFactory(OrderFactory)
- payment_method = factory.SubFactory(PaymentMethodFactory)
-
class TeamFactory(DjangoModelFactory):
class Meta:
@@ -55,38 +12,3 @@ class TeamFactory(DjangoModelFactory):
name = factory.Faker('text', max_nb_chars=15)
subscription = factory.SubFactory(SubscriptionFactory)
-
-
-@factory.django.mute_signals(signals.pre_save, signals.post_save)
-class CustomerFactory(DjangoModelFactory):
- class Meta:
- model = looper.models.Customer
-
- billing_email = factory.LazyAttribute(lambda o: '%s.billing@example.com' % o.user.username)
- user = factory.SubFactory('common.tests.factories.users.UserFactory')
-
-
-@factory.django.mute_signals(signals.pre_save, signals.post_save)
-class AddressFactory(DjangoModelFactory):
- class Meta:
- model = looper.models.Address
-
- user = factory.SubFactory('common.tests.factories.users.UserFactory')
-
-
-# TODO(anna): this should probably move to looper
-@factory.django.mute_signals(signals.pre_save, signals.post_save)
-def create_customer_with_billing_address(**data):
- """Use factories to create a User with a Customer and Address records."""
- customer_field_names = {f.name for f in looper.models.Customer._meta.get_fields()}
- address_field_names = {f.name for f in looper.models.Address._meta.get_fields()}
- user_field_names = {f.name for f in User._meta.get_fields()}
-
- address_kwargs = {k: v for k, v in data.items() if k in address_field_names}
- customer_kwargs = {k: v for k, v in data.items() if k in customer_field_names}
- user_kwargs = {k: v for k, v in data.items() if k in user_field_names}
-
- user = UserFactory(**user_kwargs)
- AddressFactory(user=user, **address_kwargs)
- CustomerFactory(user=user, **customer_kwargs)
- return user
diff --git a/docs/web-assets-migrate-guidelines.md b/docs/web-assets-migrate-guidelines.md
index 6c45afa1..1048df14 100644
--- a/docs/web-assets-migrate-guidelines.md
+++ b/docs/web-assets-migrate-guidelines.md
@@ -249,8 +249,6 @@ Symbol | Description
│ ├── 🔴 bank_transfer_details.txt
│ ├── 🔴 bank_transfer_reference.txt
│ ├── 🟢 billing_address_form.html
-│ ├── 🟢 billing_address_form_readonly.html
-│ ├── 🟢 current_plan_variation.html
│ ├── 🟢 footer.html
│ ├── ❌ header_jumbotron.html
│ ├── 🟢 info.html
@@ -265,11 +263,8 @@ Symbol | Description
│ └── 🟢 total.html
├── join
│ ├── 🟢 billing_address.html
-│ ├── 🟢 payment_method.html
│ └── ⚪ select_plan_variation.html
├── 🟢 manage.html
-├── 🟢 pay_existing_order.html
-├── 🟢 payment_method_change.html
└── widgets
└── ⚪ region_select.html
```
diff --git a/emails/admin.py b/emails/admin.py
index 21102ac8..6d98ab7f 100644
--- a/emails/admin.py
+++ b/emails/admin.py
@@ -93,12 +93,13 @@ class SubscriptionEmailPreviewAdmin(looper.admin.mixins.NoAddDeleteMixin, EmailA
def get_object(self, request, object_id, from_field=None):
"""Construct the Email on th fly from known subscription email templates."""
- user = User()
- user.customer = looper.models.Customer(full_name='Jane Doe')
+ user = User(full_name='Jane Doe')
+ customer = looper.models.Customer(user=user)
+ user.customer = customer
now = timezone.now()
subscription = looper.models.Subscription(
id=1234567890,
- user=user,
+ customer=user.customer,
payment_method=looper.models.PaymentMethod(
method_type='cc',
gateway_id=1,
@@ -112,7 +113,7 @@ class SubscriptionEmailPreviewAdmin(looper.admin.mixins.NoAddDeleteMixin, EmailA
)
order = looper.models.Order(subscription=subscription)
context = {
- 'user': subscription.user,
+ 'user': user,
'subscription': subscription,
'order': order,
# Also add context for the expired email
diff --git a/playbooks/templates/dotenv b/playbooks/templates/dotenv
index d79d2c68..03368e16 100644
--- a/playbooks/templates/dotenv
+++ b/playbooks/templates/dotenv
@@ -54,6 +54,8 @@ 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=
+STRIPE_API_SECRET_KEY=
+STRIPE_ENDPOINT_SECRET=
diff --git a/poetry.lock b/poetry.lock
index 77825334..20edfea0 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1300,7 +1300,7 @@ six = ">=1.11.0"
[[package]]
name = "looper"
-version = "2.1.3"
+version = "3.2.9"
description = ""
category = "main"
optional = false
@@ -1316,19 +1316,21 @@ braintree = "4.17.1"
colorhash = "^1.0.3"
django = "^2.2.0 || 3.0 || 3.0.* || 3.2.*"
django-countries = "^7.2.1"
+django-nested-admin = "^4.0.2"
django-pipeline = "^2.0.6"
geoip2 = "^3.0"
python-dateutil = "^2.7"
python-stdnum = "^1.16"
requests = "^2.22"
+stripe = "7.1.0"
xhtml2pdf = "^0.2"
zeep = "4.0.0"
[package.source]
type = "git"
url = "https://projects.blender.org/infrastructure/looper.git"
-reference = "10bca5a012"
-resolved_reference = "10bca5a012f3deeede160b3520db630da6b9dfa5"
+reference = "8cd4da9"
+resolved_reference = "8cd4da950b21b85e0a5ecfa8e8e3d62774215a67"
[[package]]
name = "lxml"
@@ -2687,6 +2689,22 @@ files = [
{file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"},
]
+[[package]]
+name = "stripe"
+version = "7.1.0"
+description = "Python bindings for the Stripe API"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "stripe-7.1.0-py2.py3-none-any.whl", hash = "sha256:efd1e54825752c41bb311497cb5b6ae745464a57ca63bbe2847984a2409bcb0a"},
+ {file = "stripe-7.1.0.tar.gz", hash = "sha256:9cc2632230d5742eeb779af2b41c1510e724f498a296dfb40507de98d563f9a2"},
+]
+
+[package.dependencies]
+requests = {version = ">=2.20", markers = "python_version >= \"3.0\""}
+typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""}
+
[[package]]
name = "tblib"
version = "3.0.0"
@@ -2783,14 +2801,14 @@ files = [
[[package]]
name = "typing-extensions"
-version = "4.4.0"
-description = "Backported and Experimental Type Hints for Python 3.7+"
+version = "4.12.1"
+description = "Backported and Experimental Type Hints for Python 3.8+"
category = "main"
optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
files = [
- {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"},
- {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"},
+ {file = "typing_extensions-4.12.1-py3-none-any.whl", hash = "sha256:6024b58b69089e5a89c347397254e35f1bf02a907728ec7fee9bf0fe837d203a"},
+ {file = "typing_extensions-4.12.1.tar.gz", hash = "sha256:915f5e35ff76f56588223f15fdd5938f9a1cf9195c0de25130c627e4d597f6d1"},
]
[[package]]
@@ -2939,4 +2957,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata]
lock-version = "2.0"
python-versions = "^3.8"
-content-hash = "1039e10638790d46a95584572ec99691f740e74be5166fe4c86ebe52b4e953c5"
+content-hash = "69a6d187007cb4d99d90cf4472b9295a03c6c18518efcea4b6c50dc0d3dfef9f"
diff --git a/pyproject.toml b/pyproject.toml
index 2f905855..0bc94d9c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,7 +14,7 @@ libsasscompiler = "^0.1.5"
jsmin = "3.0.0"
sorl-thumbnail = "^12.10.0"
mistune = "2.0.0a4"
-looper = {git = "https://projects.blender.org/infrastructure/looper.git", rev = "10bca5a012"}
+looper = {git = "https://projects.blender.org/infrastructure/looper.git", rev = "8cd4da9"}
Pillow = "^8.0"
django-storages = {extras = ["google"], version = "1.11.1"}
pymongo = "^3.10.1"
diff --git a/studio/settings.py b/studio/settings.py
index 5331d441..75911632 100644
--- a/studio/settings.py
+++ b/studio/settings.py
@@ -504,6 +504,12 @@ GATEWAYS = {
'supported_collection_methods': {'automatic', 'manual'},
},
'bank': {'supported_collection_methods': {'manual'}},
+ 'stripe': {
+ 'api_publishable_key': _get('STRIPE_API_PUBLISHABLE_KEY'),
+ 'api_secret_key': _get('STRIPE_API_SECRET_KEY'),
+ 'endpoint_secret': _get('STRIPE_ENDPOINT_SECRET'),
+ 'supported_collection_methods': {'automatic', 'manual'},
+ },
}
# Optional Sentry configuration
@@ -630,8 +636,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': {
@@ -676,5 +680,15 @@ S3DIRECT_DESTINATIONS = {
},
}
+# A list of payment method types used with stripe for setting a recurring payment:
+# https://stripe.com/docs/api/checkout/sessions/create#create_checkout_session-payment_method_types
+STRIPE_OFF_SESSION_PAYMENT_METHOD_TYPES = [
+ 'card',
+ 'link',
+ 'paypal',
+]
+
+STRIPE_CHECKOUT_SUBMIT_TYPE = 'pay'
+
# Maximum number of attempts for failing background tasks
MAX_ATTEMPTS = 3
diff --git a/subscriptions/forms.py b/subscriptions/forms.py
index 63076d68..92e806e4 100644
--- a/subscriptions/forms.py
+++ b/subscriptions/forms.py
@@ -5,13 +5,12 @@ import logging
from django import forms
from django.core.exceptions import ValidationError
from django.forms.fields import Field
-from django.forms.models import model_to_dict
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
import looper.forms
import looper.models
@@ -48,17 +47,11 @@ REQUIRED_FIELDS = {
class BillingAddressForm(forms.ModelForm):
- """Unify Customer and Address in a single form."""
-
- # Customer.billing_email is exposed as email in the Form
- # because Looper scripts and forms already use "email" everywhere.
- __customer_fields = {'billing_email': 'email', 'vat_number': 'vat_number'}
- # Colliding "full_name" and "company" values are taken from and saved to the Address.
- # FIXME(anna): do we need to use company and full_name on the Customer or only Address?
+ """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.
@@ -81,20 +74,6 @@ class BillingAddressForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
"""Load additional model data from Customer and set form placeholders."""
- instance: looper.models.Address = kwargs.get('instance')
- if instance:
- assert isinstance(instance, looper.models.Address), 'Must be an instance of Address'
- customer = instance.user.customer
- initial = kwargs.get('initial') or {}
- customer_data = model_to_dict(customer, self.__customer_fields.keys(), {})
- # Remap the fields, e.g. turning "billing_email" into "email"
- customer_form_data = {v: customer_data[k] for k, v in self.__customer_fields.items()}
- # Add Customer data into initial,
- # making sure that it still overrides the instance data, as it's supposed to
- kwargs['initial'] = {
- **customer_form_data,
- **initial,
- }
super().__init__(*args, **kwargs)
# Set placeholder values on all form fields
@@ -160,43 +139,62 @@ class BillingAddressForm(forms.ModelForm):
)
def save(self, commit=True):
- """Save Customer data as well."""
+ """Save cleared region field."""
# Validation against region choices is already done, because choices are set on __init__,
# however Django won't set the updated blank region value if was omitted from the form.
if self.cleaned_data['region'] == '':
self.instance.region = ''
instance = super().save(commit=commit)
-
- customer = instance.user.customer
- for model_field, form_field in self.__customer_fields.items():
- setattr(customer, model_field, self.cleaned_data[form_field])
- if commit:
- customer.save(update_fields=self.__customer_fields)
return instance
-class BillingAddressReadonlyForm(forms.ModelForm):
- """Display the billing details in a payment form but neither validate nor update them.
+class PaymentForm(BillingAddressForm):
+ """Handle PlanVariation ID and payment method details in the second step of the checkout.
- Used in PaymentMethodChangeView and PayExistingOrderView.
+ Billing details are displayed as read-only and cannot be edited,
+ but are still used by the payment flow.
"""
- class Meta:
- model = looper.models.Address
- fields = looper.models.Address.PUBLIC_FIELDS
+ gateway = looper.form_fields.GatewayChoiceField(
+ queryset=looper.models.Gateway.objects.filter(name__in={'stripe', 'bank'}).order_by(
+ '-is_default'
+ )
+ )
def __init__(self, *args, **kwargs):
- """Disable all the billing details fields.
+ """Pre-fill additional initial data from request."""
+ self.request = kwargs.pop('request', None)
+ self.plan_variation = kwargs.pop('plan_variation', None)
- The billing details are only for display and for use by the payment flow.
- """
super().__init__(*args, **kwargs)
- for field_name, field in self.fields.items():
- if field_name not in BILLING_DETAILS_PLACEHOLDERS:
- continue
- field.disabled = True
- email = forms.EmailField(required=False, disabled=True)
+ self._set_initial_from_request()
+
+ def _set_initial_from_request(self):
+ if not self.request:
+ return
+
+ # 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
+
+ def clean_gateway(self):
+ """Validate gateway against selected plan variation."""
+ gw = self.cleaned_data['gateway']
+ if not self.plan_variation:
+ return gw
+ if self.plan_variation.collection_method not in gw.provider.supported_collection_methods:
+ msg = self.fields['gateway'].default_error_messages['invalid_choice']
+ self.add_error('gateway', msg)
+ return gw
class SelectPlanVariationForm(forms.Form):
@@ -223,58 +221,6 @@ class SelectPlanVariationForm(forms.Form):
)
-class PaymentForm(BillingAddressForm):
- """Handle PlanVariation ID and payment method details in the second step of the checkout.
-
- Billing details are displayed as read-only and cannot be edited,
- but are still used by the payment flow.
- """
-
- payment_method_nonce = forms.CharField(initial='set-in-javascript', widget=forms.HiddenInput())
- gateway = looper.form_fields.GatewayChoiceField()
- device_data = forms.CharField(
- initial='set-in-javascript', widget=forms.HiddenInput(), required=False
- )
-
- price = forms.CharField(widget=forms.HiddenInput(), required=True)
-
- # 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)
- order_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
-
-
-class AutomaticPaymentForm(PaymentForm):
- """Same as the PaymentForm, but only allows payment gateways that support transactions."""
-
- gateway = looper.form_fields.GatewayChoiceField(
- queryset=looper.models.Gateway.objects.filter(
- name__in=looper.gateways.Registry.gateway_names_supports_transactions()
- )
- )
-
-
-class PayExistingOrderForm(BillingAddressReadonlyForm):
- """Same as AutomaticPaymentForm, but doesn't validate or update billing details."""
-
- payment_method_nonce = forms.CharField(initial='set-in-javascript', widget=forms.HiddenInput())
- gateway = looper.form_fields.GatewayChoiceField(
- queryset=looper.models.Gateway.objects.filter(
- name__in=looper.gateways.Registry.gateway_names_supports_transactions()
- )
- )
- device_data = forms.CharField(
- initial='set-in-javascript', widget=forms.HiddenInput(), required=False
- )
- price = forms.CharField(widget=forms.HiddenInput(), required=True)
-
-
-class ChangePaymentMethodForm(BillingAddressReadonlyForm, looper.forms.ChangePaymentMethodForm):
- """Add full billing address to the change payment form."""
-
- pass
-
-
class CancelSubscriptionForm(forms.Form):
"""Confirm cancellation of a subscription."""
diff --git a/subscriptions/migrations/0002_add_gateways.py b/subscriptions/migrations/0002_add_gateways.py
index 43402321..e7d4eb7b 100644
--- a/subscriptions/migrations/0002_add_gateways.py
+++ b/subscriptions/migrations/0002_add_gateways.py
@@ -1,29 +1,10 @@
from django.conf import settings
+from django.core.management import call_command
from django.db import migrations
-form_description = {
- 'bank': 'When choosing to pay by bank, you will be required to manually perform payments and the subscription will be activated only when we receive the funds.',
- 'braintree': 'Automatic charges',
-}
-frontend_names = {
- 'bank': 'Bank Transfer',
- 'braintree': 'Credit Card or PayPal',
-}
-
-
def add_gateways(apps, schema_editor):
- Gateway = apps.get_model('looper', 'Gateway')
-
- for (name, _) in Gateway._meta.get_field('name').choices:
- if name == 'mock':
- continue
- Gateway.objects.get_or_create(
- name=name,
- frontend_name=frontend_names.get(name),
- is_default=name.lower() == 'braintree',
- form_description=form_description.get(name),
- )
+ call_command('loaddata', 'gateways.json', app_label='looper')
def remove_gateways(apps, schema_editor):
diff --git a/subscriptions/models.py b/subscriptions/models.py
index 651b3475..1290084a 100644
--- a/subscriptions/models.py
+++ b/subscriptions/models.py
@@ -90,7 +90,7 @@ class Team(mixins.CreatedUpdatedMixin, models.Model):
"""Add given user to the team."""
seats_taken = self.users.count()
# Not adding the team manager to the team
- if user.pk == self.subscription.user_id:
+ if user.pk == self.subscription.customer.user_id:
return
if self.seats is not None and seats_taken >= self.seats:
logger.warning(
diff --git a/subscriptions/queries.py b/subscriptions/queries.py
index 67e9984c..5723c34a 100644
--- a/subscriptions/queries.py
+++ b/subscriptions/queries.py
@@ -19,7 +19,7 @@ def has_active_subscription(user: User) -> bool:
active_subscriptions: 'QuerySet[Subscription]' = Subscription.objects.active()
return active_subscriptions.filter(
- Q(user_id=user.id) | Q(team__team_users__user_id=user.id)
+ Q(customer=user.customer) | Q(team__team_users__user_id=user.id)
).exists()
@@ -33,7 +33,9 @@ def has_non_legacy_subscription(user: User) -> bool:
subscriptions: 'QuerySet[Subscription]' = Subscription.objects.filter(is_legacy=False)
- return subscriptions.filter(Q(user_id=user.id) | Q(team__team_users__user_id=user.id)).exists()
+ return subscriptions.filter(
+ Q(customer=user.customer) | Q(team__team_users__user_id=user.id)
+ ).exists()
def has_subscription(user: User) -> bool:
@@ -42,7 +44,7 @@ def has_subscription(user: User) -> bool:
return False
return Subscription.objects.filter(
- Q(user_id=user.id) | Q(team__team_users__user_id=user.id)
+ Q(customer=user.customer) | Q(team__team_users__user_id=user.id)
).exists()
@@ -51,7 +53,8 @@ def should_redirect_to_billing(user: User) -> bool:
if not user.is_authenticated:
return False
- if user.subscription_set.exclude(status__in=Subscription._CANCELLED_STATUSES).count() == 0:
+ customer = user.customer
+ if customer.subscription_set.exclude(status__in=Subscription._CANCELLED_STATUSES).count() == 0:
# Only cancelled subscriptions, no need to redirect to billing
return False
@@ -60,7 +63,7 @@ def should_redirect_to_billing(user: User) -> bool:
# so this seems to be the only currently available way to tell
# when to stop showing the checkout to the customer.
subscription.latest_order() and subscription.payment_method
- for subscription in user.subscription_set.all()
+ for subscription in customer.subscription_set.all()
)
@@ -76,4 +79,4 @@ def has_not_yet_cancelled_subscription(user: User) -> bool:
status__in=Subscription._CANCELLED_STATUSES
)
- return not_yet_cancelled_subscriptions.filter(Q(user_id=user.id)).exists()
+ return not_yet_cancelled_subscriptions.filter(Q(customer=user.customer)).exists()
diff --git a/subscriptions/signals.py b/subscriptions/signals.py
index 994e214d..4f520d63 100644
--- a/subscriptions/signals.py
+++ b/subscriptions/signals.py
@@ -3,6 +3,7 @@ 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,17 +31,39 @@ def timebased_order_number():
@receiver(django_signals.post_save, sender=User)
-def create_customer(sender, instance: User, created, **kwargs):
+def create_customer(sender, instance: User, created, raw, **kwargs):
"""Create Customer on User creation."""
+ if raw:
+ return
+
if not created:
return
- logger.debug("Creating Customer for user %i" % instance.id)
- # Assume billing name and email are the same, they should be able to change them later
- Customer.objects.create(
- user_id=instance.pk,
- billing_email=instance.email,
- full_name=instance.full_name,
- )
+
+ 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(django_signals.pre_save, sender=Order)
@@ -54,7 +77,8 @@ 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
+ users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription')
@receiver(looper.signals.subscription_activated)
@@ -65,8 +89,9 @@ 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
+ 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
@@ -79,8 +104,9 @@ def _on_subscription_status_activated(sender: looper.models.Subscription, **kwar
@receiver(looper.signals.subscription_expired)
def _on_subscription_status_deactivated(sender: looper.models.Subscription, **kwargs):
# No other active subscription exists, subscriber badge can be revoked
- if not queries.has_active_subscription(sender.user):
- users.tasks.revoke_blender_id_role(pk=sender.user_id, role='cloud_subscriber')
+ user = sender.customer.user
+ if not queries.has_active_subscription(user):
+ users.tasks.revoke_blender_id_role(pk=user.pk, role='cloud_subscriber')
if not hasattr(sender, 'team'):
return
@@ -118,7 +144,8 @@ def _on_subscription_expired(sender: looper.models.Subscription, **kwargs):
assert sender.status == 'expired', f'Expected expired, got "{sender.status} (pk={sender.pk})"'
# Only send a "subscription expired" email when there are no other active subscriptions
- if not queries.has_active_subscription(sender.user):
+ user = sender.customer.user
+ if not queries.has_active_subscription(user):
tasks.send_mail_subscription_expired(subscription_id=sender.pk)
diff --git a/subscriptions/tasks.py b/subscriptions/tasks.py
index 697b97c8..af650fc6 100644
--- a/subscriptions/tasks.py
+++ b/subscriptions/tasks.py
@@ -44,8 +44,9 @@ def _construct_subscription_mail(mail_name: str, context: Dict[str, Any]) -> Tup
def send_mail_bank_transfer_required(subscription_id: int):
"""Send out an email notifying about the required bank transfer payment."""
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
- user = subscription.user
- email = user.customer.billing_email or user.email
+ customer = subscription.customer
+ user = customer.user
+ email = customer.billing_address.email or user.email
assert (
email
), f'Cannot send notification about bank payment for subscription {subscription.pk}: no email'
@@ -65,7 +66,7 @@ def send_mail_bank_transfer_required(subscription_id: int):
assert order, "Can't send a notificaton about bank transfer without an existing order"
context = {
- 'user': subscription.user,
+ 'user': user,
'subscription': subscription,
'order': order,
**get_template_context(),
@@ -89,8 +90,9 @@ def send_mail_bank_transfer_required(subscription_id: int):
def send_mail_subscription_status_changed(subscription_id: int):
"""Send out an email notifying about the activated subscription."""
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
- user = subscription.user
- email = user.customer.billing_email or user.email
+ customer = subscription.customer
+ user = customer.user
+ email = customer.billing_address.email or user.email
assert email, f'Cannot send notification about subscription {subscription.pk} status: no email'
if is_noreply(email):
raise
@@ -109,7 +111,7 @@ def send_mail_subscription_status_changed(subscription_id: int):
verb = 'deactivated'
context = {
- 'user': subscription.user,
+ 'user': user,
'subscription': subscription,
'verb': verb,
**get_template_context(),
@@ -133,9 +135,9 @@ def send_mail_automatic_payment_performed(order_id: int, transaction_id: int):
"""Send out an email notifying about the soft-failed payment."""
order = looper.models.Order.objects.get(pk=order_id)
transaction = looper.models.Transaction.objects.get(pk=transaction_id)
- user = order.user
- customer = user.customer
- email = customer.billing_email or user.email
+ customer = order.customer
+ user = customer.user
+ email = customer.billing_address.email or user.email
logger.debug('Sending %r notification to %s', order.status, email)
# An Unsubscribe record will prevent this message from being delivered by Mailgun.
@@ -148,7 +150,7 @@ def send_mail_automatic_payment_performed(order_id: int, transaction_id: int):
receipt_url = absolute_url('subscriptions:receipt', kwargs={'order_id': order.pk})
context = {
- 'user': subscription.user,
+ 'user': user,
'email': email,
'order': order,
'subscription': subscription,
@@ -185,7 +187,8 @@ def send_mail_managed_subscription_notification(subscription_id: int):
subscription.pk,
)
- user = subscription.user
+ customer = subscription.customer
+ user = customer.user
admin_url = absolute_url(
'admin:looper_subscription_change',
kwargs={'object_id': subscription.id},
@@ -220,7 +223,8 @@ def send_mail_managed_subscription_notification(subscription_id: int):
def send_mail_subscription_expired(subscription_id: int):
"""Send out an email notifying about an expired subscription."""
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
- user = subscription.user
+ customer = subscription.customer
+ user = customer.user
assert (
subscription.status == 'expired'
@@ -229,7 +233,7 @@ def send_mail_subscription_expired(subscription_id: int):
if queries.has_active_subscription(user):
logger.error(
'Not sending subscription-expired notification: pk=%s has other active subscriptions',
- subscription.user_id,
+ user.pk,
)
return
@@ -248,7 +252,7 @@ def send_mail_subscription_expired(subscription_id: int):
logger.debug('Sending subscription-expired notification to %s', email)
context = {
- 'user': subscription.user,
+ 'user': user,
'subscription': subscription,
'latest_trainings': get_latest_trainings_and_production_lessons(),
'latest_posts': Post.objects.filter(is_published=True)[:5],
@@ -280,9 +284,9 @@ def send_mail_no_payment_method(order_id: int):
), 'send_mail_no_payment_method expects automatic subscription'
assert 'fail' in order.status, f'Unexpected order pk={order_id} status: {order.status}'
- user = order.user
- customer = user.customer
- email = customer.billing_email or user.email
+ customer = order.customer
+ user = customer.user
+ email = customer.billing_address.email or user.email
logger.debug('Sending %r notification to %s', order.status, email)
# An Unsubscribe record will prevent this message from being delivered by Mailgun.
@@ -295,7 +299,7 @@ def send_mail_no_payment_method(order_id: int):
receipt_url = absolute_url('subscriptions:receipt', kwargs={'order_id': order.pk})
context = {
- 'user': subscription.user,
+ 'user': user,
'email': email,
'order': order,
'subscription': subscription,
diff --git a/subscriptions/templates/checkout/checkout_done_transactionless.html b/subscriptions/templates/checkout/checkout_done_transactionless.html
index 907059ef..ce4b1c27 100644
--- a/subscriptions/templates/checkout/checkout_done_transactionless.html
+++ b/subscriptions/templates/checkout/checkout_done_transactionless.html
@@ -14,7 +14,7 @@
-
+
Thanks for subscribing to Blender Studio!
Make sure to follow the instructions below to activate your account.
diff --git a/subscriptions/templates/settings/billing_address.html b/subscriptions/templates/settings/billing_address.html
index c9574a92..158b849e 100644
--- a/subscriptions/templates/settings/billing_address.html
+++ b/subscriptions/templates/settings/billing_address.html
@@ -1,6 +1,5 @@
{% extends 'users/settings/base.html' %}
{% load common_extras %}
-{% load pipeline %}
{% block settings %}
Settings: Subscription
@@ -15,7 +14,3 @@
{% endwith %}
{% endblock settings %}
-
-{% block scripts %}
- {% javascript "subscriptions" %}
-{% endblock scripts %}
diff --git a/subscriptions/templates/subscriptions/components/billing_address_form_readonly.html b/subscriptions/templates/subscriptions/components/billing_address_form_readonly.html
deleted file mode 100644
index 072e856a..00000000
--- a/subscriptions/templates/subscriptions/components/billing_address_form_readonly.html
+++ /dev/null
@@ -1,66 +0,0 @@
-
-
-
- {% with field=form.full_name %}
- {{ field.value }}
- {{ field.as_hidden }}
- {% endwith %}
-
-
- {% with field=form.company %}
-
- {{ field.value }}
- {{ field.as_hidden }}
-
- {% endwith %}
-
-
- {% with field=form.email %}
- {{ field.value }}
- {{ field.as_hidden }}
- {% endwith %}
-
-
- {% with field=form.street_address %}
- {{ field.value }}
- {{ field.as_hidden }}
- {% endwith %}
-
- {% with field=form.extended_address %}
-
- {{ field.value }}
- {{ field.as_hidden }}
-
- {% endwith %}
-
-
- {% with field=form.postal_code %}
- {{ field.value }}
- {{ field.as_hidden }}
- {% endwith %}
-
- {% with field=form.region %}
-
- {{ field.value }}
- {{ field.as_hidden }}
-
- {% endwith %}
-
- {% with field=form.locality %}
- {{ field.value }}
- {{ field.as_hidden }}
- {% endwith %}
- {% with field=form.country %}
- {{ field.value }}
- {{ field.as_hidden }}
- {% endwith %}
-
-
- {% with field=form.vat_number %}
-
- VATIN: {{ field.value }}
- {{ field.as_hidden }}
-
- {% endwith %}
-
-
diff --git a/subscriptions/templates/subscriptions/components/current_plan_variation.html b/subscriptions/templates/subscriptions/components/current_plan_variation.html
deleted file mode 100644
index b1adb85c..00000000
--- a/subscriptions/templates/subscriptions/components/current_plan_variation.html
+++ /dev/null
@@ -1,23 +0,0 @@
-{% load looper %}
-{% load subscriptions %}
-{% get_taxable current_plan_variation.price current_plan_variation.plan.product.type as current_price %}
-
-
-
-
-
Renewal
-
-
- {{ current_plan_variation.collection_method|capfirst }}, {{ current_plan_variation|recurring_pricing:current_price.price }}
-
-
-
-
-
Total
-
-
-
{{ current_price.price.with_currency_symbol }}
-
{{ current_price.format_tax_amount }}
-
-
-
diff --git a/subscriptions/templates/subscriptions/components/list.html b/subscriptions/templates/subscriptions/components/list.html
index 6d179246..ccff322f 100644
--- a/subscriptions/templates/subscriptions/components/list.html
+++ b/subscriptions/templates/subscriptions/components/list.html
@@ -3,7 +3,7 @@
{# Owned subscriptions #}
- {% for subscription in user.subscription_set.all %}
+ {% for subscription in user.customer.subscription_set.all %}
{% if subscription.team %}Team {% endif %}Subscription #{{ subscription.pk }}
{% include "subscriptions/components/pretty_status.html" %}
diff --git a/subscriptions/templates/subscriptions/components/payment_form.html b/subscriptions/templates/subscriptions/components/payment_form.html
deleted file mode 100644
index 5d8664ba..00000000
--- a/subscriptions/templates/subscriptions/components/payment_form.html
+++ /dev/null
@@ -1,41 +0,0 @@
-{% if form.non_field_errors %}
-
-
Unable to proceed due to the following issue:
-
{{ form.non_field_errors }}
-
-{% endif %}
-
-{# Payment process specific form fields, most of them a hidden and don't require special templating. #}
-
- {{ form.next_url_after_done }}
- {{ form.payment_method_nonce }}
- {{ form.device_data }}
- {{ form.price }}
-
-
- {% with field=form.gateway %}
- {% for radio in field %}
-
- {{ radio }}
-
- {% endfor %}
- {% endwith %}
-
-
-
-
-{# The content of below script must be valid JSON, as type suggests. #}
-
diff --git a/subscriptions/templates/subscriptions/components/total.html b/subscriptions/templates/subscriptions/components/total.html
index 18005913..91259a2a 100644
--- a/subscriptions/templates/subscriptions/components/total.html
+++ b/subscriptions/templates/subscriptions/components/total.html
@@ -43,7 +43,12 @@
{% if user.is_authenticated %}
-
{{ button_text|default:"Continue" }}
+ {% with gw=form.gateway.field.queryset.first %}
+
+ {{ button_text|default:"Continue" }}
+
+ {% endwith %}
{% else %}
Sign in with Blender ID
{% endif %}
diff --git a/subscriptions/templates/subscriptions/emails/base.html b/subscriptions/templates/subscriptions/emails/base.html
index d38f763d..be37980c 100644
--- a/subscriptions/templates/subscriptions/emails/base.html
+++ b/subscriptions/templates/subscriptions/emails/base.html
@@ -6,7 +6,7 @@
{% endblock header_logo %}
{% block body %}
-
Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},
+
Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %},
{% block content %}{% endblock content %}
Manage subscription in your billing settings: {{ billing_url }} .
diff --git a/subscriptions/templates/subscriptions/emails/base.txt b/subscriptions/templates/subscriptions/emails/base.txt
index 0345ca5a..817d9901 100644
--- a/subscriptions/templates/subscriptions/emails/base.txt
+++ b/subscriptions/templates/subscriptions/emails/base.txt
@@ -1,4 +1,4 @@
-Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},
+Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %},
{% block content %}{% endblock content %}
Manage subscription in your billing settings: {{ billing_url }}.
diff --git a/subscriptions/templates/subscriptions/emails/managed_notification.html b/subscriptions/templates/subscriptions/emails/managed_notification.html
index 7e85c10b..4a5dcf36 100644
--- a/subscriptions/templates/subscriptions/emails/managed_notification.html
+++ b/subscriptions/templates/subscriptions/emails/managed_notification.html
@@ -1,7 +1,7 @@
{% extends "subscriptions/emails/base.html" %}
{% block body %}
- {{ user.customer.full_name|default:user.email }} has a {% include "subscriptions/components/info.html" %} that just passed its next payment date.
+ {{ user.customer.billing_address.full_name|default:user.email }} has a {% include "subscriptions/components/info.html" %} that just passed its next payment date.
diff --git a/subscriptions/templates/subscriptions/emails/managed_notification.txt b/subscriptions/templates/subscriptions/emails/managed_notification.txt
index 761276cc..a1bc6664 100644
--- a/subscriptions/templates/subscriptions/emails/managed_notification.txt
+++ b/subscriptions/templates/subscriptions/emails/managed_notification.txt
@@ -1,3 +1,3 @@
-{{ user.customer.full_name|default:user.email }} has a {% include "subscriptions/components/info.txt" %} that just passed its next payment date.
+{% firstof user.customer.billing_address.full_name user.full_name user.email %} has a {% include "subscriptions/components/info.txt" %} that just passed its next payment date.
See {{ admin_url }} in the Blender Studio admin.
diff --git a/subscriptions/templates/subscriptions/emails/subscription_expired.html b/subscriptions/templates/subscriptions/emails/subscription_expired.html
index d5d8710f..1335656d 100644
--- a/subscriptions/templates/subscriptions/emails/subscription_expired.html
+++ b/subscriptions/templates/subscriptions/emails/subscription_expired.html
@@ -6,7 +6,7 @@
{% endblock header_logo %}
{% block body %}
-
Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},
+
Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %},
Your Blender Studio subscription #{{subscription.pk}} has expired a while back. We miss you -- and you are missing some exciting content on Blender Studio as well.
{% if latest_posts or latest_trainings %}
Just recently, we've published:
diff --git a/subscriptions/templates/subscriptions/emails/subscription_expired.txt b/subscriptions/templates/subscriptions/emails/subscription_expired.txt
index c33deee6..db31c065 100644
--- a/subscriptions/templates/subscriptions/emails/subscription_expired.txt
+++ b/subscriptions/templates/subscriptions/emails/subscription_expired.txt
@@ -1,4 +1,4 @@
-Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},
+Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %},
Your Blender Studio subscription #{{subscription.pk}} has expired a while back. We miss you -- and you are missing some exciting content on Blender Studio as well.{% if latest_posts or latest_trainings %} Just recently, we've published:
{% for post in latest_posts|slice:":2" %}
diff --git a/subscriptions/templates/subscriptions/join/billing_address.html b/subscriptions/templates/subscriptions/join/billing_address.html
index 01c24abe..f1df9208 100644
--- a/subscriptions/templates/subscriptions/join/billing_address.html
+++ b/subscriptions/templates/subscriptions/join/billing_address.html
@@ -36,7 +36,7 @@
{% if messages %}
{% for message in messages %}
-
+
{{ message }}
{% endfor %}
@@ -53,6 +53,17 @@
{% 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" %}
+
+
+ Pay via Bank Transfer
+
+
+
+ {% endif %}
+ {% endwith %}
{% endwith %}
diff --git a/subscriptions/templates/subscriptions/join/payment_method.html b/subscriptions/templates/subscriptions/join/payment_method.html
deleted file mode 100644
index 1e8734f3..00000000
--- a/subscriptions/templates/subscriptions/join/payment_method.html
+++ /dev/null
@@ -1,73 +0,0 @@
-{% extends 'checkout/checkout_base.html' %}
-{% load common_extras %}
-{% load looper %}
-{% load pipeline %}
-
-{% block content %}
-
-
Payment
-
-
Step 3: Select a payment method.
-
3 of 3
-
-
-
-{% endblock content %}
-
-{% block scripts %}
- {% javascript "subscriptions" %}
-
- {% include "looper/scripts.html" with with_recaptcha=True %}
-{% endblock scripts %}
diff --git a/subscriptions/templates/subscriptions/manage.html b/subscriptions/templates/subscriptions/manage.html
index 1c3cdcb5..504805d7 100644
--- a/subscriptions/templates/subscriptions/manage.html
+++ b/subscriptions/templates/subscriptions/manage.html
@@ -93,10 +93,10 @@
{% if not subscription.is_cancelled %}
{% endif %}
diff --git a/subscriptions/templates/subscriptions/pay_existing_order.html b/subscriptions/templates/subscriptions/pay_existing_order.html
deleted file mode 100644
index c573a720..00000000
--- a/subscriptions/templates/subscriptions/pay_existing_order.html
+++ /dev/null
@@ -1,52 +0,0 @@
-{% extends "checkout/checkout_base_empty.html" %}
-{% load looper %}
-{% load pipeline %}
-{% load common_extras %}
-
-{% block content %}
-
-
-
-
Back to subscription settings
-
- Your {% include "subscriptions/components/info_with_status.html" %}. It will be activated as soon as the outstanding amount is paid
-
-
-
Paying for Order #{{ order.display_number }}
-
-
billed on {{ order.created_at|date }}
-
{{ order.price.with_currency_symbol }}
-
-
-
-
-
-
- {% javascript "subscriptions" %}
- {% include "looper/scripts.html" %}
-{% endblock %}
diff --git a/subscriptions/templates/subscriptions/payment_method_change.html b/subscriptions/templates/subscriptions/payment_method_change.html
deleted file mode 100644
index 5f9d22a6..00000000
--- a/subscriptions/templates/subscriptions/payment_method_change.html
+++ /dev/null
@@ -1,63 +0,0 @@
-{% extends "settings/base.html" %}
-{% load common_extras %}
-{% load pipeline %}
-
-{% block settings %}
- Settings: Subscription #{{ subscription.pk }}
- Change Payment Method
-
-
-
-
- Your {{ subscription.plan.name }} subscription is currently
- {{ subscription.get_status_display|lower }} .
-
-
-
- {% if current_payment_method %}
-
- {{ current_payment_method.recognisable_name }} is used as payment method.
- Feel free to change it below.
-
- {% else %}
-
- warning_amber
- You subscription is using an unsupported payment method,
- please use the form below to change it.
-
- {% endif %}
-
-
-
-{% endblock settings %}
-
-{% block scripts %}
- {{ block.super }}
- {% javascript "subscriptions" %}
- {% include "looper/scripts.html" %}
-{% endblock scripts %}
diff --git a/subscriptions/templatetags/subscriptions.py b/subscriptions/templatetags/subscriptions.py
index 5a8b2fcf..7e95dc22 100644
--- a/subscriptions/templatetags/subscriptions.py
+++ b/subscriptions/templatetags/subscriptions.py
@@ -5,7 +5,6 @@ from django import template
from looper.models import PlanVariation, Subscription
import looper.money
-import looper.taxes
import subscriptions.queries
diff --git a/subscriptions/tests/_responses/stripe_get_cs_eur.yaml b/subscriptions/tests/_responses/stripe_get_cs_eur.yaml
new file mode 100644
index 00000000..0853077d
--- /dev/null
+++ b/subscriptions/tests/_responses/stripe_get_cs_eur.yaml
@@ -0,0 +1,158 @@
+responses:
+- response:
+ auto_calculate_content_length: false
+ body: "{\n \"id\": \"cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW\"\
+ ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\
+ allow_promotion_codes\": null,\n \"amount_subtotal\": 1252,\n \"amount_total\"\
+ : 1252,\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/10/billing/\",\n\
+ \ \"client_reference_id\": null,\n \"client_secret\": null,\n \"consent\"\
+ : null,\n \"consent_collection\": null,\n \"created\": 1718354548,\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_QI5uhaeNfeXwYP\",\n \"customer_creation\": null,\n \"customer_details\"\
+ : {\n \"address\": {\n \"city\": null,\n \"country\": \"NL\",\n\
+ \ \"line1\": null,\n \"line2\": null,\n \"postal_code\": null,\n\
+ \ \"state\": null\n },\n \"email\": \"my.billing.email@example.com\"\
+ ,\n \"name\": \"New Full Name\",\n \"phone\": null,\n \"tax_exempt\"\
+ : \"none\",\n \"tax_ids\": []\n },\n \"customer_email\": null,\n \"expires_at\"\
+ : 1718440948,\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\": {\n \"id\": \"pi_3PRVj3E4KAUB5djs1xbsGdDP\"\
+ ,\n \"object\": \"payment_intent\",\n \"amount\": 1252,\n \"amount_capturable\"\
+ : 0,\n \"amount_details\": {\n \"tip\": {}\n },\n \"amount_received\"\
+ : 1252,\n \"application\": null,\n \"application_fee_amount\": null,\n\
+ \ \"automatic_payment_methods\": null,\n \"canceled_at\": null,\n \"\
+ cancellation_reason\": null,\n \"capture_method\": \"automatic\",\n \"\
+ client_secret\": \"pi_3PRVj3E4KAUB5djs1xbsGdDP_secret_foobar\"\
+ ,\n \"confirmation_method\": \"automatic\",\n \"created\": 1718354593,\n\
+ \ \"currency\": \"eur\",\n \"customer\": {\n \"id\": \"cus_QI5uhaeNfeXwYP\"\
+ ,\n \"object\": \"customer\",\n \"address\": null,\n \"balance\"\
+ : 0,\n \"created\": 1718354548,\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\": \"8D38744D\",\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\
+ \ },\n \"description\": null,\n \"invoice\": null,\n \"last_payment_error\"\
+ : null,\n \"latest_charge\": {\n \"id\": \"ch_3PRVj3E4KAUB5djs16uXWpi8\"\
+ ,\n \"object\": \"charge\",\n \"amount\": 1252,\n \"amount_captured\"\
+ : 1252,\n \"amount_refunded\": 0,\n \"application\": null,\n \
+ \ \"application_fee\": null,\n \"application_fee_amount\": null,\n \
+ \ \"balance_transaction\": \"txn_3PRVj3E4KAUB5djs1sxkERoM\",\n \"billing_details\"\
+ : {\n \"address\": {\n \"city\": null,\n \"country\"\
+ : \"NL\",\n \"line1\": null,\n \"line2\": null,\n \
+ \ \"postal_code\": null,\n \"state\": null\n },\n \"\
+ email\": \"my.billing.email@example.com\",\n \"name\": \"Jane Doe\",\n\
+ \ \"phone\": null\n },\n \"calculated_statement_descriptor\"\
+ : \"BLENDER STUDIO\",\n \"captured\": true,\n \"created\": 1718354593,\n\
+ \ \"currency\": \"eur\",\n \"customer\": \"cus_QI5uhaeNfeXwYP\",\n\
+ \ \"description\": null,\n \"destination\": null,\n \"dispute\"\
+ : null,\n \"disputed\": false,\n \"failure_balance_transaction\":\
+ \ null,\n \"failure_code\": null,\n \"failure_message\": null,\n \
+ \ \"fraud_details\": {},\n \"invoice\": null,\n \"livemode\":\
+ \ false,\n \"metadata\": {\n \"order_id\": \"1\"\n },\n \
+ \ \"on_behalf_of\": null,\n \"order\": null,\n \"outcome\": {\n\
+ \ \"network_status\": \"approved_by_network\",\n \"reason\": null,\n\
+ \ \"risk_level\": \"normal\",\n \"risk_score\": 16,\n \"\
+ seller_message\": \"Payment complete.\",\n \"type\": \"authorized\"\n\
+ \ },\n \"paid\": true,\n \"payment_intent\": \"pi_3PRVj3E4KAUB5djs1xbsGdDP\"\
+ ,\n \"payment_method\": \"pm_1PRVj2E4KAUB5djsNQr0k105\",\n \"payment_method_details\"\
+ : {\n \"card\": {\n \"amount_authorized\": 1252,\n \
+ \ \"brand\": \"visa\",\n \"checks\": {\n \"address_line1_check\"\
+ : null,\n \"address_postal_code_check\": null,\n \"cvc_check\"\
+ : \"pass\"\n },\n \"country\": \"US\",\n \"exp_month\"\
+ : 12,\n \"exp_year\": 2033,\n \"extended_authorization\":\
+ \ {\n \"status\": \"disabled\"\n },\n \"fingerprint\"\
+ : \"YcmpGi38fZZuBsh4\",\n \"funding\": \"credit\",\n \"incremental_authorization\"\
+ : {\n \"status\": \"unavailable\"\n },\n \"installments\"\
+ : null,\n \"last4\": \"4242\",\n \"mandate\": null,\n \
+ \ \"multicapture\": {\n \"status\": \"unavailable\"\n \
+ \ },\n \"network\": \"visa\",\n \"network_token\": {\n\
+ \ \"used\": false\n },\n \"overcapture\": {\n \
+ \ \"maximum_amount_capturable\": 1252,\n \"status\": \"\
+ unavailable\"\n },\n \"three_d_secure\": null,\n \
+ \ \"wallet\": null\n },\n \"type\": \"card\"\n },\n \
+ \ \"radar_options\": {},\n \"receipt_email\": null,\n \"receipt_number\"\
+ : null,\n \"receipt_url\": \"https://pay.stripe.com/receipts/payment/foobar\"\
+ ,\n \"refunded\": false,\n \"review\": null,\n \"shipping\":\
+ \ null,\n \"source\": null,\n \"source_transfer\": null,\n \"\
+ statement_descriptor\": null,\n \"statement_descriptor_suffix\": null,\n\
+ \ \"status\": \"succeeded\",\n \"transfer_data\": null,\n \"\
+ transfer_group\": null\n },\n \"livemode\": false,\n \"metadata\":\
+ \ {\n \"order_id\": \"1\"\n },\n \"next_action\": null,\n \"on_behalf_of\"\
+ : null,\n \"payment_method\": {\n \"id\": \"pm_1PRVj2E4KAUB5djsNQr0k105\"\
+ ,\n \"object\": \"payment_method\",\n \"allow_redisplay\": \"limited\"\
+ ,\n \"billing_details\": {\n \"address\": {\n \"city\"\
+ : null,\n \"country\": \"NL\",\n \"line1\": null,\n \
+ \ \"line2\": null,\n \"postal_code\": null,\n \"state\"\
+ : null\n },\n \"email\": \"my.billing.email@example.com\",\n \
+ \ \"name\": \"Jane Doe\",\n \"phone\": null\n },\n \"\
+ card\": {\n \"brand\": \"visa\",\n \"checks\": {\n \"\
+ address_line1_check\": null,\n \"address_postal_code_check\": null,\n\
+ \ \"cvc_check\": \"pass\"\n },\n \"country\": \"US\"\
+ ,\n \"display_brand\": \"visa\",\n \"exp_month\": 12,\n \
+ \ \"exp_year\": 2033,\n \"fingerprint\": \"YcmpGi38fZZuBsh4\",\n \
+ \ \"funding\": \"credit\",\n \"generated_from\": null,\n \"\
+ last4\": \"4242\",\n \"networks\": {\n \"available\": [\n \
+ \ \"visa\"\n ],\n \"preferred\": null\n },\n\
+ \ \"three_d_secure_usage\": {\n \"supported\": true\n \
+ \ },\n \"wallet\": null\n },\n \"created\": 1718354592,\n\
+ \ \"customer\": \"cus_QI5uhaeNfeXwYP\",\n \"livemode\": false,\n \
+ \ \"metadata\": {},\n \"type\": \"card\"\n },\n \"payment_method_configuration_details\"\
+ : null,\n \"payment_method_options\": {\n \"card\": {\n \"installments\"\
+ : null,\n \"mandate_options\": null,\n \"network\": null,\n \
+ \ \"request_three_d_secure\": \"automatic\"\n }\n },\n \"payment_method_types\"\
+ : [\n \"card\"\n ],\n \"processing\": null,\n \"receipt_email\"\
+ : null,\n \"review\": null,\n \"setup_future_usage\": \"off_session\"\
+ ,\n \"shipping\": null,\n \"source\": null,\n \"statement_descriptor\"\
+ : null,\n \"statement_descriptor_suffix\": null,\n \"status\": \"succeeded\"\
+ ,\n \"transfer_data\": null,\n \"transfer_group\": null\n },\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\": \"paid\",\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\": \"complete\",\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\": null\n\
+ }"
+ content_type: text/plain
+ method: GET
+ status: 200
+ url: https://api.stripe.com/v1/checkout/sessions/cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW?expand%5B0%5D=payment_intent&expand%5B1%5D=payment_intent.customer&expand%5B2%5D=payment_intent.latest_charge&expand%5B3%5D=payment_intent.latest_charge.payment_method_details&expand%5B4%5D=payment_intent.payment_method
+- response:
+ auto_calculate_content_length: false
+ body: "{\n \"id\": \"pm_1PRVj2E4KAUB5djsNQr0k105\",\n \"object\": \"payment_method\"\
+ ,\n \"allow_redisplay\": \"limited\",\n \"billing_details\": {\n \"address\"\
+ : {\n \"city\": null,\n \"country\": \"NL\",\n \"line1\": null,\n\
+ \ \"line2\": null,\n \"postal_code\": null,\n \"state\": null\n\
+ \ },\n \"email\": \"my.billing.email@example.com\",\n \"name\": \"\
+ Jane Doe\",\n \"phone\": null\n },\n \"card\": {\n \"brand\": \"visa\"\
+ ,\n \"checks\": {\n \"address_line1_check\": null,\n \"address_postal_code_check\"\
+ : null,\n \"cvc_check\": \"pass\"\n },\n \"country\": \"US\",\n \
+ \ \"display_brand\": \"visa\",\n \"exp_month\": 12,\n \"exp_year\":\
+ \ 2033,\n \"fingerprint\": \"YcmpGi38fZZuBsh4\",\n \"funding\": \"credit\"\
+ ,\n \"generated_from\": null,\n \"last4\": \"4242\",\n \"networks\"\
+ : {\n \"available\": [\n \"visa\"\n ],\n \"preferred\"\
+ : null\n },\n \"three_d_secure_usage\": {\n \"supported\": true\n\
+ \ },\n \"wallet\": null\n },\n \"created\": 1718354592,\n \"customer\"\
+ : \"cus_QI5uhaeNfeXwYP\",\n \"livemode\": false,\n \"metadata\": {},\n \"\
+ type\": \"card\"\n}"
+ content_type: text/plain
+ method: GET
+ status: 200
+ url: https://api.stripe.com/v1/payment_methods/pm_1PRVj2E4KAUB5djsNQr0k105
diff --git a/subscriptions/tests/_responses/stripe_get_cs_setup.yaml b/subscriptions/tests/_responses/stripe_get_cs_setup.yaml
new file mode 100644
index 00000000..97d35be8
--- /dev/null
+++ b/subscriptions/tests/_responses/stripe_get_cs_setup.yaml
@@ -0,0 +1,67 @@
+responses:
+- response:
+ auto_calculate_content_length: false
+ body: "{\n \"id\": \"cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9\"\
+ ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\
+ allow_promotion_codes\": null,\n \"amount_subtotal\": null,\n \"amount_total\"\
+ : null,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\"\
+ : null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\
+ \ \"cancel_url\": \"http://testserver/settings/billing/\",\n \"client_reference_id\"\
+ : null,\n \"client_secret\": null,\n \"consent\": null,\n \"consent_collection\"\
+ : null,\n \"created\": 1718021357,\n \"currency\": null,\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_QGeKULCHd4p9o2\",\n \"customer_creation\"\
+ : null,\n \"customer_details\": {\n \"address\": null,\n \"email\": \"\
+ billing@example.com\",\n \"name\": \"\u0410\u043B\u0435\u043A\u0441\u0435\
+ \u0439 \u041D.\",\n \"phone\": null,\n \"tax_exempt\": null,\n \"tax_ids\"\
+ : []\n },\n \"customer_email\": null,\n \"expires_at\": 1718107757,\n \"\
+ invoice\": null,\n \"invoice_creation\": null,\n \"livemode\": false,\n \"\
+ locale\": null,\n \"metadata\": {},\n \"mode\": \"setup\",\n \"payment_intent\"\
+ : null,\n \"payment_link\": null,\n \"payment_method_collection\": \"always\"\
+ ,\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\": \"no_payment_required\",\n \"phone_number_collection\"\
+ : {\n \"enabled\": false\n },\n \"recovered_from\": null,\n \"saved_payment_method_options\"\
+ : null,\n \"setup_intent\": {\n \"id\": \"seti_1PQ72HE4KAUB5djsPthAKRdZ\"\
+ ,\n \"object\": \"setup_intent\",\n \"application\": null,\n \"automatic_payment_methods\"\
+ : null,\n \"cancellation_reason\": null,\n \"client_secret\": \"foobar\"\
+ ,\n \"created\": 1718021357,\n \"customer\": \"cus_QGeKULCHd4p9o2\",\n\
+ \ \"description\": null,\n \"flow_directions\": null,\n \"last_setup_error\"\
+ : null,\n \"latest_attempt\": \"setatt_1PQ7DuE4KAUB5djsZtPVk4XB\",\n \"\
+ livemode\": false,\n \"mandate\": null,\n \"metadata\": {\n \"subscription_id\"\
+ : \"1\"\n },\n \"next_action\": null,\n \"on_behalf_of\": null,\n \
+ \ \"payment_method\": {\n \"id\": \"pm_1PQ7DsE4KAUB5djsw4z0K5PS\",\n\
+ \ \"object\": \"payment_method\",\n \"allow_redisplay\": \"always\"\
+ ,\n \"billing_details\": {\n \"address\": {\n \"city\"\
+ : null,\n \"country\": \"NL\",\n \"line1\": null,\n \
+ \ \"line2\": null,\n \"postal_code\": null,\n \"state\"\
+ : null\n },\n \"email\": \"billing@example.com\",\n \"\
+ name\": \"John Smith\",\n \"phone\": null\n },\n \"card\":\
+ \ {\n \"brand\": \"visa\",\n \"checks\": {\n \"address_line1_check\"\
+ : null,\n \"address_postal_code_check\": null,\n \"cvc_check\"\
+ : \"pass\"\n },\n \"country\": \"US\",\n \"display_brand\"\
+ : \"visa\",\n \"exp_month\": 12,\n \"exp_year\": 2033,\n \
+ \ \"fingerprint\": \"YcmpGi38fZZuBsh4\",\n \"funding\": \"credit\"\
+ ,\n \"generated_from\": null,\n \"last4\": \"4242\",\n \
+ \ \"networks\": {\n \"available\": [\n \"visa\"\n \
+ \ ],\n \"preferred\": null\n },\n \"three_d_secure_usage\"\
+ : {\n \"supported\": true\n },\n \"wallet\": null\n \
+ \ },\n \"created\": 1718022076,\n \"customer\": \"cus_QGeKULCHd4p9o2\"\
+ ,\n \"livemode\": false,\n \"metadata\": {},\n \"type\": \"card\"\
+ \n },\n \"payment_method_configuration_details\": null,\n \"payment_method_options\"\
+ : {\n \"card\": {\n \"mandate_options\": null,\n \"network\"\
+ : null,\n \"request_three_d_secure\": \"automatic\"\n }\n },\n\
+ \ \"payment_method_types\": [\n \"card\",\n \"link\",\n \"\
+ paypal\"\n ],\n \"single_use_mandate\": null,\n \"status\": \"succeeded\"\
+ ,\n \"usage\": \"off_session\"\n },\n \"shipping_address_collection\":\
+ \ null,\n \"shipping_cost\": null,\n \"shipping_details\": null,\n \"shipping_options\"\
+ : [],\n \"status\": \"complete\",\n \"submit_type\": null,\n \"subscription\"\
+ : null,\n \"success_url\": \"http://testserver/subscription/1/payment-method/change/{CHECKOUT_SESSION_ID}/\"\
+ ,\n \"total_details\": null,\n \"ui_mode\": \"hosted\",\n \"url\": null\n\
+ }"
+ content_type: text/plain; charset=utf-8
+ method: GET
+ status: 200
+ url: https://api.stripe.com/v1/checkout/sessions/cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9?expand%5B0%5D=setup_intent&expand%5B1%5D=setup_intent.payment_method
diff --git a/subscriptions/tests/_responses/stripe_get_cs_usd.yaml b/subscriptions/tests/_responses/stripe_get_cs_usd.yaml
new file mode 100644
index 00000000..e2ed2e9b
--- /dev/null
+++ b/subscriptions/tests/_responses/stripe_get_cs_usd.yaml
@@ -0,0 +1,132 @@
+responses:
+- response:
+ auto_calculate_content_length: false
+ body: "{\n \"id\": \"cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w\"\
+ ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\
+ allow_promotion_codes\": null,\n \"amount_subtotal\": 1110,\n \"amount_total\"\
+ : 1110,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\"\
+ : null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\
+ \ \"cancel_url\": \"http://testserver/subscription/1/manage/\",\n \"client_reference_id\"\
+ : null,\n \"client_secret\": null,\n \"consent\": null,\n \"consent_collection\"\
+ : null,\n \"created\": 1718033250,\n \"currency\": \"usd\",\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_QGhXPj2pOTBdSo\",\n \"customer_creation\"\
+ : null,\n \"customer_details\": {\n \"address\": {\n \"city\": null,\n\
+ \ \"country\": null,\n \"line1\": null,\n \"line2\": null,\n\
+ \ \"postal_code\": null,\n \"state\": null\n },\n \"email\"\
+ : \"billing@example.com\",\n \"name\": \"\u0410\u043B\u0435\u043A\u0441\u0435\
+ \u0439 \u041D.\",\n \"phone\": null,\n \"tax_exempt\": \"none\",\n \
+ \ \"tax_ids\": []\n },\n \"customer_email\": null,\n \"expires_at\": 1718119650,\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\": {\n \"id\": \"pi_3PQAVnE4KAUB5djs1ciLiZeV\"\
+ ,\n \"object\": \"payment_intent\",\n \"amount\": 1110,\n \"amount_capturable\"\
+ : 0,\n \"amount_details\": {\n \"tip\": {}\n },\n \"amount_received\"\
+ : 1110,\n \"application\": null,\n \"application_fee_amount\": null,\n\
+ \ \"automatic_payment_methods\": null,\n \"canceled_at\": null,\n \"\
+ cancellation_reason\": null,\n \"capture_method\": \"automatic\",\n \"\
+ client_secret\": \"pi_3PQAVnE4KAUB5djs1ciLiZeV_secret_foobar\"\
+ ,\n \"confirmation_method\": \"automatic\",\n \"created\": 1718034719,\n\
+ \ \"currency\": \"usd\",\n \"customer\": {\n \"id\": \"cus_QGhXPj2pOTBdSo\"\
+ ,\n \"object\": \"customer\",\n \"address\": null,\n \"balance\"\
+ : 0,\n \"created\": 1718033249,\n \"currency\": null,\n \"default_source\"\
+ : null,\n \"delinquent\": false,\n \"description\": null,\n \"\
+ discount\": null,\n \"email\": \"billing@example.com\",\n \"invoice_prefix\"\
+ : \"B062701B\",\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\": \"\u0410\u043B\u0435\u043A\u0441\u0435\
+ \u0439 \u041D.\",\n \"phone\": null,\n \"preferred_locales\": [],\n\
+ \ \"shipping\": null,\n \"tax_exempt\": \"none\",\n \"test_clock\"\
+ : null\n },\n \"description\": null,\n \"invoice\": null,\n \"last_payment_error\"\
+ : null,\n \"latest_charge\": {\n \"id\": \"py_3PQAVnE4KAUB5djs1yXEDdPh\"\
+ ,\n \"object\": \"charge\",\n \"amount\": 1110,\n \"amount_captured\"\
+ : 1110,\n \"amount_refunded\": 0,\n \"application\": null,\n \
+ \ \"application_fee\": null,\n \"application_fee_amount\": null,\n \
+ \ \"balance_transaction\": \"txn_3PQAVnE4KAUB5djs1bl5WHpi\",\n \"billing_details\"\
+ : {\n \"address\": {\n \"city\": null,\n \"country\"\
+ : null,\n \"line1\": null,\n \"line2\": null,\n \"\
+ postal_code\": null,\n \"state\": null\n },\n \"email\"\
+ : \"billing@example.com\",\n \"name\": \"Josh Dane\",\n \"phone\"\
+ : null\n },\n \"calculated_statement_descriptor\": null,\n \"\
+ captured\": true,\n \"created\": 1718034735,\n \"currency\": \"usd\"\
+ ,\n \"customer\": \"cus_QGhXPj2pOTBdSo\",\n \"description\": null,\n\
+ \ \"destination\": null,\n \"dispute\": null,\n \"disputed\"\
+ : false,\n \"failure_balance_transaction\": null,\n \"failure_code\"\
+ : null,\n \"failure_message\": null,\n \"fraud_details\": {},\n \
+ \ \"invoice\": null,\n \"livemode\": false,\n \"metadata\": {\n\
+ \ \"order_id\": \"1\"\n },\n \"on_behalf_of\": null,\n \
+ \ \"order\": null,\n \"outcome\": {\n \"network_status\": \"approved_by_network\"\
+ ,\n \"reason\": null,\n \"risk_level\": \"not_assessed\",\n \
+ \ \"seller_message\": \"Payment complete.\",\n \"type\": \"authorized\"\
+ \n },\n \"paid\": true,\n \"payment_intent\": \"pi_3PQAVnE4KAUB5djs1ciLiZeV\"\
+ ,\n \"payment_method\": \"pm_1PQAVnE4KAUB5djsss37I9F6\",\n \"payment_method_details\"\
+ : {\n \"paypal\": {\n \"country\": \"FR\",\n \"payer_email\"\
+ : \"billing@example.com\",\n \"payer_id\": \"2RDOSMFNFJCL0\",\n \
+ \ \"payer_name\": \"Name Surname\",\n \"seller_protection\":\
+ \ {\n \"dispute_categories\": [\n \"product_not_received\"\
+ ,\n \"fraudulent\"\n ],\n \"status\": \"\
+ eligible\"\n },\n \"transaction_id\": \"a3c6e965-49d6-4133-9d8e-0220b0dd8ec1\"\
+ \n },\n \"type\": \"paypal\"\n },\n \"radar_options\"\
+ : {},\n \"receipt_email\": null,\n \"receipt_number\": null,\n \
+ \ \"receipt_url\": \"https://pay.stripe.com/receipts/payment/foobar\"\
+ ,\n \"refunded\": false,\n \"review\": null,\n \"shipping\":\
+ \ null,\n \"source\": null,\n \"source_transfer\": null,\n \"\
+ statement_descriptor\": null,\n \"statement_descriptor_suffix\": null,\n\
+ \ \"status\": \"succeeded\",\n \"transfer_data\": null,\n \"\
+ transfer_group\": null\n },\n \"livemode\": false,\n \"metadata\":\
+ \ {\n \"order_id\": \"1\"\n },\n \"next_action\": null,\n \"on_behalf_of\"\
+ : null,\n \"payment_method\": {\n \"id\": \"pm_1PQAVnE4KAUB5djsss37I9F6\"\
+ ,\n \"object\": \"payment_method\",\n \"allow_redisplay\": \"limited\"\
+ ,\n \"billing_details\": {\n \"address\": {\n \"city\"\
+ : null,\n \"country\": null,\n \"line1\": null,\n \
+ \ \"line2\": null,\n \"postal_code\": null,\n \"state\":\
+ \ null\n },\n \"email\": \"billing@example.com\",\n \"\
+ name\": \"Josh Dane\",\n \"phone\": null\n },\n \"created\"\
+ : 1718034719,\n \"customer\": \"cus_QGhXPj2pOTBdSo\",\n \"livemode\"\
+ : false,\n \"metadata\": {},\n \"paypal\": {\n \"country\"\
+ : \"FR\",\n \"payer_email\": \"billing@example.com\",\n \"payer_id\"\
+ : \"2RDOSMFNFJCL0\"\n },\n \"type\": \"paypal\"\n },\n \"payment_method_configuration_details\"\
+ : null,\n \"payment_method_options\": {\n \"paypal\": {\n \"\
+ preferred_locale\": null,\n \"reference\": null\n }\n },\n \
+ \ \"payment_method_types\": [\n \"paypal\"\n ],\n \"processing\"\
+ : null,\n \"receipt_email\": null,\n \"review\": null,\n \"setup_future_usage\"\
+ : \"off_session\",\n \"shipping\": null,\n \"source\": null,\n \"statement_descriptor\"\
+ : null,\n \"statement_descriptor_suffix\": null,\n \"status\": \"succeeded\"\
+ ,\n \"transfer_data\": null,\n \"transfer_group\": null\n },\n \"payment_link\"\
+ : null,\n \"payment_method_collection\": \"if_required\",\n \"payment_method_configuration_details\"\
+ : null,\n \"payment_method_options\": {},\n \"payment_method_types\": [\n\
+ \ \"card\",\n \"link\",\n \"paypal\"\n ],\n \"payment_status\": \"\
+ paid\",\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\": \"complete\",\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\": null\n\
+ }"
+ content_type: text/plain; charset=utf-8
+ method: GET
+ status: 200
+ url: https://api.stripe.com/v1/checkout/sessions/cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w?expand%5B0%5D=payment_intent&expand%5B1%5D=payment_intent.customer&expand%5B2%5D=payment_intent.latest_charge&expand%5B3%5D=payment_intent.latest_charge.payment_method_details&expand%5B4%5D=payment_intent.payment_method
+- response:
+ auto_calculate_content_length: false
+ body: "{\n \"id\": \"pm_1PQAVnE4KAUB5djsss37I9F6\",\n \"object\": \"payment_method\"\
+ ,\n \"allow_redisplay\": \"limited\",\n \"billing_details\": {\n \"address\"\
+ : {\n \"city\": null,\n \"country\": null,\n \"line1\": null,\n\
+ \ \"line2\": null,\n \"postal_code\": null,\n \"state\": null\n\
+ \ },\n \"email\": \"billing@example.com\",\n \"name\": \"Josh Dane\"\
+ ,\n \"phone\": null\n },\n \"created\": 1718034719,\n \"customer\": \"\
+ cus_QGhXPj2pOTBdSo\",\n \"livemode\": false,\n \"metadata\": {},\n \"paypal\"\
+ : {\n \"country\": \"FR\",\n \"payer_email\": \"billing@example.com\"\
+ ,\n \"payer_id\": \"2RDOSMFNFJCL0\"\n },\n \"type\": \"paypal\"\n}"
+ content_type: text/plain
+ method: GET
+ status: 200
+ url: https://api.stripe.com/v1/payment_methods/pm_1PQAVnE4KAUB5djsss37I9F6
diff --git a/subscriptions/tests/_responses/stripe_new_cs_eur.yaml b/subscriptions/tests/_responses/stripe_new_cs_eur.yaml
new file mode 100644
index 00000000..ff5b857e
--- /dev/null
+++ b/subscriptions/tests/_responses/stripe_new_cs_eur.yaml
@@ -0,0 +1,59 @@
+responses:
+- response:
+ auto_calculate_content_length: false
+ body: "{\n \"id\": \"cus_QI5uhaeNfeXwYP\",\n \"object\": \"customer\",\n \"\
+ address\": null,\n \"balance\": 0,\n \"created\": 1718354548,\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\": \"8D38744D\",\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_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW\"\
+ ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\
+ allow_promotion_codes\": null,\n \"amount_subtotal\": 1252,\n \"amount_total\"\
+ : 1252,\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/10/billing/\",\n\
+ \ \"client_reference_id\": null,\n \"client_secret\": null,\n \"consent\"\
+ : null,\n \"consent_collection\": null,\n \"created\": 1718354548,\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_QI5uhaeNfeXwYP\",\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\"\
+ : 1718440948,\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_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl\"\
+ \n}"
+ content_type: text/plain
+ method: POST
+ status: 200
+ url: https://api.stripe.com/v1/checkout/sessions
diff --git a/subscriptions/tests/_responses/stripe_new_cs_setup.yaml b/subscriptions/tests/_responses/stripe_new_cs_setup.yaml
new file mode 100644
index 00000000..478ec93d
--- /dev/null
+++ b/subscriptions/tests/_responses/stripe_new_cs_setup.yaml
@@ -0,0 +1,51 @@
+responses:
+- response:
+ auto_calculate_content_length: false
+ body: "{\n \"id\": \"cus_QGeKULCHd4p9o2\",\n \"object\": \"customer\",\n \"\
+ address\": null,\n \"balance\": 0,\n \"created\": 1718021356,\n \"currency\"\
+ : null,\n \"default_source\": null,\n \"delinquent\": false,\n \"description\"\
+ : null,\n \"discount\": null,\n \"email\": \"billing@example.com\",\n \"\
+ invoice_prefix\": \"0ABA8C57\",\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\": \"\u0410\u043B\u0435\u043A\u0441\u0435\u0439 \u041D.\",\n \"phone\"\
+ : null,\n \"preferred_locales\": [],\n \"shipping\": null,\n \"tax_exempt\"\
+ : \"none\",\n \"test_clock\": null\n}"
+ content_type: text/plain; charset=utf-8
+ method: POST
+ status: 200
+ url: https://api.stripe.com/v1/customers
+- response:
+ auto_calculate_content_length: false
+ body: "{\n \"id\": \"cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9\"\
+ ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\
+ allow_promotion_codes\": null,\n \"amount_subtotal\": null,\n \"amount_total\"\
+ : null,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\"\
+ : null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\
+ \ \"cancel_url\": \"http://testserver/settings/billing/\",\n \"client_reference_id\"\
+ : null,\n \"client_secret\": null,\n \"consent\": null,\n \"consent_collection\"\
+ : null,\n \"created\": 1718021357,\n \"currency\": null,\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_QGeKULCHd4p9o2\",\n \"customer_creation\"\
+ : null,\n \"customer_details\": {\n \"address\": null,\n \"email\": \"\
+ billing@example.com\",\n \"name\": null,\n \"phone\": null,\n \"tax_exempt\"\
+ : null,\n \"tax_ids\": null\n },\n \"customer_email\": null,\n \"expires_at\"\
+ : 1718107757,\n \"invoice\": null,\n \"invoice_creation\": null,\n \"livemode\"\
+ : false,\n \"locale\": null,\n \"metadata\": {},\n \"mode\": \"setup\",\n\
+ \ \"payment_intent\": null,\n \"payment_link\": null,\n \"payment_method_collection\"\
+ : \"always\",\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\": \"no_payment_required\",\n \"phone_number_collection\"\
+ : {\n \"enabled\": false\n },\n \"recovered_from\": null,\n \"saved_payment_method_options\"\
+ : null,\n \"setup_intent\": \"seti_1PQ72HE4KAUB5djsPthAKRdZ\",\n \"shipping_address_collection\"\
+ : null,\n \"shipping_cost\": null,\n \"shipping_details\": null,\n \"shipping_options\"\
+ : [],\n \"status\": \"open\",\n \"submit_type\": null,\n \"subscription\"\
+ : null,\n \"success_url\": \"http://testserver/subscription/1/payment-method/change/{CHECKOUT_SESSION_ID}/\"\
+ ,\n \"total_details\": null,\n \"ui_mode\": \"hosted\",\n \"url\": \"https://checkout.stripe.com/c/pay/cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBaZmppcGhrJyknYGtkZ2lgVWlkZmBtamlhYHd2Jz9xd3BgeCUl\"\
+ \n}"
+ content_type: text/plain
+ method: POST
+ status: 200
+ url: https://api.stripe.com/v1/checkout/sessions
diff --git a/subscriptions/tests/_responses/stripe_new_cs_usd.yaml b/subscriptions/tests/_responses/stripe_new_cs_usd.yaml
new file mode 100644
index 00000000..1c9602c0
--- /dev/null
+++ b/subscriptions/tests/_responses/stripe_new_cs_usd.yaml
@@ -0,0 +1,58 @@
+responses:
+- response:
+ auto_calculate_content_length: false
+ body: "{\n \"id\": \"cus_QGhXPj2pOTBdSo\",\n \"object\": \"customer\",\n \"\
+ address\": null,\n \"balance\": 0,\n \"created\": 1718033249,\n \"currency\"\
+ : null,\n \"default_source\": null,\n \"delinquent\": false,\n \"description\"\
+ : null,\n \"discount\": null,\n \"email\": \"billing@example.com\",\n \"\
+ invoice_prefix\": \"B062701B\",\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\": \"\u0410\u043B\u0435\u043A\u0441\u0435\u0439 \u041D.\",\n \"phone\"\
+ : null,\n \"preferred_locales\": [],\n \"shipping\": null,\n \"tax_exempt\"\
+ : \"none\",\n \"test_clock\": null\n}"
+ content_type: text/plain; charset=utf-8
+ method: POST
+ status: 200
+ url: https://api.stripe.com/v1/customers
+- response:
+ auto_calculate_content_length: false
+ body: "{\n \"id\": \"cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w\"\
+ ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\
+ allow_promotion_codes\": null,\n \"amount_subtotal\": 1110,\n \"amount_total\"\
+ : 1110,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\"\
+ : null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\
+ \ \"cancel_url\": \"http://testserver/subscription/1/manage/\",\n \"client_reference_id\"\
+ : null,\n \"client_secret\": null,\n \"consent\": null,\n \"consent_collection\"\
+ : null,\n \"created\": 1718033250,\n \"currency\": \"usd\",\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_QGhXPj2pOTBdSo\",\n \"customer_creation\"\
+ : null,\n \"customer_details\": {\n \"address\": null,\n \"email\": \"\
+ billing@example.com\",\n \"name\": null,\n \"phone\": null,\n \"tax_exempt\"\
+ : \"none\",\n \"tax_ids\": null\n },\n \"customer_email\": null,\n \"\
+ expires_at\": 1718119650,\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_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl\"\
+ \n}"
+ content_type: text/plain
+ method: POST
+ status: 200
+ url: https://api.stripe.com/v1/checkout/sessions
diff --git a/subscriptions/tests/_responses/vies_valid.yaml b/subscriptions/tests/_responses/vies_valid.yaml
new file mode 100644
index 00000000..8bf5ac12
--- /dev/null
+++ b/subscriptions/tests/_responses/vies_valid.yaml
@@ -0,0 +1,9 @@
+responses:
+- response:
+ auto_calculate_content_length: false
+ body: DE 260543043 2024-06-14+02:00 true --- ---
+ content_type: text/plain
+ method: POST
+ status: 200
+ url: https://ec.europa.eu/taxation_customs/vies/services/checkVatService
diff --git a/subscriptions/tests/_responses/vies_wsdl.yaml b/subscriptions/tests/_responses/vies_wsdl.yaml
new file mode 100644
index 00000000..1f79b9d9
--- /dev/null
+++ b/subscriptions/tests/_responses/vies_wsdl.yaml
@@ -0,0 +1,106 @@
+responses:
+- response:
+ auto_calculate_content_length: false
+ body: "\n\n \n\
+ \ blah blah blah time period, try again later. \n\t \n\
+ \ \n \n \n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\
+ \t\t\t\t\t\n\t\t\t\t\t\
+ \t\n\t\t\t\t\t \n\
+ \t\t\t\t \n\t\t\t \n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\
+ \t\t\t\t\t\n\t\t\t\t\t\
+ \t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t \n\t\t\t\t\t \n\t\t\t\t \n\t\t\t\
+ \n\t\t\t\n\t\t\t\t\n\
+ \t\t\t\t\t\n\t\t\t\t\t\t \n\t\t\t\t\t\t \n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\
+ \n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\
+ \t\t\t\t\n\t\t\t\t\t \n\t\t\t\t \n\
+ \t\t\t \n\t\t\t\n\t\
+ \t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t \n\t\t\t\t\t\t\n\t\t\t\t\t\t \n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\
+ \t \n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\
+ \n\t\t\t\t\t\t\n\t\t\t\t\t\t \n\t\t\t\t\t\t \n\t\t\t\t\t \n\t\t\t\t \n\t\t\t \n\
+ \t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\
+ \t\t\t \n\t\t\t \n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tVALID \n\
+ \t\t\t\t\t\t \n\t\t\t\t\t \n\t\t\t\t\t\n \n \
+ \ INVALID \n \
+ \ \n \n \
+ \ \n \n\
+ \ NOT_PROCESSED \n\
+ \ \n \n\
+ \t\t\t\t \n\t\t\t \n\t\t \n \n\
+ \ \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \
+ \ \n \
+ \ \n \n \n \n \n \n \n \n \n \n\
+ \ \n \n \n \n \n \n \n \n \n \n \n \n\
+ \ \n \n \n \n \n \n \n \n \n\
+ \ \n \n \n \n \n \n \n"
+ content_type: text/plain
+ method: GET
+ status: 200
+ url: https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl
diff --git a/subscriptions/tests/base.py b/subscriptions/tests/base.py
index 6227ec92..ad54574b 100644
--- a/subscriptions/tests/base.py
+++ b/subscriptions/tests/base.py
@@ -1,6 +1,6 @@
+from typing import Tuple
import os
-from django.contrib.auth import get_user_model
from django.core import mail
from django.db.models import signals
from django.test import TestCase
@@ -8,10 +8,12 @@ from django.urls import reverse
import factory
import responses
-from common.tests.factories.subscriptions import create_customer_with_billing_address
+from looper.tests.factories import create_customer_with_billing_address
+import looper.models
+
import users.tests.util as util
-User = get_user_model()
+responses_dir = 'subscriptions/tests/_responses/'
def _write_mail(mail, index=0):
@@ -24,16 +26,41 @@ def _write_mail(mail, index=0):
f.write(str(content))
+def responses_from_file(file_name: str, rsps=responses, order_id=None):
+ """Add a response mock from file, override `order_id` metadata with a given one."""
+ rsps._add_from_file(f'{responses_dir}{file_name}')
+ # Replace metadata's "order_id" hardcoded in the response YAML with current order ID,
+ # because it differs depending on whether this test is run alone or with all the tests.
+ for _ in rsps.registered():
+ if '%5D=payment_intent' in _.url:
+ assert '\"order_id\": \"1' in _.body
+ _.body = _.body.replace('\"order_id\": \"1', f'\"order_id\": \"{order_id}')
+
+
class BaseSubscriptionTestCase(TestCase):
+ fixtures = ['gateways']
+
+ 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):
+ super().setUp()
+
# Allow requests to Braintree Sandbox
responses.add_passthru('https://api.sandbox.braintreegateway.com:443/')
# Create the admin user used for logging
self.admin_user = util.create_admin_log_user()
- self.user = create_customer_with_billing_address(
+ self.customer = create_customer_with_billing_address(
full_name='Алексей Н.',
company='Testcompany B.V.',
street_address='Billing street 1',
@@ -43,11 +70,18 @@ class BaseSubscriptionTestCase(TestCase):
region='North Holland',
country='NL',
vat_number='NL-KVK-41202535',
- billing_email='billing@example.com',
+ email='billing@example.com',
)
- self.customer = self.user.customer
+ self.user = self.customer.user
self.billing_address = self.customer.billing_address
+ responses._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
+
+ def tearDown(self):
+ super().tearDown()
+ responses.stop()
+ responses.reset()
+
def _mock_vies_response(self, is_valid=True, is_broken=False):
path = os.path.abspath(__file__)
dir_path = os.path.join(os.path.dirname(path), 'vies')
@@ -86,12 +120,13 @@ 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"')
- 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_pay_via_bank_not_displayed(self, response):
+ self.assertNotContains(response, 'name="gateway" value="bank"')
+
+ def _assert_pay_via_bank_displayed(self, response):
+ self.assertContains(response, 'name="gateway" value="bank"')
def _assert_pricing_has_been_updated(self, response):
self.assertContains(response, 'Pricing has been updated')
@@ -319,7 +354,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertContains(response, 'Bank details: ', html=True)
self.assertContains(response, 'on hold')
self.assertContains(response, 'NL07 INGB 0008 4489 82')
- subscription = response.wsgi_request.user.subscription_set.first()
+ subscription = response.wsgi_request.user.customer.subscription_set.first()
self.assertContains(
response, f'Blender Studio order-{subscription.latest_order().display_number}'
)
@@ -341,11 +376,11 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(len(mail.outbox), 0)
def _assert_bank_transfer_email_is_sent(self, subscription):
- user = subscription.user
+ customer = subscription.customer
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
- self.assertEqual(email.to, [user.customer.billing_email])
+ self.assertEqual(email.to, [customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@@ -384,11 +419,11 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Recurring total: €\xa026.45', email_body.replace(' ', ' '))
def _assert_subscription_activated_email_is_sent(self, subscription):
- user = subscription.user
+ customer = subscription.customer
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
- self.assertEqual(email.to, [user.customer.billing_email])
+ self.assertEqual(email.to, [customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@@ -397,17 +432,17 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn('activated', email_body)
- self.assertIn(f'Dear {user.customer.full_name},', email_body)
+ self.assertIn(f'Dear {customer.billing_address.full_name},', email_body)
self.assertIn(reverse('user-settings-billing'), email_body)
self.assertIn('Automatic renewal subscription', email_body)
self.assertIn('Blender Studio Team', email_body)
def _assert_team_subscription_activated_email_is_sent(self, subscription):
- user = subscription.user
+ customer = subscription.customer
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
- self.assertEqual(email.to, [user.customer.billing_email])
+ self.assertEqual(email.to, [customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@@ -416,17 +451,17 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn('activated', email_body)
- self.assertIn(f'Dear {user.customer.full_name},', email_body)
+ self.assertIn(f'Dear {customer.billing_address.full_name},', email_body)
self.assertIn(reverse('user-settings-billing'), email_body)
self.assertIn('Automatic renewal, 15 seats subscription', email_body)
self.assertIn('Blender Studio Team', email_body)
def _assert_subscription_deactivated_email_is_sent(self, subscription):
- user = subscription.user
+ customer = subscription.customer
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
- self.assertEqual(email.to, [user.customer.billing_email])
+ self.assertEqual(email.to, [customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@@ -440,11 +475,12 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Blender Studio Team', email_body)
def _assert_payment_soft_failed_email_is_sent(self, subscription):
- user = subscription.user
+ customer = subscription.customer
+ user = customer.user
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
- self.assertEqual(email.to, [user.customer.billing_email])
+ self.assertEqual(email.to, [user.customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@@ -454,7 +490,7 @@ class BaseSubscriptionTestCase(TestCase):
)
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
- self.assertIn(f'Dear {user.customer.full_name},', email_body)
+ self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
self.assertIn('Automatic payment', email_body)
self.assertIn('failed', email_body)
self.assertIn('try again', email_body)
@@ -470,11 +506,12 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Blender Studio Team', email_body)
def _assert_payment_failed_email_is_sent(self, subscription):
- user = subscription.user
+ customer = subscription.customer
+ user = customer.user
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
- self.assertEqual(email.to, [user.customer.billing_email])
+ self.assertEqual(email.to, [user.customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@@ -482,7 +519,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.subject, 'Blender Studio Subscription: payment failed')
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
- self.assertIn(f'Dear {user.customer.full_name},', email_body)
+ self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
self.assertIn('Automatic payment', email_body)
self.assertIn('failed', email_body)
self.assertIn('3 times', email_body)
@@ -497,11 +534,12 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Blender Studio Team', email_body)
def _assert_payment_paid_email_is_sent(self, subscription):
- user = subscription.user
+ customer = subscription.customer
+ user = customer.user
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
- self.assertEqual(email.to, [user.customer.billing_email])
+ self.assertEqual(email.to, [user.customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@@ -509,7 +547,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.subject, 'Blender Studio Subscription: payment received')
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
- self.assertIn(f'Dear {user.customer.full_name},', email_body)
+ self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
self.assertIn('Automatic monthly payment', email_body)
self.assertIn('successful', email_body)
self.assertIn('$\xa011.10', email_body)
@@ -523,7 +561,8 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Blender Studio Team', email_body)
def _assert_managed_subscription_notification_email_is_sent(self, subscription):
- user = subscription.user
+ customer = subscription.customer
+ user = customer.user
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
@@ -533,7 +572,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.subject, 'Blender Studio managed subscription needs attention')
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
- self.assertIn(f'{user.customer.full_name} has', email_body)
+ self.assertIn(f'{user.customer.billing_address.full_name} has', email_body)
self.assertIn('its next payment date', email_body)
self.assertIn('$\xa011.10', email_body)
self.assertIn(
@@ -542,16 +581,17 @@ class BaseSubscriptionTestCase(TestCase):
)
def _assert_subscription_expired_email_is_sent(self, subscription):
- user = subscription.user
+ customer = subscription.customer
+ user = customer.user
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
- self.assertEqual(email.to, [subscription.user.email])
+ self.assertEqual(email.to, [user.email])
self.assertEqual(email.from_email, 'webmaster@localhost')
self.assertEqual(email.subject, 'We miss you at Blender Studio')
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
- self.assertIn(f'Dear {user.customer.full_name}', email_body)
+ self.assertIn(f'Dear {user.customer.billing_address.full_name}', email_body)
self.assertIn(f'#{subscription.pk}', email_body)
self.assertIn('has expired', email_body)
self.assertIn(
diff --git a/subscriptions/tests/test_clock.py b/subscriptions/tests/test_clock.py
index 54ad8b76..d60fcac5 100644
--- a/subscriptions/tests/test_clock.py
+++ b/subscriptions/tests/test_clock.py
@@ -11,27 +11,26 @@ from looper import admin_log
from looper.clock import Clock
from looper.models import Gateway, Subscription
from looper.money import Money
+from looper.tests.factories import SubscriptionFactory, create_customer_with_billing_address
-from common.tests.factories.subscriptions import (
- SubscriptionFactory,
- create_customer_with_billing_address,
-)
from subscriptions.tests.base import BaseSubscriptionTestCase
import subscriptions.tasks
import users.tasks
import users.tests.util as util
+from common.tests.factories.users import OAuthUserInfoFactory
-class TestClock(BaseSubscriptionTestCase):
+class TestClockBraintree(BaseSubscriptionTestCase):
def _create_subscription_due_now(self) -> Subscription:
- user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
+ customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
+ OAuthUserInfoFactory(user=customer.user, oauth_user_id=554433)
now = timezone.now()
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = now + relativedelta(months=-1)
# print('fake now:', mock_now.return_value)
subscription = SubscriptionFactory(
- user=user,
- payment_method__user_id=user.pk,
+ customer=customer,
+ payment_method__customer_id=customer.pk,
payment_method__recognisable_name='Test payment method',
payment_method__gateway=Gateway.objects.get(name='braintree'),
currency='USD',
@@ -52,6 +51,9 @@ class TestClock(BaseSubscriptionTestCase):
def setUp(self):
super().setUp()
+ # Allow requests to Braintree Sandbox
+ responses.add_passthru('https://api.sandbox.braintreegateway.com:443/')
+
self.subscription = self._create_subscription_due_now()
@patch(
@@ -114,7 +116,7 @@ class TestClock(BaseSubscriptionTestCase):
# Tick the clock and check that order and transaction were created
util.mock_blender_id_badger_badger_response(
- 'revoke', 'cloud_subscriber', self.subscription.user.oauth_info.oauth_user_id
+ 'revoke', 'cloud_subscriber', self.subscription.customer.user.oauth_info.oauth_user_id
)
Clock().tick()
@@ -167,7 +169,7 @@ class TestClock(BaseSubscriptionTestCase):
# Create another active subscription for the same user
SubscriptionFactory(
- user=self.subscription.user,
+ customer=self.subscription.customer,
payment_method=self.subscription.payment_method,
currency='USD',
price=Money('USD', 1110),
@@ -190,10 +192,12 @@ class TestClock(BaseSubscriptionTestCase):
)
def test_automated_payment_paid_email_is_sent(self):
now = timezone.now()
+ self.assertEqual(self.subscription.collection_method, 'automatic')
# Tick the clock and check that subscription renews, order and transaction were created
with patch(
- 'looper.gateways.BraintreeGateway.transact_sale', return_value='mock-transaction-id'
+ 'looper.gateways.BraintreeGateway.transact_sale',
+ return_value={'transaction_id': 'mock-transaction-id'},
):
Clock().tick()
@@ -254,9 +258,9 @@ class TestClock(BaseSubscriptionTestCase):
class TestClockExpiredSubscription(BaseSubscriptionTestCase):
def test_subscription_on_hold_not_long_enough(self):
now = timezone.now()
- user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
+ customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
self.subscription = SubscriptionFactory(
- user=user,
+ customer=customer,
status='on-hold',
# payment date has passed, but not long enough ago
next_payment=now - timedelta(weeks=4),
@@ -283,15 +287,16 @@ class TestClockExpiredSubscription(BaseSubscriptionTestCase):
@responses.activate
def test_subscription_on_hold_too_long_status_changed_to_expired_email_sent(self):
now = timezone.now()
- user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
+ customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
+ OAuthUserInfoFactory(user=customer.user, oauth_user_id=223344)
self.subscription = SubscriptionFactory(
- user=user,
+ customer=customer,
status='on-hold',
# payment date has passed a long long time ago
next_payment=now - timedelta(weeks=4 * 10),
)
util.mock_blender_id_badger_badger_response(
- 'revoke', 'cloud_subscriber', user.oauth_info.oauth_user_id
+ 'revoke', 'cloud_subscriber', customer.user.oauth_info.oauth_user_id
)
Clock().tick()
diff --git a/subscriptions/tests/test_forms.py b/subscriptions/tests/test_forms.py
index 9f3caccb..7aa57de0 100644
--- a/subscriptions/tests/test_forms.py
+++ b/subscriptions/tests/test_forms.py
@@ -21,7 +21,6 @@ class TestBillingAddressForm(BaseSubscriptionTestCase):
def test_instance_loads_both_address_and_customer_data(self):
form = BillingAddressForm(instance=self.billing_address)
- # N.B.: email is loaded from Customer.billing_email
self.assertEqual(form['email'].value(), 'billing@example.com')
self.assertEqual(form['company'].value(), 'Testcompany B.V.')
self.assertEqual(form['country'].value(), 'NL')
@@ -215,15 +214,12 @@ class TestBillingAddressForm(BaseSubscriptionTestCase):
class TestPaymentForm(BaseSubscriptionTestCase):
required_payment_form_data = {
'gateway': 'bank',
- 'payment_method_nonce': 'fake-nonce',
'plan_variation_id': 1,
- 'price': '9.90',
}
def test_instance_loads_both_address_and_customer_data(self):
form = PaymentForm(instance=self.billing_address)
- # N.B.: email is loaded from Customer.billing_email
self.assertEqual(form['email'].value(), 'billing@example.com')
self.assertEqual(form['company'].value(), 'Testcompany B.V.')
self.assertEqual(form['country'].value(), 'NL')
@@ -246,8 +242,6 @@ class TestPaymentForm(BaseSubscriptionTestCase):
'email': ['This field is required.'],
'full_name': ['This field is required.'],
'gateway': ['This field is required.'],
- 'payment_method_nonce': ['This field is required.'],
- 'price': ['This field is required.'],
},
)
diff --git a/subscriptions/tests/test_queries.py b/subscriptions/tests/test_queries.py
index 21219c7d..12328fb8 100644
--- a/subscriptions/tests/test_queries.py
+++ b/subscriptions/tests/test_queries.py
@@ -1,7 +1,9 @@
from django.test import TestCase
+from looper.tests.factories import SubscriptionFactory
+
from common.tests.factories.users import UserFactory
-from common.tests.factories.subscriptions import SubscriptionFactory, TeamFactory
+from common.tests.factories.subscriptions import TeamFactory
import looper.models
@@ -25,12 +27,12 @@ class TestHasActiveSubscription(TestCase):
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
)
- self.assertTrue(has_active_subscription(subscription.user))
+ self.assertTrue(has_active_subscription(subscription.customer.user))
def test_false_when_subscription_inactive(self):
subscription = SubscriptionFactory(plan_id=1)
- self.assertFalse(has_active_subscription(subscription.user))
+ self.assertFalse(has_active_subscription(subscription.customer.user))
def test_false_when_team_subscription_inactive(self):
team = TeamFactory(subscription__plan_id=1)
@@ -60,17 +62,17 @@ class TestHasNotYetCancelledSubscription(TestCase):
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
)
- self.assertTrue(has_not_yet_cancelled_subscription(subscription.user))
+ self.assertTrue(has_not_yet_cancelled_subscription(subscription.customer.user))
def test_false_when_subscription_cancelled(self):
subscription = SubscriptionFactory(plan_id=1, status='cancelled')
- self.assertFalse(has_not_yet_cancelled_subscription(subscription.user))
+ self.assertFalse(has_not_yet_cancelled_subscription(subscription.customer.user))
def test_true_when_subscription_inactive(self):
subscription = SubscriptionFactory(plan_id=1)
- self.assertTrue(has_not_yet_cancelled_subscription(subscription.user))
+ self.assertTrue(has_not_yet_cancelled_subscription(subscription.customer.user))
def test_false_when_team_subscription_inactive(self):
team = TeamFactory(subscription__plan_id=1)
@@ -103,7 +105,7 @@ class TestHasNotYetCancelledSubscription(TestCase):
)
team.team_users.create(user=UserFactory())
SubscriptionFactory(
- user=team.team_users.first().user,
+ customer=team.team_users.first().user.customer,
plan_id=1,
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
)
@@ -117,7 +119,7 @@ class TestHasNotYetCancelledSubscription(TestCase):
)
team.team_users.create(user=UserFactory())
SubscriptionFactory(
- user=team.team_users.first().user,
+ customer=team.team_users.first().user.customer,
plan_id=1,
status='cancelled',
)
@@ -137,12 +139,12 @@ class TestHasSubscription(TestCase):
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
)
- self.assertTrue(has_subscription(subscription.user))
+ self.assertTrue(has_subscription(subscription.customer.user))
def test_true_when_subscription_inactive(self):
subscription = SubscriptionFactory(plan_id=1)
- self.assertTrue(has_subscription(subscription.user))
+ self.assertTrue(has_subscription(subscription.customer.user))
def test_true_when_team_subscription_inactive(self):
team = TeamFactory(subscription__plan_id=1)
@@ -166,12 +168,12 @@ class TestHasSubscription(TestCase):
is_legacy=True,
)
- self.assertTrue(has_subscription(subscription.user))
+ self.assertTrue(has_subscription(subscription.customer.user))
def test_true_when_subscription_inactive_and_is_legacy(self):
subscription = SubscriptionFactory(plan_id=1, is_legacy=True)
- self.assertTrue(has_subscription(subscription.user))
+ self.assertTrue(has_subscription(subscription.customer.user))
def test_true_when_team_subscription_inactive_and_is_legacy(self):
team = TeamFactory(subscription__plan_id=1, subscription__is_legacy=True)
@@ -192,12 +194,12 @@ class TestHasNonLegacySubscription(TestCase):
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
)
- self.assertTrue(has_non_legacy_subscription(subscription.user))
+ self.assertTrue(has_non_legacy_subscription(subscription.customer.user))
def test_true_when_subscription_inactive_and_not_is_legacy(self):
subscription = SubscriptionFactory(plan_id=1)
- self.assertTrue(has_non_legacy_subscription(subscription.user))
+ self.assertTrue(has_non_legacy_subscription(subscription.customer.user))
def test_true_when_team_subscription_inactive_and_not_is_legacy(self):
team = TeamFactory(subscription__plan_id=1)
@@ -208,7 +210,7 @@ class TestHasNonLegacySubscription(TestCase):
def test_false_when_subscription_inactive_and_is_legacy(self):
subscription = SubscriptionFactory(plan_id=1, is_legacy=True)
- self.assertFalse(has_non_legacy_subscription(subscription.user))
+ self.assertFalse(has_non_legacy_subscription(subscription.customer.user))
def test_false_when_subscription_active_and_is_legacy(self):
subscription = SubscriptionFactory(
@@ -217,7 +219,7 @@ class TestHasNonLegacySubscription(TestCase):
is_legacy=True,
)
- self.assertFalse(has_non_legacy_subscription(subscription.user))
+ self.assertFalse(has_non_legacy_subscription(subscription.customer.user))
def test_false_when_team_subscription_inactive_and_is_legacy(self):
team = TeamFactory(subscription__plan_id=1, subscription__is_legacy=True)
diff --git a/subscriptions/urls.py b/subscriptions/urls.py
index c1911980..34ee0202 100644
--- a/subscriptions/urls.py
+++ b/subscriptions/urls.py
@@ -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//billing/',
- BillingDetailsView.as_view(),
+ JoinView.as_view(),
name='join-billing-details',
),
- path(
- 'join/plan-variation//confirm/',
- ConfirmAndPayView.as_view(),
- name='join-confirm-and-pay',
- ),
path(
'subscription//manage/',
settings.ManageSubscriptionView.as_view(),
@@ -49,15 +44,18 @@ urlpatterns = [
settings.PaymentMethodChangeView.as_view(),
name='payment-method-change',
),
+ path(
+ 'subscription//payment-method/change//',
+ settings.PaymentMethodChangeDoneView.as_view(),
+ name='payment-method-change-done',
+ ),
path(
'subscription/order//pay/',
settings.PayExistingOrderView.as_view(),
name='pay-existing-order',
),
path(
- 'settings/billing-address/',
- settings.BillingAddressView.as_view(),
- name='billing-address',
+ 'settings/billing-address/', settings.BillingAddressView.as_view(), name='billing-address'
),
path('settings/receipts/', looper_settings.settings_receipts, name='receipts'),
path(
diff --git a/subscriptions/views/join.py b/subscriptions/views/join.py
index 8ebcd61b..0aedc2e2 100644
--- a/subscriptions/views/join.py
+++ b/subscriptions/views/join.py
@@ -1,23 +1,21 @@
"""Views handling subscription management."""
-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 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,111 +25,145 @@ 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,
- # 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
+ # FIXME(anna): this view uses some functionality of CheckoutStripeView,
+ # but cannot directly inherit it, since JoinView supports creating only one subscription.
+ _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
- existing_subscriptions = self.request.user.subscription_set.exclude(
+ existing_subscriptions = self.request.user.customer.subscription_set.exclude(
status__in=looper.models.Subscription._CANCELLED_STATUSES
)
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):
- """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.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) -> 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
-
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 _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(customer=self.customer)
+ is_new = True
+ logger_args = [self.customer.pk, gateway]
+ logger.debug('Creating a new subscription for customer pk=%s, %s', *logger_args)
+ collection_method = self.plan_variation.collection_method
+ if collection_method not in gateway.provider.supported_collection_methods:
+ # FIXME(anna): this breaks plan selector because collection method
+ # might not match the one selected by the customer.
+ collection_method = next(iter(gateway.provider.supported_collection_methods))
+
+ with transaction.atomic():
+ subscription.plan = self.plan_variation.plan
+ subscription.customer = self.customer
+ # 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()
+
+ if gateway.name == 'bank':
+ payment_method = self.customer.payment_method_add(None, gateway)
+ if subscription.payment_method_id != payment_method.pk:
+ logger.info(
+ 'Switching subscription pk=%d from payment method pk=%d to pk=%d',
+ *[subscription.pk, subscription.payment_method_id, payment_method.pk],
+ )
+ subscription.switch_payment_method(payment_method)
+
+ # 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 JoinView: %s, %s', form.errors, form.data)
+ 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
@@ -144,17 +176,10 @@ class BillingDetailsView(_JoinMixin, 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)
@@ -164,149 +189,55 @@ class BillingDetailsView(_JoinMixin, LoginRequiredMixin, FormView):
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,
- 'client_token': self.get_client_token(currency) if self.customer else None,
- '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)
+ gateway = form.cleaned_data['gateway']
+ price_cents = new_taxable.price.cents
+ subscription = self._get_or_create_subscription(gateway)
# Update the tax info stored on the subscription
subscription.update_tax()
order = self._fetch_or_create_order(form, subscription)
# Update the order to take into account latest changes
+ if order.payment_method_id != subscription.payment_method_id:
+ order.switch_payment_method(subscription.payment_method)
order.update()
# 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'
+ 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)
- response = self._charge_if_supported(form, gateway, order)
- return response
+ 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())
+
+ 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)
diff --git a/subscriptions/views/mixins.py b/subscriptions/views/mixins.py
index c90d8dcc..7085c09d 100644
--- a/subscriptions/views/mixins.py
+++ b/subscriptions/views/mixins.py
@@ -4,7 +4,6 @@ import logging
from django.contrib.auth.mixins import LoginRequiredMixin
from django.forms.utils import ErrorList
-from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from looper.models import Subscription
@@ -35,7 +34,9 @@ class SingleSubscriptionMixin(LoginRequiredMixin):
def get_subscription(self) -> Subscription:
"""Retrieve Subscription object."""
- return get_object_or_404(self.request.user.subscription_set, pk=self.subscription_id)
+ return get_object_or_404(
+ self.request.user.customer.subscription_set, pk=self.subscription_id
+ )
def get_context_data(self, **kwargs) -> dict:
"""Add Subscription to the template context."""
@@ -45,29 +46,6 @@ class SingleSubscriptionMixin(LoginRequiredMixin):
'subscription': subscription,
}
- def dispatch(self, request, *args, **kwargs):
- """Allow the view to do things that rely on auth state before dispatch.
-
- The AnonymousUser instance doesn't have a 'subscriptions' property,
- but login checking only happens in the super().dispatch() call.
- """
- if not hasattr(request.user, 'subscription_set'):
- return self.handle_no_permission()
- response = self.pre_dispatch(request, *args, **kwargs)
- if response:
- return response
- return super().dispatch(request, *args, **kwargs)
-
- def pre_dispatch(self, request, *args, **kwargs) -> Optional[HttpResponse]:
- """Called between a permission check and calling super().dispatch().
-
- This allows you to get the current subscription, or otherwise do things
- that require the user to be logged in.
-
- :return: None to continue handling this request, or a
- HttpResponse to stop processing early.
- """
-
class BootstrapErrorListMixin:
"""Override get_form method changing error_class of the form."""
diff --git a/subscriptions/views/select_plan_variation.py b/subscriptions/views/select_plan_variation.py
index 61207a8e..6bf8175f 100644
--- a/subscriptions/views/select_plan_variation.py
+++ b/subscriptions/views/select_plan_variation.py
@@ -5,7 +5,7 @@ import logging
from django.shortcuts import redirect
from django.views.generic import FormView
-from looper.views.checkout import AbstractPaymentView
+from looper.views.checkout_stripe import CheckoutStripeView
import looper.gateways
import looper.middleware
import looper.models
@@ -20,7 +20,7 @@ logger.setLevel(logging.DEBUG)
class _PlanSelectorMixin:
- get_currency = AbstractPaymentView.get_currency
+ get_currency = CheckoutStripeView.get_currency
select_team_plans = False
plan_variation = None
plan = None
diff --git a/subscriptions/views/settings.py b/subscriptions/views/settings.py
index c8793255..a344cb94 100644
--- a/subscriptions/views/settings.py
+++ b/subscriptions/views/settings.py
@@ -1,11 +1,7 @@
"""Views handling subscription management."""
-from typing import Optional
import logging
-from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
-from django.http import HttpResponseForbidden
-from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy, reverse
from django.views.generic import UpdateView, FormView
@@ -15,8 +11,6 @@ import looper.views.settings
from subscriptions.forms import (
BillingAddressForm,
CancelSubscriptionForm,
- ChangePaymentMethodForm,
- PayExistingOrderForm,
TeamForm,
)
from subscriptions.views.mixins import SingleSubscriptionMixin, BootstrapErrorListMixin
@@ -26,24 +20,13 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
-class BillingAddressView(LoginRequiredMixin, UpdateView):
- """Combine looper's Customer and Address into a billing address."""
+class BillingAddressView(looper.views.settings.BillingAddressView):
+ """Override form class and success URL of looper's view."""
template_name = 'settings/billing_address.html'
- model = looper.models.Address
form_class = BillingAddressForm
success_url = reverse_lazy('subscriptions:billing-address')
- def _get_customer_object(self) -> Optional[looper.models.Customer]:
- if self.request.user.is_anonymous:
- return None
- return self.request.user.customer
-
- def get_object(self, queryset=None) -> Optional[looper.models.Address]:
- """Get billing address."""
- customer = self._get_customer_object()
- return customer.billing_address if customer else None
-
class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
"""Confirm and cancel a subscription."""
@@ -68,78 +51,40 @@ class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView):
- """Use the Braintree drop-in UI to switch a subscription's payment method."""
+ """Override cancel and success URLs."""
- template_name = 'subscriptions/payment_method_change.html'
- form_class = ChangePaymentMethodForm
- success_url = reverse_lazy('user-settings-billing')
+ success_url = 'subscriptions:payment-method-change-done'
- subscription: looper.models.Subscription
-
- def get_initial(self) -> dict:
- """Modify initial form data."""
- initial = super().get_initial()
- initial['next_url_after_done'] = self.success_url
-
- # Looper's view uses customer full_name, we don't
- initial.pop('full_name', None)
-
- # 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
-
- def form_invalid(self, form):
- """Temporarily log all validation errors."""
- logger.exception('Validation error in ChangePaymentMethodForm: %s', form.errors)
- return super().form_invalid(form)
+ def get_cancel_url(self):
+ """Return to this subscription's manage page."""
+ return reverse(
+ 'subscriptions:manage',
+ kwargs={'subscription_id': self.kwargs['subscription_id']},
+ )
-class PayExistingOrderView(looper.views.checkout.CheckoutExistingOrderView):
+class PaymentMethodChangeDoneView(looper.views.settings.PaymentMethodChangeDoneView):
+ """Change payment method in response to a successful payment setup."""
+
+ @property
+ def success_url(self):
+ """Return to this subscription's manage page."""
+ return reverse(
+ 'subscriptions:manage',
+ kwargs={'subscription_id': self.kwargs['subscription_id']},
+ )
+
+
+class PayExistingOrderView(looper.views.checkout_stripe.CheckoutExistingOrderView):
"""Override looper's view with our forms."""
# Redirect to LOGIN_URL instead of raising an exception
raise_exception = False
- template_name = 'subscriptions/pay_existing_order.html'
- form_class = PayExistingOrderForm
- success_url = reverse_lazy('user-settings-billing')
- def get_initial(self) -> dict:
- """Prefill the payment amount and missing form data, if any."""
- initial = {
- 'price': self.order.price.decimals_string,
- 'email': self.customer.billing_email,
- }
-
- # 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
-
- def form_invalid(self, form):
- """Temporarily log all validation errors."""
- logger.exception('Validation error in PayExistingOrderView: %s', form.errors)
- return super().form_invalid(form)
-
- def dispatch(self, request, *args, **kwargs):
- """Return 403 unless current session and the order belong to the same user.
-
- Looper renders a template instead, we just want to display the standard 403 page
- or redirect to login, like LoginRequiredMixin does with raise_exception=False.
- """
- self.order = get_object_or_404(looper.models.Order, pk=kwargs['order_id'])
- if request.user.is_authenticated and self.order.user_id != request.user.id:
- return HttpResponseForbidden()
- self.plan = self.order.subscription.plan
- return super(looper.views.checkout.CheckoutExistingOrderView, self).dispatch(
- request, *args, **kwargs
- )
+ def get_cancel_url(self):
+ """Return to this subscription's manage page."""
+ order = self.get_object()
+ return reverse('subscriptions:manage', kwargs={'subscription_id': order.subscription_id})
class ManageSubscriptionView(
diff --git a/subscriptions/views/teams.py b/subscriptions/views/teams.py
index 66dc0c7b..b1957a11 100644
--- a/subscriptions/views/teams.py
+++ b/subscriptions/views/teams.py
@@ -2,7 +2,7 @@
from collections import defaultdict
from django.views.generic.base import TemplateView
-from looper.views.checkout import AbstractPaymentView
+from looper.views.checkout_stripe import CheckoutStripeView
import looper.models
import subscriptions.models
@@ -11,7 +11,7 @@ import subscriptions.models
class TeamsLanding(TemplateView):
"""Display a selection of team plans and existing sponsors."""
- get_currency = AbstractPaymentView.get_currency
+ get_currency = CheckoutStripeView.get_currency
template_name = 'subscriptions/teams_landing.html'
@staticmethod
diff --git a/subscriptions/views/tests/test_join.py b/subscriptions/views/tests/test_join.py
index a102d5ea..5a722c3f 100644
--- a/subscriptions/views/tests/test_join.py
+++ b/subscriptions/views/tests/test_join.py
@@ -1,24 +1,27 @@
-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
-from common.tests.factories.subscriptions import create_customer_with_billing_address
-from common.tests.factories.users import UserFactory
-from subscriptions.tests.base import BaseSubscriptionTestCase
+from looper.tests.factories import create_customer_with_billing_address
+from common.tests.factories.users import UserFactory, OAuthUserInfoFactory
+from subscriptions.tests.base import BaseSubscriptionTestCase, responses_from_file
import subscriptions.tasks
import users.tasks
import users.tests.util as util
+responses_dir = 'subscriptions/tests/_responses/'
required_address_data = {
'country': 'NL',
'email': 'my.billing.email@example.com',
@@ -36,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 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})
+ 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_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)
@@ -53,6 +68,7 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
self.assertEqual(response.status_code, 200)
self._assert_total_default_variation_selected_tax_21_eur(response)
+ self._assert_pay_via_bank_not_displayed(response)
self.assertContains(
response,
' ',
@@ -65,7 +81,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 +93,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 +104,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 +118,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 +151,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 +169,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)
@@ -156,14 +184,42 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
)
self._assert_total_default_variation_selected_tax_21_eur(response)
+ def test_plan_variation_matches_detected_currency_eur_non_eea_ip(self):
+ customer = create_customer_with_billing_address()
+ self.client.force_login(customer.user)
+
+ response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4)
+
+ self.assertEqual(response.status_code, 200)
+ # Check that prices are in EUR and there is no tax
+ self._assert_total_default_variation_selected_no_tax_eur(response)
+
+ def test_billing_address_country_takes_precedence_over_geo_ip(self):
+ customer = create_customer_with_billing_address(country='NL')
+ self.client.force_login(customer.user)
+
+ response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4)
+
+ self.assertEqual(response.status_code, 200)
+ self._assert_total_default_variation_selected_tax_21_eur(response)
+
@freeze_time('2023-05-19 11:41:11')
-class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
+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})
+ cs_url = 'https://checkout.stripe.com/c/pay/cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl'
+ cs_id = 'cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW'
+
+ def setUp(self):
+ super().setUp()
+ responses._add_from_file(f'{responses_dir}vies_wsdl.yaml')
+ responses._add_from_file(f'{responses_dir}stripe_new_cs_eur.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 = (
@@ -174,7 +230,7 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
)
.first()
)
- data = full_billing_address_data
+ data = {**full_billing_address_data, 'gateway': 'stripe'}
url = reverse(
'subscriptions:join-billing-details',
kwargs={'plan_variation_id': selected_variation.pk},
@@ -197,51 +253,36 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
self.assertContains(response, 'Manual ')
self.assertContains(response, '/ 1 year ', html=True)
- def test_post_has_correct_price_field_value(self):
+ @responses.activate
+ def test_post_redirects_to_stripe_hosted_checkout(self):
self.client.force_login(self.user)
- default_variation = _get_default_variation('EUR')
- data = required_address_data
+ data = {**required_address_data, 'gateway': 'stripe'}
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,
- ' ',
- html=True,
- )
+ self.assertEqual(response['Location'], self.cs_url)
+ @responses.activate
def test_post_updates_billing_address_and_customer_applies_reverse_charged_tax(self):
+ responses._add_from_file(f'{responses_dir}vies_valid.yaml')
self.client.force_login(self.user)
data = {
**required_address_data,
- 'vat_number': 'DE 260543043',
+ 'gateway': 'stripe',
'country': 'DE',
'postal_code': '11111',
+ 'vat_number': 'DE 260543043',
}
response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4)
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
+ address.refresh_from_db()
+ 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')
@@ -255,23 +296,14 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
# Post the same form again
response = self.client.post(self.url, data)
self.assertEqual(response.status_code, 302)
- # Follow the redirect to avoid unexpected assertion errors
- response = self.client.get(response['Location'])
- # Check that we are no longer on the billing details page
- self._assert_payment_form_displayed(response)
-
- # The hidden price field must also be set to a matching amount
- self.assertContains(
- response,
- ' ',
- html=True,
- )
+ self.assertEqual(response['Location'], self.cs_url, response['Location'])
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)
@@ -285,6 +317,7 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
# Post an new address that doesn't require a region
data = {
**required_address_data,
+ 'gateway': 'stripe',
'country': 'DE',
'postal_code': '11111',
}
@@ -304,94 +337,51 @@ 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
+ data = {**required_address_data, 'gateway': 'stripe'}
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
- self.assertEqual(response.status_code, 404)
-
- 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()
- self.client.force_login(user)
-
- data = required_address_data
- response = self.client.post(url, data, REMOTE_ADDR=SINGAPORE_IPV4)
-
- self.assertEqual(response.status_code, 200)
- # Check that prices are in EUR and there is no tax
- self._assert_total_default_variation_selected_no_tax_eur(response)
+ self.assertEqual(response.status_code, 302)
+ expected_url, _ = self._get_url_for(currency='EUR', price=10900)
+ self.assertEqual(response['Location'], expected_url)
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')
- self.client.force_login(user)
+ customer = create_customer_with_billing_address(country='GE')
+ self.client.force_login(customer.user)
- data = required_address_data
- response = self.client.post(url, data, REMOTE_ADDR=SINGAPORE_IPV4)
+ data = {**required_address_data, 'gateway': 'stripe'}
+ response = self.client.post(self.url, data, REMOTE_ADDR=SINGAPORE_IPV4)
self.assertEqual(response.status_code, 200)
self._assert_total_default_variation_selected_tax_21_eur(response)
def test_invalid_missing_required_fields(self):
- url, _ = self._get_url_for(currency='EUR', price=990)
- user = create_customer_with_billing_address(country='NL')
- self.client.force_login(user)
+ customer = create_customer_with_billing_address(country='NL')
+ self.client.force_login(customer.user)
- data = required_address_data
- response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
+ response = self.client.post(self.url, {}, REMOTE_ADDR=EURO_IPV4)
self.assertEqual(response.status_code, 200)
self._assert_total_default_variation_selected_tax_21_eur(response)
self.assertEqual(
response.context['form'].errors,
{
+ 'country': ['This field is required.'],
+ 'email': ['This field is required.'],
+ 'full_name': ['This field is required.'],
'gateway': ['This field is required.'],
- 'payment_method_nonce': ['This field is required.'],
- 'price': ['This field is required.'],
},
)
- 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')
- self.client.force_login(user)
-
- data = {
- **required_address_data,
- 'gateway': 'braintree',
- 'payment_method_nonce': 'fake-valid-nonce',
- 'price': '999.09',
- }
- response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
-
- self.assertEqual(response.status_code, 200)
- self._assert_total_default_variation_selected_tax_21_eur(response)
- self.assertEqual(
- response.context['form'].errors,
- {'__all__': ['Payment failed: please reload the page and try again']},
- )
-
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 +413,9 @@ 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')
+ user = customer.user
+ OAuthUserInfoFactory(user=user, oauth_user_id=554433)
self.client.force_login(user)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
@@ -435,17 +427,12 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
interval_unit='month',
plan__name='Manual renewal',
)
- data = {
- **required_address_data,
- 'gateway': 'bank',
- 'payment_method_nonce': 'unused',
- 'price': '14.90',
- }
+ data = {**required_address_data, 'full_name': 'Jane Doe', 'gateway': 'bank'}
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
self._assert_transactionless_done_page_displayed(response)
- subscription = user.subscription_set.first()
+ subscription = user.customer.subscription_set.first()
self.assertEqual(subscription.status, 'on-hold')
self.assertEqual(subscription.price, Money('EUR', 1490))
self.assertEqual(subscription.tax, Money('EUR', 259))
@@ -500,9 +487,17 @@ 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(
- country='ES', full_name='Jane Doe', vat_number='DE260543043'
- )
+ responses._add_from_file(f'{responses_dir}vies_valid.yaml')
+ address = {
+ **required_address_data,
+ 'country': 'ES',
+ 'full_name': 'Jane Doe',
+ 'postal_code': '11111',
+ 'vat_number': 'ES A78374725',
+ }
+ customer = create_customer_with_billing_address(**address)
+ user = customer.user
+ OAuthUserInfoFactory(user=user, oauth_user_id=554433)
self.client.force_login(user)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
@@ -514,17 +509,12 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
interval_length=3,
interval_unit='month',
)
- data = {
- **required_address_data,
- 'gateway': 'bank',
- 'payment_method_nonce': 'unused',
- 'price': '26.45',
- }
+ data = {'gateway': 'bank', **address}
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
self._assert_transactionless_done_page_displayed(response)
- subscription = user.subscription_set.first()
+ subscription = user.customer.subscription_set.first()
self.assertEqual(subscription.status, 'on-hold')
self.assertEqual(subscription.price, Money('EUR', 3200))
self.assertEqual(subscription.tax, Money('EUR', 0))
@@ -533,6 +523,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self.assertEqual(subscription.collection_method, selected_variation.collection_method)
self.assertEqual(subscription.collection_method, 'manual')
self.assertEqual(subscription.plan, selected_variation.plan)
+ self.assertEqual(str(subscription.payment_method), 'Bank Transfer')
+ self.assertIsNone(subscription.payment_method.token)
order = subscription.latest_order()
self.assertEqual(order.status, 'created')
@@ -545,6 +537,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self.assertIsNotNone(order.display_number)
self.assertIsNotNone(order.vat_number)
self.assertNotEqual(order.display_number, str(order.pk))
+ self.assertEqual(str(order.payment_method), 'Bank Transfer')
+ self.assertIsNone(order.payment_method.token)
self._assert_bank_transfer_email_is_sent(subscription)
self._assert_bank_transfer_email_is_sent_tax_21_eur_reverse_charged(subscription)
@@ -553,6 +547,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
response = self.client.get(
reverse('subscriptions:manage', kwargs={'subscription_id': subscription.pk})
)
+
self.assertNotIn('32.00', response)
self.assertNotIn('21%', response)
self.assertNotIn('Inc.', response)
@@ -581,34 +576,44 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
'users.signals.tasks.grant_blender_id_role',
new=users.tasks.grant_blender_id_role.task_function,
)
- @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
+ OAuthUserInfoFactory(user=user, oauth_user_id=554433)
self.client.force_login(user)
- util.mock_blender_id_badger_badger_response(
- 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
- )
- util.mock_blender_id_badger_badger_response(
- 'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id
- )
- data = {
- **required_address_data,
- 'gateway': 'braintree',
- # fake-three-d-secure-visa-full-authentication-nonce
- # causes the following error:
- # Merchant account must match the 3D Secure authorization merchant account: code 91584
- # TODO(anna): figure out if this is due to our settings or a quirk of the sandbox
- 'payment_method_nonce': 'fake-valid-nonce',
- 'price': '9.90',
- }
- response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
+ data = {**required_address_data, 'gateway': 'stripe'}
+ with responses.RequestsMock() as rsps:
+ rsps._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
+ response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
+
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response['Location'], self.cs_url, response['Location'])
+
+ # **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
+ # Pretend that checkout session was completed and we've returned to the success page with its ID:
+ subscription = user.customer.subscription_set.first()
+ order = subscription.latest_order()
+ url = reverse(
+ 'looper:stripe_success',
+ kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
+ )
+ url = url.replace('CHECKOUT_SESSION_ID', self.cs_id)
+ with responses.RequestsMock() as rsps:
+ responses_from_file('stripe_get_cs_eur.yaml', order_id=order.pk, rsps=rsps)
+ util.mock_blender_id_badger_badger_response(
+ 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id, rsps
+ )
+ util.mock_blender_id_badger_badger_response(
+ 'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id, rsps
+ )
+ response = self.client.get(url)
self._assert_done_page_displayed(response)
- subscription = user.subscription_set.first()
- order = subscription.latest_order()
+ subscription.refresh_from_db()
+ order.refresh_from_db()
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.price, Money('EUR', 990))
self.assertEqual(subscription.collection_method, selected_variation.collection_method)
@@ -629,33 +634,47 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
'users.signals.tasks.grant_blender_id_role',
new=users.tasks.grant_blender_id_role.task_function,
)
- @responses.activate
def test_pay_with_credit_card_creates_order_subscription_active_team(self):
url, selected_variation = self._get_url_for(
currency='EUR',
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
+ OAuthUserInfoFactory(user=user, oauth_user_id=554433)
self.client.force_login(user)
- util.mock_blender_id_badger_badger_response(
- 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
- )
- util.mock_blender_id_badger_badger_response(
- 'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id
- )
- data = {
- **required_address_data,
- 'gateway': 'braintree',
- 'payment_method_nonce': 'fake-valid-nonce',
- 'price': '90.00',
- }
- response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
+ data = {**required_address_data, 'gateway': 'stripe'}
+ with responses.RequestsMock() as rsps:
+ rsps._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
+ response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
+
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response['Location'], self.cs_url, response['Location'])
+
+ # **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
+ # Pretend that checkout session was completed and we've returned to the success page with its ID:
+ subscription = user.customer.subscription_set.first()
+ order = subscription.latest_order()
+ url = reverse(
+ 'looper:stripe_success',
+ kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
+ )
+ url = url.replace('CHECKOUT_SESSION_ID', self.cs_id)
+ with responses.RequestsMock() as rsps:
+ responses_from_file('stripe_get_cs_eur.yaml', order_id=order.pk, rsps=rsps)
+ util.mock_blender_id_badger_badger_response(
+ 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id, rsps
+ )
+ util.mock_blender_id_badger_badger_response(
+ 'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id, rsps
+ )
+ response = self.client.get(url)
self._assert_done_page_displayed(response)
- subscription = user.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 +689,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(
@@ -681,19 +701,46 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
)
data = {
**required_address_data,
- 'vat_number': 'DE 260543043',
+ 'gateway': 'stripe',
'country': 'DE',
'postal_code': '11111',
- 'gateway': 'braintree',
- 'payment_method_nonce': 'fake-valid-nonce',
- # VAT is subtracted from the plan variation price:
- 'price': '12.52',
+ 'vat_number': 'DE 260543043',
}
- response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
+ # @_recorder.record(file_path=f'{responses_dir}stripe_new_cs_eur.yaml')
+ def _continue_to_payment(): # noqa: E306
+ return self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
+
+ with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
+ # request to wsdl doesn't happen on subsequent calls to the validator,
+ # hence assert_all_requests_are_fired = False.
+ rsps._add_from_file(f'{responses_dir}vies_wsdl.yaml')
+ rsps._add_from_file(f'{responses_dir}vies_valid.yaml')
+ rsps._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
+ response = _continue_to_payment()
+
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response['Location'], self.cs_url, response['Location'])
+
+ # **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
+ # Pretend that checkout session was completed and we've returned to the success page with its ID:
+ subscription = user.customer.subscription_set.first()
+ order = subscription.latest_order()
+ url = reverse(
+ 'looper:stripe_success',
+ kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
+ )
+ url = url.replace('CHECKOUT_SESSION_ID', self.cs_id)
+ # @_recorder.record(file_path=f'{responses_dir}stripe_get_cs_eur.yaml')
+ def _back_to_success_url(): # noqa: E306
+ return self.client.get(url)
+
+ with responses.RequestsMock() as rsps:
+ responses_from_file('stripe_get_cs_eur.yaml', order_id=order.pk, rsps=rsps)
+ response = _back_to_success_url()
self._assert_done_page_displayed(response)
- subscription = user.subscription_set.first()
+ subscription.refresh_from_db()
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.price, Money('EUR', 1490))
self.assertEqual(subscription.tax, Money('EUR', 0))
@@ -716,15 +763,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/')
diff --git a/subscriptions/views/tests/test_receipt_pdf.py b/subscriptions/views/tests/test_receipt_pdf.py
index 409ebfc7..8d48c930 100644
--- a/subscriptions/views/tests/test_receipt_pdf.py
+++ b/subscriptions/views/tests/test_receipt_pdf.py
@@ -10,11 +10,9 @@ from freezegun import freeze_time
from looper.tests.factories import PaymentMethodFactory, OrderFactory
import looper.taxes
-from common.tests.factories.subscriptions import (
- TeamFactory,
- create_customer_with_billing_address,
-)
+from common.tests.factories.subscriptions import TeamFactory
from common.tests.factories.users import UserFactory
+from looper.tests.factories import create_customer_with_billing_address
expected_text_tmpl = '''Invoice
Blender Studio B.V.
@@ -63,16 +61,16 @@ class TestReceiptPDFView(TestCase):
def setUpClass(cls):
super().setUpClass()
- user = create_customer_with_billing_address(email='mail1@example.com')
- cls.payment_method = PaymentMethodFactory(user=user)
+ customer = create_customer_with_billing_address(email='mail1@example.com')
+ cls.payment_method = PaymentMethodFactory(customer=customer)
cls.paid_order = OrderFactory(
- user=user,
+ customer=customer,
price=990,
status='paid',
tax_country='NL',
payment_method=cls.payment_method,
+ subscription__customer=customer,
subscription__payment_method=cls.payment_method,
- subscription__user=user,
subscription__plan__product__name='Blender Studio Subscription',
)
@@ -84,15 +82,15 @@ class TestReceiptPDFView(TestCase):
def test_get_pdf_unpaid_order_not_found(self):
unpaid_order = OrderFactory(
- user=self.payment_method.user,
+ customer=self.payment_method.customer,
price=990,
tax_country='NL',
payment_method=self.payment_method,
+ subscription__customer=self.payment_method.customer,
subscription__payment_method=self.payment_method,
- subscription__user=self.payment_method.user,
subscription__plan_id=1,
)
- self.client.force_login(unpaid_order.user)
+ self.client.force_login(unpaid_order.customer.user)
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': unpaid_order.pk})
response = self.client.get(url)
@@ -115,7 +113,7 @@ class TestReceiptPDFView(TestCase):
self.assertEqual(404, response.status_code)
def test_get_pdf_has_logo(self):
- self.client.force_login(self.paid_order.user)
+ self.client.force_login(self.paid_order.customer.user)
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': self.paid_order.pk})
response = self.client.get(url)
@@ -136,7 +134,9 @@ class TestReceiptPDFView(TestCase):
tax_type=looper.taxes.TaxType.VAT_CHARGE,
tax_rate=Decimal(19),
)
+ user = UserFactory()
order = OrderFactory(
+ customer=user.customer,
price=taxable.price,
status='paid',
tax=taxable.tax,
@@ -144,9 +144,10 @@ class TestReceiptPDFView(TestCase):
tax_type=taxable.tax_type.value,
tax_rate=taxable.tax_rate,
email='billing@example.com',
+ subscription__customer=user.customer,
subscription__plan_id=1,
)
- self.client.force_login(order.user)
+ self.client.force_login(order.customer.user)
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
response = self.client.get(url)
@@ -178,7 +179,9 @@ class TestReceiptPDFView(TestCase):
tax_type=looper.taxes.TaxType.VAT_REVERSE_CHARGE,
tax_rate=Decimal(19),
)
+ user = UserFactory()
order = OrderFactory(
+ customer=user.customer,
price=taxable.price,
status='paid',
tax=taxable.tax,
@@ -187,9 +190,10 @@ class TestReceiptPDFView(TestCase):
tax_rate=taxable.tax_rate,
vat_number='DE123456789',
email='billing@example.com',
+ subscription__customer=user.customer,
subscription__plan_id=1,
)
- self.client.force_login(order.user)
+ self.client.force_login(order.customer.user)
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
response = self.client.get(url)
@@ -225,7 +229,9 @@ class TestReceiptPDFView(TestCase):
tax_type=looper.taxes.TaxType.VAT_CHARGE,
tax_rate=Decimal(21),
)
+ user = UserFactory()
order = OrderFactory(
+ customer=user.customer,
price=taxable.price,
status='paid',
tax=taxable.tax,
@@ -234,9 +240,10 @@ class TestReceiptPDFView(TestCase):
tax_rate=taxable.tax_rate,
vat_number='NL123456789',
email='billing@example.com',
+ subscription__customer=user.customer,
subscription__plan_id=1,
)
- self.client.force_login(order.user)
+ self.client.force_login(order.customer.user)
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
response = self.client.get(url)
@@ -263,15 +270,18 @@ class TestReceiptPDFView(TestCase):
@freeze_time('2023-02-08T11:12:20+01:00')
def test_get_pdf_total_no_vat(self):
+ user = UserFactory()
order = OrderFactory(
+ customer=user.customer,
price=1000,
currency='USD',
status='paid',
tax_country='US',
email='billing@example.com',
+ subscription__customer=user.customer,
subscription__plan_id=1,
)
- self.client.force_login(order.user)
+ self.client.force_login(order.customer.user)
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
response = self.client.get(url)
@@ -305,6 +315,7 @@ class TestReceiptPDFView(TestCase):
subscription__plan_id=1,
)
order = OrderFactory(
+ customer=team.subscription.customer,
price=20000,
currency='USD',
status='paid',
@@ -312,7 +323,7 @@ class TestReceiptPDFView(TestCase):
email='billing@example.com',
subscription=team.subscription,
)
- self.client.force_login(order.user)
+ self.client.force_login(order.customer.user)
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
response = self.client.get(url)
@@ -347,6 +358,7 @@ class TestReceiptPDFView(TestCase):
invoice_reference='PO #9876',
)
order = OrderFactory(
+ customer=team.subscription.customer,
price=20000,
currency='USD',
status='paid',
@@ -354,7 +366,7 @@ class TestReceiptPDFView(TestCase):
email='billing@example.com',
subscription=team.subscription,
)
- self.client.force_login(order.user)
+ self.client.force_login(order.customer.user)
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
response = self.client.get(url)
diff --git a/subscriptions/views/tests/test_select_plan_variation.py b/subscriptions/views/tests/test_select_plan_variation.py
index 7e12b773..0a7fbfdd 100644
--- a/subscriptions/views/tests/test_select_plan_variation.py
+++ b/subscriptions/views/tests/test_select_plan_variation.py
@@ -7,7 +7,7 @@ from freezegun import freeze_time
from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4 # , SINGAPORE_IPV4
-from common.tests.factories.subscriptions import create_customer_with_billing_address
+from looper.tests.factories import create_customer_with_billing_address
from subscriptions.tests.base import BaseSubscriptionTestCase
# **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database.
@@ -49,8 +49,8 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase):
self._assert_default_variation_selected_no_tax_usd(response)
def test_get_displays_plan_selection_to_logged_in_nl(self):
- user = create_customer_with_billing_address(vat_number='', country='NL')
- self.client.force_login(user)
+ customer = create_customer_with_billing_address(vat_number='', country='NL')
+ self.client.force_login(customer.user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
@@ -60,8 +60,8 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase):
self._assert_default_variation_selected_tax_21_eur(response)
def test_get_displays_plan_selection_to_logged_in_de(self):
- user = create_customer_with_billing_address(vat_number='', country='DE')
- self.client.force_login(user)
+ customer = create_customer_with_billing_address(vat_number='', country='DE')
+ self.client.force_login(customer.user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
@@ -71,10 +71,10 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase):
self._assert_default_variation_selected_tax_19_eur(response)
def test_get_displays_plan_selection_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'
)
- self.client.force_login(user)
+ self.client.force_login(customer.user)
response = self.client.get(self.url)
@@ -84,10 +84,10 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase):
self._assert_plan_selector_no_tax(response)
def test_get_team_displays_plan_selection_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'
)
- self.client.force_login(user)
+ self.client.force_login(customer.user)
response = self.client.get(self.url_team)
diff --git a/subscriptions/views/tests/test_settings.py b/subscriptions/views/tests/test_settings.py
index f9a7d266..5fd34e11 100644
--- a/subscriptions/views/tests/test_settings.py
+++ b/subscriptions/views/tests/test_settings.py
@@ -4,12 +4,16 @@ from django.urls import reverse
from looper.models import PaymentMethod, PaymentMethodAuthentication, Gateway
from looper.money import Money
+from looper.tests.factories import SubscriptionFactory
+import responses
+
+# from responses import _recorder
from common.tests.factories.users import UserFactory
-from common.tests.factories.subscriptions import SubscriptionFactory
-from subscriptions.tests.base import BaseSubscriptionTestCase
+from subscriptions.tests.base import BaseSubscriptionTestCase, responses_from_file
import subscriptions.tasks
+responses_dir = 'subscriptions/tests/_responses/'
required_address_data = {
'country': 'NL',
'email': 'my.billing.email@example.com',
@@ -27,16 +31,17 @@ full_billing_address_data = {
class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
- def test_saves_both_address_and_customer(self):
+ url = reverse('subscriptions:billing-address')
+
+ def test_saves_full_billing_address(self):
user = UserFactory()
self.client.force_login(user)
- url = reverse('subscriptions:billing-address')
- response = self.client.post(url, full_billing_address_data)
+ response = self.client.post(self.url, full_billing_address_data)
# Check that the redirect on success happened
self.assertEqual(response.status_code, 302, response.content)
- self.assertEqual(response['Location'], url)
+ self.assertEqual(response['Location'], self.url)
# Check that all address fields were updated
customer = user.customer
@@ -51,15 +56,14 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
self.assertEqual(address.company, 'Test LLC')
# Check that customer fields were updated as well
- self.assertEqual(customer.vat_number, 'NL818152011B01')
- # N.B.: email is saved as Customer.billing_email
- self.assertEqual(customer.billing_email, 'my.billing.email@example.com')
+ self.assertEqual(customer.billing_address.vat_number, 'NL818152011B01')
+ self.assertEqual(customer.billing_address.email, 'my.billing.email@example.com')
def test_invalid_missing_required_fields(self):
user = UserFactory()
self.client.force_login(user)
- response = self.client.post(reverse('subscriptions:billing-address'), {})
+ response = self.client.post(self.url, {})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist')
@@ -72,7 +76,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
data = {
'email': 'new@example.com',
}
- response = self.client.post(reverse('subscriptions:billing-address'), data)
+ response = self.client.post(self.url, data)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist')
@@ -85,7 +89,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
data = {
'full_name': 'New Full Name',
}
- response = self.client.post(reverse('subscriptions:billing-address'), data)
+ response = self.client.post(self.url, data)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist')
@@ -100,11 +104,13 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
url_name = 'subscriptions:payment-method-change'
success_url_name = 'user-settings-billing'
+ # @_recorder.record(file_path=f'{responses_dir}stripe_new_cs_setup.yaml')
+ # @_recorder.record(file_path=f'{responses_dir}stripe_get_cs_setup.yaml')
def test_can_change_payment_method_from_bank_to_credit_card_with_sca(self):
bank = Gateway.objects.get(name='bank')
subscription = SubscriptionFactory(
- user=self.user,
- payment_method__user_id=self.user.pk,
+ customer=self.user.customer,
+ payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=bank,
)
self.assertEqual(PaymentMethod.objects.count(), 1)
@@ -113,15 +119,24 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
self.client.force_login(self.user)
url = reverse(self.url_name, kwargs={'subscription_id': subscription.pk})
- data = {
- **self.shared_payment_form_data,
- 'gateway': 'braintree',
- 'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
- }
- response = self.client.post(url, data=data)
+ with responses.RequestsMock() as rsps:
+ rsps._add_from_file(f'{responses_dir}stripe_new_cs_setup.yaml')
+ response = self.client.post(url)
self.assertEqual(response.status_code, 302)
- self.assertEqual(response['Location'], reverse(self.success_url_name))
+ expected_redirect_url = 'https://checkout.stripe.com/c/pay/cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBaZmppcGhrJyknYGtkZ2lgVWlkZmBtamlhYHd2Jz9xd3BgeCUl'
+ self.assertEqual(response['Location'], expected_redirect_url, response['Location'])
+
+ # **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
+ # Pretend that checkout session was completed and we've returned to the success page with its ID:
+ checkout_session_id = 'cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9'
+ success_url = url + f'{checkout_session_id}/'
+ with responses.RequestsMock() as rsps:
+ rsps._add_from_file(f'{responses_dir}stripe_get_cs_setup.yaml')
+ response = self.client.get(success_url)
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response['Location'], f'/subscription/{subscription.pk}/manage/')
+
# New payment method was created
self.assertEqual(PaymentMethod.objects.count(), 2)
@@ -131,40 +146,9 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
self.assertNotEqual(subscription.payment_method_id, payment_method.pk)
self.assertEqual(
str(subscription.payment_method),
- 'Visa credit card ending in 0002',
+ 'visa credit card ending in 4242',
)
- # SCA was stored
- self.assertIsNotNone(PaymentMethodAuthentication.objects.first())
-
- def test_can_change_payment_method_from_credit_card_to_bank(self):
- braintree = Gateway.objects.get(name='braintree')
- subscription = SubscriptionFactory(
- user=self.user,
- payment_method__user_id=self.user.pk,
- payment_method__gateway=braintree,
- )
- self.assertEqual(PaymentMethod.objects.count(), 1)
- payment_method = subscription.payment_method
- self.client.force_login(self.user)
-
- url = reverse(self.url_name, kwargs={'subscription_id': subscription.pk})
- data = {
- **self.shared_payment_form_data,
- 'gateway': 'bank',
- 'payment_method_nonce': 'unused',
- }
- response = self.client.post(url, data=data)
-
- self.assertEqual(response.status_code, 302)
- self.assertEqual(response['Location'], reverse(self.success_url_name))
- # New payment method was created
- self.assertEqual(PaymentMethod.objects.count(), 2)
-
- subscription.refresh_from_db()
- subscription.payment_method.refresh_from_db()
- # Subscription's payment method was changed to bank transfer
- self.assertNotEqual(subscription.payment_method_id, payment_method.pk)
- self.assertEqual(str(subscription.payment_method), 'Bank Transfer')
+ # SCA isn't stored for Stripe payment methods
self.assertIsNone(PaymentMethodAuthentication.objects.first())
@@ -173,8 +157,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
def test_can_cancel_when_on_hold(self):
subscription = SubscriptionFactory(
- user=self.user,
- payment_method__user_id=self.user.pk,
+ customer=self.user.customer,
+ payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold',
)
@@ -198,8 +182,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
def test_can_cancel_when_active(self):
subscription = SubscriptionFactory(
- user=self.user,
- payment_method__user_id=self.user.pk,
+ customer=self.user.customer,
+ payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='active',
)
@@ -218,8 +202,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
def test_email_sent_when_pending_cancellation_changes_to_cancelled(self):
subscription = SubscriptionFactory(
- user=self.user,
- payment_method__user_id=self.user.pk,
+ customer=self.user.customer,
+ payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='pending-cancellation',
)
@@ -240,12 +224,11 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
class TestPayExistingOrder(BaseSubscriptionTestCase):
url_name = 'subscriptions:pay-existing-order'
- success_url_name = 'user-settings-billing'
def test_redirect_to_login_when_anonymous(self):
subscription = SubscriptionFactory(
- user=self.user,
- payment_method__user_id=self.user.pk,
+ customer=self.user.customer,
+ payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold',
)
@@ -253,19 +236,15 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
self.client.logout()
url = reverse(self.url_name, kwargs={'order_id': order.pk})
- data = {
- 'gateway': 'braintree',
- 'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
- }
- response = self.client.post(url, data=data)
+ response = self.client.get(url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], f'/oauth/login?next={url}')
def test_cannot_pay_someone_elses_order(self):
subscription = SubscriptionFactory(
- user=self.user,
- payment_method__user_id=self.user.pk,
+ customer=self.user.customer,
+ payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold',
)
@@ -274,40 +253,12 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
self.client.force_login(user)
url = reverse(self.url_name, kwargs={'order_id': order.pk})
- data = {
- 'gateway': 'braintree',
- 'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
- }
- response = self.client.post(url, data=data)
+ response = self.client.get(url)
- self.assertEqual(response.status_code, 403)
-
- def test_invalid_missing_required_form_data(self):
- subscription = SubscriptionFactory(
- user=self.user,
- payment_method__user_id=self.user.pk,
- payment_method__gateway=Gateway.objects.get(name='bank'),
- status='on-hold',
- )
- order = subscription.generate_order()
- self.client.force_login(self.user)
-
- url = reverse(self.url_name, kwargs={'order_id': order.pk})
- response = self.client.post(url, data={})
-
- self.assertEqual(response.status_code, 200)
- self.assertEqual(
- response.context['form'].errors,
- {
- # 'full_name': ['This field is required.'],
- # 'country': ['This field is required.'],
- # 'email': ['This field is required.'],
- 'payment_method_nonce': ['This field is required.'],
- 'gateway': ['This field is required.'],
- 'price': ['This field is required.'],
- },
- )
+ self.assertEqual(response.status_code, 404)
+ # @_recorder.record(file_path=f'{responses_dir}stripe_new_cs_usd.yaml')
+ # @_recorder.record(file_path=f'{responses_dir}stripe_get_cs_usd.yaml')
@patch(
# Make sure background task is executed as a normal function
'subscriptions.signals.tasks.send_mail_subscription_status_changed',
@@ -315,26 +266,39 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
)
def test_can_pay_for_manual_subscription_with_an_order(self):
subscription = SubscriptionFactory(
- user=self.user,
- payment_method__user_id=self.user.pk,
+ plan__name='Automatic renewal subscription',
+ customer=self.user.customer,
+ payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
currency='USD',
price=Money('USD', 1110),
status='on-hold',
)
+ self.assertEqual(subscription.collection_method, 'automatic')
order = subscription.generate_order()
self.client.force_login(self.user)
url = reverse(self.url_name, kwargs={'order_id': order.pk})
- data = {
- **required_address_data,
- 'price': order.price,
- 'gateway': 'braintree',
- 'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
- }
- response = self.client.post(url, data=data)
+ with responses.RequestsMock() as rsps:
+ rsps._add_from_file(f'{responses_dir}stripe_new_cs_usd.yaml')
+ response = self.client.get(url)
self.assertEqual(response.status_code, 302)
+ expected_redirect_url = 'https://checkout.stripe.com/c/pay/cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl'
+ self.assertEqual(response['Location'], expected_redirect_url)
+
+ # **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
+ # Pretend that checkout session was completed and we've returned to the success page with its ID:
+ checkout_session_id = 'cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w'
+ url = reverse(
+ 'looper:stripe_success',
+ kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
+ )
+ url = url.replace('CHECKOUT_SESSION_ID', checkout_session_id)
+ with responses.RequestsMock() as rsps:
+ responses_from_file('stripe_get_cs_usd.yaml', order_id=order.pk, rsps=rsps)
+ response = self.client.get(url)
+
self.assertEqual(order.transaction_set.count(), 1)
transaction = order.latest_transaction()
self.assertEqual(
@@ -350,7 +314,7 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
self.assertNotEqual(subscription.payment_method, 'bank')
self.assertEqual(
str(subscription.payment_method),
- 'Visa credit card ending in 0002',
+ 'PayPal account billing@example.com',
)
self.assertEqual(subscription.status, 'active')
diff --git a/users/admin.py b/users/admin.py
index cf287739..9d0a3f6c 100644
--- a/users/admin.py
+++ b/users/admin.py
@@ -6,6 +6,9 @@ from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from rest_framework.authtoken.admin import TokenAdmin
+import nested_admin
+
+from looper.admin import REL_CUSTOMER_SEARCH_FIELDS
import looper.admin
import looper.models
@@ -26,7 +29,7 @@ user_section_progress_link.short_description = 'Training sections progress'
def user_subscriptions_link(obj, title='View subscriptions of this user'):
admin_view = looper.admin._get_admin_url_name(looper.models.Subscription, 'changelist')
- link = reverse(admin_view) + f'?user_id={obj.pk}'
+ link = reverse(admin_view) + f'?customer_id={obj.customer.pk}'
return format_html('{} ', link, title)
@@ -80,7 +83,7 @@ class NumberOfBraintreeCustomerIDsFilter(admin.SimpleListFilter):
@admin.register(get_user_model())
-class UserAdmin(auth_admin.UserAdmin):
+class UserAdmin(auth_admin.UserAdmin, nested_admin.NestedModelAdmin):
change_form_template = 'loginas/change_form.html'
def has_add_permission(self, request):
@@ -91,9 +94,9 @@ class UserAdmin(auth_admin.UserAdmin):
"""Count user subscriptions for subscription debugging purposes."""
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.annotate(
- subscriptions_count=Count('subscription', distinct=True),
+ subscriptions_count=Count('customer__subscription', distinct=True),
braintreecustomerids_count=Count(
- 'gatewaycustomerid', Q(gateway__name='braintree'), distinct=True
+ 'customer__gatewaycustomerid', Q(customer__gateway__name='braintree'), distinct=True
),
)
return queryset
@@ -147,13 +150,12 @@ class UserAdmin(auth_admin.UserAdmin):
user_subscriptions_link,
subscriptions,
)
- inlines = [
- looper.admin.AddressInline,
+ inlines = (
+ looper.admin.LinkCustomerTokenInline,
looper.admin.CustomerInline,
- looper.admin.GatewayCustomerIdInline,
- ]
+ )
ordering = ['-date_joined']
- search_fields = ['email', 'full_name', 'username']
+ search_fields = ['email', 'full_name', 'username', *REL_CUSTOMER_SEARCH_FIELDS]
def deletion_requested(self, obj):
"""Display yes/no icon status of deletion request."""
diff --git a/users/models.py b/users/models.py
index 3988bc81..5c76a93a 100644
--- a/users/models.py
+++ b/users/models.py
@@ -174,7 +174,7 @@ class User(AbstractUser):
self.delete_oauth()
# If there are no orders, the user account can be deleted
- if self.order_set.count() == 0:
+ if self.customer.order_set.count() == 0:
logger.warning(
'User pk=%s requested deletion and has no orders: deleting the account',
self.pk,
@@ -199,7 +199,7 @@ class User(AbstractUser):
logger.warning('Anonymized user pk=%s', self.pk)
logger.warning('Soft-deleting payment methods records of user pk=%s', self.pk)
- for payment_method in self.paymentmethod_set.all():
+ for payment_method in self.customer.paymentmethod_set.all():
payment_method.recognisable_name = ''
logger.warning(
'Deleting payment method %s of user pk=%s at the payment gateway',
@@ -208,17 +208,14 @@ class User(AbstractUser):
)
payment_method.delete()
- logger.warning('Deleting address records of user pk=%s', self.pk)
- looper.models.Address.objects.filter(user_id=self.pk).delete()
+ customer_id = self.customer.pk
+ logger.warning('Deleting address records of customer pk=%s', customer_id)
+ looper.models.Address.objects.filter(customer_id=customer_id).delete()
- logger.warning('Anonymizing Customer record of user pk=%s', self.pk)
- looper.models.Customer.objects.exclude(user_id=None).filter(user_id=self.pk).update(
- billing_email=f'{username}@example.com',
- full_name='',
- )
-
- looper.models.GatewayCustomerId.objects.filter(user_id=self.pk).delete()
+ logger.warning('Deleting gateway customer ID records of customer pk=%s', customer_id)
+ looper.models.GatewayCustomerId.objects.filter(customer_id=customer_id).delete()
+ logger.warning('Deleting user pk=%s from teams', self.pk)
subscriptions.models.TeamUsers.objects.filter(user_id=self.pk).delete()
logger.warning('Anonymizing comments of user pk=%s', self.pk)
diff --git a/users/tasks.py b/users/tasks.py
index d1948dd4..5446d029 100644
--- a/users/tasks.py
+++ b/users/tasks.py
@@ -107,7 +107,7 @@ def handle_tracking_event_unsubscribe(event_type: str, message_id: str, event: D
def unsubscribe_from_newsletters(pk: int):
"""Remove emails of user with given pk from newsletter lists."""
user = User.objects.get(pk=pk)
- emails = (user.email, user.customer.billing_email)
+ emails = (user.email, user.customer.billing_address.email)
for email in emails:
if not email:
continue
@@ -135,7 +135,7 @@ def handle_deletion_request(pk: int) -> bool:
try:
unsubscribe_from_newsletters(pk=pk)
except Exception:
- logger.warning('Error while trying to unsubscribe user pk=%s from newsletters')
+ logger.warning('Error while trying to unsubscribe user pk=%s from newsletters', pk)
user.anonymize_or_delete()
return True
diff --git a/users/templates/users/settings/base.html b/users/templates/users/settings/base.html
index a0e1bd46..d5fec9ba 100644
--- a/users/templates/users/settings/base.html
+++ b/users/templates/users/settings/base.html
@@ -1,4 +1,5 @@
{% extends 'common/base.html' %}
+{% load pipeline %}
{% block nav_drawer_inner %}
{% include 'users/settings/tabs.html' %}
@@ -21,9 +22,13 @@
- {% block settings%}
+ {% block settings %}
{% endblock settings %}
{% endblock content %}
+
+{% block scripts %}
+ {% javascript "subscriptions" %}
+{% endblock scripts %}
diff --git a/users/templates/users/settings/billing.html b/users/templates/users/settings/billing.html
index 3f355947..98c8d9dd 100644
--- a/users/templates/users/settings/billing.html
+++ b/users/templates/users/settings/billing.html
@@ -4,7 +4,7 @@
{% block settings %}
Settings
- Subscription{% if user.subscription_set.count > 1 %}s{% endif %}
+ Subscription{% if user.customer.subscription_set.count > 1 %}s{% endif %}
If you have any problems with billing, contact the team directly on {{ ADMIN_MAIL }} .
{% if user|has_group:"demo" %}
@@ -18,7 +18,8 @@
- {% else %}{# for everyone except demo #}
+ {% endif %}
+ {% if not user|has_group:"demo" or user.customer.subscription_set.count %}
{% include "subscriptions/components/list.html" %}
diff --git a/users/tests/test_tasks.py b/users/tests/test_tasks.py
index 01bc6491..909e688a 100644
--- a/users/tests/test_tasks.py
+++ b/users/tests/test_tasks.py
@@ -5,15 +5,17 @@ from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.utils import timezone
-from comments.queries import set_comment_like
-from common.tests.factories.comments import CommentFactory
-from common.tests.factories.subscriptions import (
- TeamFactory,
+from looper.tests.factories import (
PaymentMethodFactory,
TransactionFactory,
create_customer_with_billing_address,
)
-from common.tests.factories.users import UserFactory
+import looper.models
+
+from comments.queries import set_comment_like
+from common.tests.factories.comments import CommentFactory
+from common.tests.factories.subscriptions import TeamFactory
+from common.tests.factories.users import UserFactory, OAuthUserInfoFactory, OAuthUserTokenFactory
import users.tasks as tasks
import users.tests.util as util
@@ -49,9 +51,13 @@ class TestTasks(TestCase):
def test_handle_deletion_request(self):
now = timezone.now()
- user = create_customer_with_billing_address(
+ customer = create_customer_with_billing_address(
email='mail1@example.com', date_deletion_requested=now - timedelta(days=30)
)
+ user = customer.user
+ OAuthUserInfoFactory(user=user, oauth_user_id=223344)
+ OAuthUserTokenFactory(user=user)
+ OAuthUserTokenFactory(user=user)
# this user made some comments
user_comments = [CommentFactory(user=user) for _ in range(2)]
# this user liked some comments as well
@@ -102,22 +108,27 @@ class TestTasks(TestCase):
def test_handle_deletion_request_user_has_orders(self):
now = timezone.now()
- user = create_customer_with_billing_address(
+ customer = create_customer_with_billing_address(
email='mail1@example.com', date_deletion_requested=now - timedelta(days=30)
)
+ user = customer.user
+ OAuthUserInfoFactory(user=user, oauth_user_id=223344)
+ OAuthUserTokenFactory(user=user)
+ OAuthUserTokenFactory(user=user)
# this user has a subscription with an order and a transaction
- payment_method = PaymentMethodFactory(user=user)
+ payment_method = PaymentMethodFactory(customer=customer, token='fake-token')
transaction = TransactionFactory(
- user=user,
- order__price=990,
- order__tax_country='NL',
+ customer=customer,
+ order__customer=customer,
order__payment_method=payment_method,
+ order__price=990,
+ order__subscription__customer=customer,
order__subscription__payment_method=payment_method,
- order__subscription__user=user,
order__subscription__status='cancelled',
- order__user=user,
+ order__tax_country='NL',
payment_method=payment_method,
)
+ billing_address = customer.billing_address
# this user made some comments
user_comments = [CommentFactory(user=user) for _ in range(2)]
# this user liked some comments as well
@@ -142,8 +153,9 @@ class TestTasks(TestCase):
f'Anonymized user pk={user.pk}',
f'Soft-deleting payment methods records of user pk={user.pk}',
rf'Deleting payment method \d+ of user pk={user.pk} at the payment gateway',
- f'Deleting address records of user pk={user.pk}',
- f'Anonymizing Customer record of user pk={user.pk}',
+ f'Deleting address records of customer pk={customer.pk}',
+ f'Deleting gateway customer ID records of customer pk={customer.pk}',
+ f'Deleting user pk={user.pk} from teams',
f'Anonymizing comments of user pk={user.pk}',
f'Anonymizing likes of user pk={user.pk}',
f'Deleting actions of user pk={user.pk}',
@@ -165,12 +177,11 @@ class TestTasks(TestCase):
self.assertEqual(user.full_name, '')
self.assertTrue(user.email.startswith('del'))
self.assertTrue(user.email.endswith('@example.com'))
- user.customer.refresh_from_db()
- self.assertTrue(user.customer.billing_email.startswith('del'), user.customer.billing_email)
- self.assertTrue(user.customer.billing_email.endswith('@example.com'), user.customer.billing_email)
- self.assertEqual(user.customer.full_name, '' , user.customer.full_name)
- self.assertEqual(user.address_set.count(), 0)
- self.assertEqual(user.paymentmethod_set.first().recognisable_name, '')
+ # billing address was deleted
+ with self.assertRaises(looper.models.Address.DoesNotExist):
+ billing_address.refresh_from_db()
+ customer.refresh_from_db()
+ self.assertEqual(customer.paymentmethod_set.first().recognisable_name, '')
# user actions got deleted
for action in user_actions:
@@ -192,22 +203,23 @@ class TestTasks(TestCase):
def test_handle_deletion_request_user_has_not_yet_cancelled_subscription(self):
now = timezone.now()
- user = create_customer_with_billing_address(
+ customer = create_customer_with_billing_address(
full_name='Joe Dane',
email='mail1@example.com',
date_deletion_requested=now - timedelta(days=30),
)
+ user = customer.user
# this user has a subscription with an order and a transaction
- payment_method = PaymentMethodFactory(user=user)
+ payment_method = PaymentMethodFactory(customer=customer)
transaction = TransactionFactory(
- user=user,
- order__price=990,
- order__tax_country='NL',
+ customer=customer,
+ order__customer=customer,
order__payment_method=payment_method,
+ order__price=990,
+ order__subscription__customer=customer,
order__subscription__payment_method=payment_method,
- order__subscription__user=user,
order__subscription__status='on-hold',
- order__user=user,
+ order__tax_country='NL',
payment_method=payment_method,
)
@@ -235,7 +247,7 @@ class TestTasks(TestCase):
def test_handle_deletion_request_user_has_orders_and_is_on_a_team(self):
now = timezone.now()
team = TeamFactory(
- subscription__user=create_customer_with_billing_address(
+ subscription__customer=create_customer_with_billing_address(
full_name='Joe Manager Dane',
email='mail1@example.com',
)
@@ -246,25 +258,27 @@ class TestTasks(TestCase):
team.users.add(user_to_be_deleted)
self.assertEqual(3, team.users.count())
# this user also has a subscription with an order and a transaction
- payment_method = PaymentMethodFactory(user=user_to_be_deleted)
+ payment_method = PaymentMethodFactory(
+ customer=user_to_be_deleted.customer, token='fake-token'
+ )
TransactionFactory(
- user=user_to_be_deleted,
+ customer=user_to_be_deleted.customer,
order__price=990,
order__tax_country='NL',
+ order__customer=user_to_be_deleted.customer,
order__payment_method=payment_method,
order__subscription__payment_method=payment_method,
- order__subscription__user=user_to_be_deleted,
+ order__subscription__customer=user_to_be_deleted.customer,
order__subscription__status='cancelled',
- order__user=user_to_be_deleted,
payment_method=payment_method,
)
tasks.handle_deletion_request.task_function(pk=user_to_be_deleted.pk)
# sanity check: nothing happened to the user owning the team subscription
- team.subscription.user.refresh_from_db()
- self.assertEqual('Joe Manager Dane', team.subscription.user.full_name)
- self.assertTrue(team.subscription.user.is_active)
+ team.subscription.customer.user.refresh_from_db()
+ self.assertEqual('Joe Manager Dane', team.subscription.customer.user.full_name)
+ self.assertTrue(team.subscription.customer.user.is_active)
# user wasn't deleted but anonymised
user_to_be_deleted.refresh_from_db()
@@ -280,7 +294,7 @@ class TestTasks(TestCase):
def test_handle_deletion_request_user_and_is_on_a_team(self):
now = timezone.now()
team = TeamFactory(
- subscription__user=create_customer_with_billing_address(
+ subscription__customer=create_customer_with_billing_address(
full_name='Joe Manager Dane',
email='mail1@example.com',
)
@@ -294,9 +308,9 @@ class TestTasks(TestCase):
tasks.handle_deletion_request.task_function(pk=user_to_be_deleted.pk)
# sanity check: nothing happened to the user owning the team subscription
- team.subscription.user.refresh_from_db()
- self.assertEqual('Joe Manager Dane', team.subscription.user.full_name)
- self.assertTrue(team.subscription.user.is_active)
+ team.subscription.customer.user.refresh_from_db()
+ self.assertEqual('Joe Manager Dane', team.subscription.customer.user.full_name)
+ self.assertTrue(team.subscription.customer.user.is_active)
# user was deleted
with self.assertRaises(User.DoesNotExist):
diff --git a/users/tests/util.py b/users/tests/util.py
index 21f02b35..dce3092a 100644
--- a/users/tests/util.py
+++ b/users/tests/util.py
@@ -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,7 +143,7 @@ def mock_mailgun_responses() -> None:
def create_admin_log_user() -> User:
"""Create the admin user used for logging."""
- admin_user = UserFactory(id=1, 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)
+ admin_user, _ = User.objects.update_or_create(
+ id=1, defaults={'email': 'admin@blender.studio', 'is_staff': True, 'is_superuser': True}
+ )
return admin_user