From e27d353cd0b04a29927e154496778aec4c8fa484 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Thu, 6 Jun 2024 19:03:22 +0200 Subject: [PATCH 01/49] Initial attempt at loading the checkout and existing records --- .env.example | 4 + common/tests/factories/subscriptions.py | 80 +------------- poetry.lock | 61 ++++++---- pyproject.toml | 6 +- studio/settings.py | 16 +++ subscriptions/forms.py | 104 +++--------------- subscriptions/migrations/0002_add_gateways.py | 23 +--- subscriptions/queries.py | 15 ++- subscriptions/signals.py | 42 +++++-- subscriptions/tasks.py | 8 +- .../subscriptions/components/list.html | 2 +- .../templates/subscriptions/emails/base.html | 2 +- .../templates/subscriptions/emails/base.txt | 2 +- .../emails/managed_notification.html | 2 +- .../emails/managed_notification.txt | 2 +- .../emails/paypal_subscription_cancelled.html | 2 +- .../emails/paypal_subscription_cancelled.txt | 2 +- .../emails/subscription_expired.html | 2 +- .../emails/subscription_expired.txt | 2 +- subscriptions/templatetags/subscriptions.py | 1 - subscriptions/tests/base.py | 46 ++++---- subscriptions/tests/test_clock.py | 7 +- subscriptions/tests/test_forms.py | 2 - subscriptions/tests/test_queries.py | 4 +- subscriptions/urls.py | 2 +- subscriptions/views/join.py | 5 +- subscriptions/views/mixins.py | 6 +- subscriptions/views/select_plan_variation.py | 2 +- subscriptions/views/settings.py | 32 +----- subscriptions/views/teams.py | 2 +- subscriptions/views/tests/test_join.py | 12 +- subscriptions/views/tests/test_receipt_pdf.py | 6 +- .../views/tests/test_select_plan_variation.py | 2 +- subscriptions/views/tests/test_settings.py | 51 +++++---- users/admin.py | 20 ++-- users/tasks.py | 2 +- users/templates/users/settings/billing.html | 2 +- users/tests/test_tasks.py | 9 +- 38 files changed, 232 insertions(+), 358 deletions(-) diff --git a/.env.example b/.env.example index 955d37fe..8cbc61bc 100644 --- a/.env.example +++ b/.env.example @@ -52,3 +52,7 @@ 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/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/poetry.lock b/poetry.lock index 806d74de..36b6cb24 100644 --- a/poetry.lock +++ b/poetry.lock @@ -504,19 +504,19 @@ test = ["djangorestframework", "graphene-django", "pytest", "pytest-cov", "pytes [[package]] name = "django-debug-toolbar" -version = "2.2.1" +version = "4.3.0" description = "A configurable set of panels that display various debug information about the current request/response." category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.8" files = [ - {file = "django-debug-toolbar-2.2.1.tar.gz", hash = "sha256:7aadab5240796ffe8e93cc7dfbe2f87a204054746ff7ff93cd6d4a0c3747c853"}, - {file = "django_debug_toolbar-2.2.1-py3-none-any.whl", hash = "sha256:7feaee934608f5cdd95432154be832fe30fda6c1249018191e2c27bc0b6a965e"}, + {file = "django_debug_toolbar-4.3.0-py3-none-any.whl", hash = "sha256:e09b7dcb8417b743234dfc57c95a7c1d1d87a88844abd13b4c5387f807b31bf6"}, + {file = "django_debug_toolbar-4.3.0.tar.gz", hash = "sha256:0b0dddee5ea29b9cb678593bc0d7a6d76b21d7799cb68e091a2148341a80f3c4"}, ] [package.dependencies] -Django = ">=1.11" -sqlparse = ">=0.2.0" +django = ">=3.2.4" +sqlparse = ">=0.2" [[package]] name = "django-loginas" @@ -532,19 +532,22 @@ files = [ [[package]] name = "django-nested-admin" -version = "3.4.0" +version = "4.0.2" description = "Django admin classes that allow for nested inlines" category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" files = [ - {file = "django-nested-admin-3.4.0.tar.gz", hash = "sha256:fbcf20d75a73dcbcc6285793ff936eff8df4deba5b169e0c1ab765394c562805"}, - {file = "django_nested_admin-3.4.0-py2.py3-none-any.whl", hash = "sha256:c6852c5ac632f4e698b6beda455006fd464c852459e5e858a6db832cdb23d9e1"}, + {file = "django-nested-admin-4.0.2.tar.gz", hash = "sha256:79a5ce80b81c41a0f48f778fb556a3721029df48580bfce60a5962ffa2ef2d47"}, + {file = "django_nested_admin-4.0.2-py3-none-any.whl", hash = "sha256:74d8fc0d0f17862ffcece66d39b8814cb649878b3b6ba8438076145a688ea473"}, ] [package.dependencies] python-monkey-business = ">=1.0.0" -six = "*" + +[package.extras] +dev = ["Pillow", "black", "dj-database-url", "django-selenosis", "flake8", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "selenium"] +test = ["Pillow", "dj-database-url", "django-selenosis", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "selenium"] [[package]] name = "django-pipeline" @@ -1297,7 +1300,7 @@ six = ">=1.11.0" [[package]] name = "looper" -version = "2.1.2" +version = "3.2.9" description = "" category = "main" optional = false @@ -1313,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 = "c5f54b309d0" -resolved_reference = "c5f54b309d001912ef29ea5482864f97be8a2773" +reference = "56c6d4b" +resolved_reference = "56c6d4b612a4b0cae032a6b5f9dad0ac4c3608ac" [[package]] name = "lxml" @@ -2674,6 +2679,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" @@ -2770,14 +2791,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]] @@ -2926,4 +2947,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "11610e2657ee40c139b3aa89ccc597309b77d5b1fa8dcc9358cbe06fb3ac5796" +content-hash = "8ca93ee62a2b6e396676492b70085f90ca192e5a84d742f032e884c3ef691d41" diff --git a/pyproject.toml b/pyproject.toml index 5540b9e1..c1571aff 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 = "c5f54b309d0"} +looper = {git = "https://projects.blender.org/infrastructure/looper.git", rev = "56c6d4b"} Pillow = "^8.0" django-storages = {extras = ["google"], version = "1.11.1"} pymongo = "^3.10.1" @@ -32,7 +32,7 @@ requests-oauthlib = "^1.3.0" django-activity-stream = "^0.9.0" django-background-tasks-updated = {git = "https://projects.blender.org/infrastructure/django-background-tasks.git", rev ="2cbe547"} django-anymail = {extras = ["mailgun"], version = "8.2"} -django-nested-admin = "^3.3.3" +django-nested-admin = "^4.0.2" html5lib = "1.1" braintree = "4.17.1" python-stdnum = "^1.16" @@ -57,7 +57,7 @@ django-stubs = "^1.5" pre-commit = "2.16.0" ipython = "^7.17" factory-boy = "^3.0" -django-debug-toolbar = "^2.2" +django-debug-toolbar = "^4.2.0" flake8 = "^3.8.3" flake8-docstrings = "^1.5.0" freezegun = "^1.0.0" diff --git a/studio/settings.py b/studio/settings.py index 88c01ad5..cb22bb1c 100644 --- a/studio/settings.py +++ b/studio/settings.py @@ -506,6 +506,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'}, + }, } # Optional Sentry configuration @@ -678,5 +684,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 = 'donate' + # Maximum number of attempts for failing background tasks MAX_ATTEMPTS = 3 diff --git a/subscriptions/forms.py b/subscriptions/forms.py index 63076d68..8acb8d48 100644 --- a/subscriptions/forms.py +++ b/subscriptions/forms.py @@ -5,13 +5,11 @@ 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 stdnum.eu import vat import localflavor.exceptions - import looper.form_fields import looper.forms import looper.models @@ -48,14 +46,6 @@ 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? - class Meta: model = looper.models.Address fields = looper.models.Address.PUBLIC_FIELDS @@ -81,20 +71,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 +136,33 @@ 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() + price = forms.CharField(widget=forms.HiddenInput(), required=True) - def __init__(self, *args, **kwargs): - """Disable all the billing details fields. + # 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) - 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) +class AutomaticPaymentForm(PaymentForm): + pass class SelectPlanVariationForm(forms.Form): @@ -223,53 +189,13 @@ 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): +class PayExistingOrderForm(forms.Form): # TODO """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): +class ChangePaymentMethodForm(forms.Form): # TODO """Add full billing address to the change payment form.""" pass 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/queries.py b/subscriptions/queries.py index 67e9984c..3d4e8af9 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_id=user.id) | 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_id=user.id) | 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_id=user.id) | 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_id=user.id)).exists() diff --git a/subscriptions/signals.py b/subscriptions/signals.py index 994e214d..170e6351 100644 --- a/subscriptions/signals.py +++ b/subscriptions/signals.py @@ -3,12 +3,13 @@ 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 import django.db.models.signals as django_signals -from looper.models import Customer, Order +from looper.models import Order import looper.admin_log import looper.signals @@ -30,17 +31,40 @@ 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.""" + from looper.models import Customer + + 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, - ) + + my_log = logger.getChild('create_customer') + try: + customer = instance.customer + except Customer.DoesNotExist: + pass + else: + my_log.debug( + 'Newly created User %d already has a Customer %d, not creating new one', + instance.pk, + customer.pk, + ) + billing_address = customer.billing_address + my_log.info('Creating new billing address due to creation of user %s', instance.pk) + if not billing_address.pk: + billing_address.email = instance.email + billing_address.save() + return + + my_log.info('Creating new Customer due to creation of user %s', instance.pk) + with transaction.atomic(): + customer = Customer.objects.create(user=instance) + billing_address = customer.billing_address + billing_address.email = instance.email + billing_address.save() @receiver(django_signals.pre_save, sender=Order) diff --git a/subscriptions/tasks.py b/subscriptions/tasks.py index 697b97c8..8074ee6c 100644 --- a/subscriptions/tasks.py +++ b/subscriptions/tasks.py @@ -45,7 +45,7 @@ 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 + email = user.customer.billing_address.email or user.email assert ( email ), f'Cannot send notification about bank payment for subscription {subscription.pk}: no email' @@ -90,7 +90,7 @@ 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 + email = user.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 @@ -135,7 +135,7 @@ def send_mail_automatic_payment_performed(order_id: int, transaction_id: int): transaction = looper.models.Transaction.objects.get(pk=transaction_id) user = order.user customer = user.customer - email = customer.billing_email or user.email + 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. @@ -282,7 +282,7 @@ def send_mail_no_payment_method(order_id: int): user = order.user customer = user.customer - email = customer.billing_email or user.email + 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. 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 %} diff --git a/subscriptions/templates/subscriptions/emails/base.html b/subscriptions/templates/subscriptions/emails/base.html index d38f763d..cb8a3f66 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.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..019298d7 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.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..632e9c56 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. +{{ user.customer.billing_address.full_name|default: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/paypal_subscription_cancelled.html b/subscriptions/templates/subscriptions/emails/paypal_subscription_cancelled.html index f04dc6a3..8cf1c75d 100644 --- a/subscriptions/templates/subscriptions/emails/paypal_subscription_cancelled.html +++ b/subscriptions/templates/subscriptions/emails/paypal_subscription_cancelled.html @@ -7,7 +7,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.customer.billing_address.full_name user.full_name user.email %},

As you may have heard, Blender Studio's subscription system recently got a new shiny update, more on that in the blog post. diff --git a/subscriptions/templates/subscriptions/emails/paypal_subscription_cancelled.txt b/subscriptions/templates/subscriptions/emails/paypal_subscription_cancelled.txt index 0acd634e..9936b984 100644 --- a/subscriptions/templates/subscriptions/emails/paypal_subscription_cancelled.txt +++ b/subscriptions/templates/subscriptions/emails/paypal_subscription_cancelled.txt @@ -1,4 +1,4 @@ -{% load subscriptions %}Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %}, +{% load subscriptions %}Dear {% firstof user.customer.billing_address.full_name user.customer.billing_address.full_name user.full_name user.email %}, As you may have heard, Blender Studio's subscription system recently got a new shiny update, more on that in the blog post https://studio.blender.org/blog/subscription-system-update-2021/ . diff --git a/subscriptions/templates/subscriptions/emails/subscription_expired.html b/subscriptions/templates/subscriptions/emails/subscription_expired.html index d5d8710f..cbfc359e 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.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..9c2ed097 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.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/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/base.py b/subscriptions/tests/base.py index 6227ec92..870af099 100644 --- a/subscriptions/tests/base.py +++ b/subscriptions/tests/base.py @@ -8,7 +8,7 @@ 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 users.tests.util as util User = get_user_model() @@ -33,7 +33,7 @@ class BaseSubscriptionTestCase(TestCase): # 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,9 +43,9 @@ 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 def _mock_vies_response(self, is_valid=True, is_broken=False): @@ -319,7 +319,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 +341,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 +384,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 +397,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 +416,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 @@ -444,7 +444,7 @@ class BaseSubscriptionTestCase(TestCase): 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 +454,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) @@ -474,7 +474,7 @@ class BaseSubscriptionTestCase(TestCase): 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 +482,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) @@ -501,7 +501,7 @@ class BaseSubscriptionTestCase(TestCase): 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 +509,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) @@ -533,7 +533,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( @@ -551,7 +551,7 @@ class BaseSubscriptionTestCase(TestCase): 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..04ba6d5c 100644 --- a/subscriptions/tests/test_clock.py +++ b/subscriptions/tests/test_clock.py @@ -11,11 +11,8 @@ 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 @@ -31,7 +28,7 @@ class TestClock(BaseSubscriptionTestCase): # print('fake now:', mock_now.return_value) subscription = SubscriptionFactory( user=user, - payment_method__user_id=user.pk, + payment_method__customer_id=user.customer.pk, payment_method__recognisable_name='Test payment method', payment_method__gateway=Gateway.objects.get(name='braintree'), currency='USD', diff --git a/subscriptions/tests/test_forms.py b/subscriptions/tests/test_forms.py index 9f3caccb..71cfda50 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') @@ -223,7 +222,6 @@ class TestPaymentForm(BaseSubscriptionTestCase): 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') diff --git a/subscriptions/tests/test_queries.py b/subscriptions/tests/test_queries.py index 21219c7d..41d0ac00 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 diff --git a/subscriptions/urls.py b/subscriptions/urls.py index a22ba91e..cc71323e 100644 --- a/subscriptions/urls.py +++ b/subscriptions/urls.py @@ -57,7 +57,7 @@ urlpatterns = [ ), path( 'settings/billing-address/', - settings.BillingAddressView.as_view(), + looper_settings.BillingAddressView.as_view(), name='billing-address', ), path('settings/receipts/', looper_settings.settings_receipts, name='receipts'), diff --git a/subscriptions/views/join.py b/subscriptions/views/join.py index 8ebcd61b..d4ba4033 100644 --- a/subscriptions/views/join.py +++ b/subscriptions/views/join.py @@ -10,7 +10,7 @@ from django.shortcuts import redirect, get_object_or_404 from django.views.generic import FormView from looper.middleware import COUNTRY_CODE_SESSION_KEY -from looper.views.checkout import AbstractPaymentView, CheckoutView +from looper.views.checkout_braintree import AbstractPaymentView, CheckoutView import looper.gateways import looper.middleware import looper.models @@ -45,7 +45,7 @@ class _JoinMixin: 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() @@ -213,7 +213,6 @@ class ConfirmAndPayView(_JoinMixin, LoginRequiredMixin, FormView): 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 diff --git a/subscriptions/views/mixins.py b/subscriptions/views/mixins.py index c90d8dcc..28556600 100644 --- a/subscriptions/views/mixins.py +++ b/subscriptions/views/mixins.py @@ -35,7 +35,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.""" @@ -51,7 +53,7 @@ class SingleSubscriptionMixin(LoginRequiredMixin): 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'): + if not hasattr(request.user.customer, 'subscription_set'): return self.handle_no_permission() response = self.pre_dispatch(request, *args, **kwargs) if response: diff --git a/subscriptions/views/select_plan_variation.py b/subscriptions/views/select_plan_variation.py index 61207a8e..c8ca3c29 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_braintree import AbstractPaymentView import looper.gateways import looper.middleware import looper.models diff --git a/subscriptions/views/settings.py b/subscriptions/views/settings.py index c8793255..bafff226 100644 --- a/subscriptions/views/settings.py +++ b/subscriptions/views/settings.py @@ -1,8 +1,6 @@ """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 @@ -10,10 +8,11 @@ from django.urls import reverse_lazy, reverse from django.views.generic import UpdateView, FormView import looper.models +import looper.views.checkout_braintree import looper.views.settings +import looper.views.settings_braintree from subscriptions.forms import ( - BillingAddressForm, CancelSubscriptionForm, ChangePaymentMethodForm, PayExistingOrderForm, @@ -26,25 +25,6 @@ logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) -class BillingAddressView(LoginRequiredMixin, UpdateView): - """Combine looper's Customer and Address into a billing address.""" - - 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.""" @@ -67,7 +47,7 @@ class CancelSubscriptionView(SingleSubscriptionMixin, FormView): return super().form_valid(form) -class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView): +class PaymentMethodChangeView(looper.views.settings_braintree.PaymentMethodChangeView): """Use the Braintree drop-in UI to switch a subscription's payment method.""" template_name = 'subscriptions/payment_method_change.html' @@ -98,7 +78,7 @@ class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView): return super().form_invalid(form) -class PayExistingOrderView(looper.views.checkout.CheckoutExistingOrderView): +class PayExistingOrderView(looper.views.checkout_braintree.CheckoutExistingOrderView): """Override looper's view with our forms.""" # Redirect to LOGIN_URL instead of raising an exception @@ -111,7 +91,7 @@ class PayExistingOrderView(looper.views.checkout.CheckoutExistingOrderView): """Prefill the payment amount and missing form data, if any.""" initial = { 'price': self.order.price.decimals_string, - 'email': self.customer.billing_email, + 'email': self.customer.billing_address.email, } # Only set initial values if they aren't already saved to the billing address. @@ -137,7 +117,7 @@ class PayExistingOrderView(looper.views.checkout.CheckoutExistingOrderView): 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( + return super(looper.views.checkout_braintree.CheckoutExistingOrderView, self).dispatch( request, *args, **kwargs ) diff --git a/subscriptions/views/teams.py b/subscriptions/views/teams.py index 66dc0c7b..11a8ace6 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_braintree import AbstractPaymentView import looper.models import subscriptions.models diff --git a/subscriptions/views/tests/test_join.py b/subscriptions/views/tests/test_join.py index 5e88f0dc..a337039b 100644 --- a/subscriptions/views/tests/test_join.py +++ b/subscriptions/views/tests/test_join.py @@ -12,7 +12,7 @@ from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4, SINGAPORE_ from looper.money import Money import looper.models -from common.tests.factories.subscriptions import create_customer_with_billing_address +from looper.tests.factories import create_customer_with_billing_address from common.tests.factories.users import UserFactory from subscriptions.tests.base import BaseSubscriptionTestCase import subscriptions.tasks @@ -445,7 +445,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): 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)) @@ -524,7 +524,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): 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)) @@ -607,7 +607,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): self._assert_done_page_displayed(response) - subscription = user.subscription_set.first() + subscription = user.customer.subscription_set.first() order = subscription.latest_order() self.assertEqual(subscription.status, 'active') self.assertEqual(subscription.price, Money('EUR', 990)) @@ -655,7 +655,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): self._assert_done_page_displayed(response) - subscription = user.subscription_set.first() + subscription = user.customer.subscription_set.first() order = subscription.latest_order() self.assertEqual(subscription.status, 'active') self.assertEqual(subscription.price, Money('EUR', 9000)) @@ -693,7 +693,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): self._assert_done_page_displayed(response) - subscription = user.subscription_set.first() + subscription = user.customer.subscription_set.first() self.assertEqual(subscription.status, 'active') self.assertEqual(subscription.price, Money('EUR', 1490)) self.assertEqual(subscription.tax, Money('EUR', 0)) diff --git a/subscriptions/views/tests/test_receipt_pdf.py b/subscriptions/views/tests/test_receipt_pdf.py index 7feedde3..d06dbc6e 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. diff --git a/subscriptions/views/tests/test_select_plan_variation.py b/subscriptions/views/tests/test_select_plan_variation.py index 7e12b773..d17d6584 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. diff --git a/subscriptions/views/tests/test_settings.py b/subscriptions/views/tests/test_settings.py index f9a7d266..138150aa 100644 --- a/subscriptions/views/tests/test_settings.py +++ b/subscriptions/views/tests/test_settings.py @@ -4,9 +4,9 @@ from django.urls import reverse from looper.models import PaymentMethod, PaymentMethodAuthentication, Gateway from looper.money import Money +from looper.tests.factories import SubscriptionFactory from common.tests.factories.users import UserFactory -from common.tests.factories.subscriptions import SubscriptionFactory from subscriptions.tests.base import BaseSubscriptionTestCase import subscriptions.tasks @@ -31,7 +31,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase): user = UserFactory() self.client.force_login(user) - url = reverse('subscriptions:billing-address') + url = reverse('user-settings-billing') response = self.client.post(url, full_billing_address_data) # Check that the redirect on success happened @@ -51,15 +51,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(reverse('user-settings-billing'), {}) self.assertEqual(response.status_code, 200) self.assertContains(response, 'errorlist') @@ -72,7 +71,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase): data = { 'email': 'new@example.com', } - response = self.client.post(reverse('subscriptions:billing-address'), data) + response = self.client.post(reverse('user-settings-billing'), data) self.assertEqual(response.status_code, 200) self.assertContains(response, 'errorlist') @@ -85,7 +84,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase): data = { 'full_name': 'New Full Name', } - response = self.client.post(reverse('subscriptions:billing-address'), data) + response = self.client.post(reverse('user-settings-billing'), data) self.assertEqual(response.status_code, 200) self.assertContains(response, 'errorlist') @@ -103,8 +102,8 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase): 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) @@ -139,8 +138,8 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase): 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, + customer=self.user.customer, + payment_method__customer_id=self.user.customer.pk, payment_method__gateway=braintree, ) self.assertEqual(PaymentMethod.objects.count(), 1) @@ -173,8 +172,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 +197,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 +217,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', ) @@ -244,8 +243,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase): 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', ) @@ -264,8 +263,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase): 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', ) @@ -284,8 +283,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase): def test_invalid_missing_required_form_data(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', ) @@ -315,8 +314,8 @@ 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, + 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), 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/tasks.py b/users/tasks.py index d1948dd4..5528207c 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 diff --git a/users/templates/users/settings/billing.html b/users/templates/users/settings/billing.html index 3f355947..f7380339 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" %} diff --git a/users/tests/test_tasks.py b/users/tests/test_tasks.py index 7f1a92a5..39307d08 100644 --- a/users/tests/test_tasks.py +++ b/users/tests/test_tasks.py @@ -5,14 +5,15 @@ 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 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 import users.tasks as tasks import users.tests.util as util -- 2.30.2 From 763d46ac3ae5b87225353849255ceaf7a923d2e9 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 7 Jun 2024 16:14:30 +0200 Subject: [PATCH 02/49] Remove old "blender-cloud-" receipt URL --- subscriptions/urls.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/subscriptions/urls.py b/subscriptions/urls.py index cc71323e..d8953c1e 100644 --- a/subscriptions/urls.py +++ b/subscriptions/urls.py @@ -1,5 +1,4 @@ from django.urls import path, re_path -from django.views.generic import RedirectView from looper.views import settings as looper_settings @@ -71,10 +70,5 @@ urlpatterns = [ looper_settings.ReceiptPDFView.as_view(), name='receipt-pdf', ), - # TODO(anna): remove this once blender-cloud-1243.pdf no longer appear in access logs. - path( - 'settings/receipts/blender-cloud-.pdf', - RedirectView.as_view(pattern_name='subscriptions:receipt-pdf', permanent=True), - ), path('teams/', TeamsLanding.as_view(), name='teams-landing'), ] -- 2.30.2 From 8a6ac223d6b4ec5150ce74c2cbbef8efa15c5291 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 7 Jun 2024 16:34:27 +0200 Subject: [PATCH 03/49] Working checkout --- .env.example | 2 - common/context_processors.py | 1 - playbooks/templates/dotenv | 2 - studio/settings.py | 4 +- subscriptions/forms.py | 27 +- subscriptions/signals.py | 14 +- .../subscriptions/join/billing_address.html | 3 +- .../subscriptions/join/payment_method.html | 73 ---- .../subscriptions/pay_existing_order.html | 52 --- .../subscriptions/payment_method_change.html | 63 ---- subscriptions/urls.py | 9 +- subscriptions/views/join.py | 320 +++++++----------- 12 files changed, 154 insertions(+), 416 deletions(-) delete mode 100644 subscriptions/templates/subscriptions/join/payment_method.html delete mode 100644 subscriptions/templates/subscriptions/pay_existing_order.html delete mode 100644 subscriptions/templates/subscriptions/payment_method_change.html diff --git a/.env.example b/.env.example index 8cbc61bc..476a0f00 100644 --- a/.env.example +++ b/.env.example @@ -49,8 +49,6 @@ 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= diff --git a/common/context_processors.py b/common/context_processors.py index 019c9b27..0d3021a4 100644 --- a/common/context_processors.py +++ b/common/context_processors.py @@ -30,5 +30,4 @@ def extra_context(request: HttpRequest) -> Dict[str, str]: 'ADMIN_MAIL': settings.ADMIN_MAIL, 'STORE_PRODUCT_URL': settings.STORE_PRODUCT_URL, 'STORE_MANAGE_URL': settings.STORE_MANAGE_URL, - 'GOOGLE_RECAPTCHA_SITE_KEY': settings.GOOGLE_RECAPTCHA_SITE_KEY, } diff --git a/playbooks/templates/dotenv b/playbooks/templates/dotenv index d79d2c68..dee31b93 100644 --- a/playbooks/templates/dotenv +++ b/playbooks/templates/dotenv @@ -54,6 +54,4 @@ MAILGUN_API_KEY= MAILGUN_WEBHOOK_SIGNING_KEY= MAILGUN_WEBHOOK_SECRET= -GOOGLE_RECAPTCHA_SITE_KEY= -GOOGLE_RECAPTCHA_SECRET_KEY= GOOGLE_ANALYTICS_TRACKING_ID= diff --git a/studio/settings.py b/studio/settings.py index cb22bb1c..faea6e2e 100644 --- a/studio/settings.py +++ b/studio/settings.py @@ -638,8 +638,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': { @@ -692,7 +690,7 @@ STRIPE_OFF_SESSION_PAYMENT_METHOD_TYPES = [ 'paypal', ] -STRIPE_CHECKOUT_SUBMIT_TYPE = 'donate' +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 8acb8d48..a9189461 100644 --- a/subscriptions/forms.py +++ b/subscriptions/forms.py @@ -8,6 +8,7 @@ from django.forms.fields import Field 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 @@ -46,9 +47,11 @@ REQUIRED_FIELDS = { class BillingAddressForm(forms.ModelForm): + """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. @@ -71,8 +74,24 @@ class BillingAddressForm(forms.ModelForm): def __init__(self, *args, **kwargs): """Load additional model data from Customer and set form placeholders.""" + self.request = kwargs.pop('request') + self.customer = self.request.user.customer + self.plan_variation = kwargs.pop('plan_variation') + super().__init__(*args, **kwargs) + # Only preset country when it's not already selected by the customer + geoip_country = self.request.session.get(COUNTRY_CODE_SESSION_KEY) + if geoip_country and (not self.instance.country): + self.initial['country'] = geoip_country + + # Only set initial values if they aren't already saved to the billing address. + # Initial values always override form data, which leads to confusing issues with views. + if not self.instance.full_name: + # Fall back to user's full name, if no full name set already in the billing address: + if self.request.user.full_name: + self.initial['full_name'] = self.request.user.full_name + # Set placeholder values on all form fields for field_name, field in self.fields.items(): placeholder = BILLING_DETAILS_PLACEHOLDERS.get(field_name) @@ -152,7 +171,7 @@ class PaymentForm(BillingAddressForm): but are still used by the payment flow. """ - gateway = looper.form_fields.GatewayChoiceField() + # Price value is a decimal number in major units of selected currency. price = forms.CharField(widget=forms.HiddenInput(), required=True) # These are used when a payment fails, so that the next attempt to pay can reuse @@ -161,10 +180,6 @@ class PaymentForm(BillingAddressForm): order_pk = forms.CharField(widget=forms.HiddenInput(), required=False) -class AutomaticPaymentForm(PaymentForm): - pass - - class SelectPlanVariationForm(forms.Form): """Form used in the plan selector.""" diff --git a/subscriptions/signals.py b/subscriptions/signals.py index 170e6351..12682b3f 100644 --- a/subscriptions/signals.py +++ b/subscriptions/signals.py @@ -78,7 +78,11 @@ 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 + if not user: + logger.error('Cannot grand role to an account-less customer pk=%s', sender.customer_id) + return + users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription') @receiver(looper.signals.subscription_activated) @@ -89,8 +93,12 @@ 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 + if not user: + logger.error('Cannot grand role to an account-less customer pk=%s', sender.customer_id) + return + users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription') + users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_subscriber') if not hasattr(sender, 'team'): return diff --git a/subscriptions/templates/subscriptions/join/billing_address.html b/subscriptions/templates/subscriptions/join/billing_address.html index 01c24abe..1ecc9fed 100644 --- a/subscriptions/templates/subscriptions/join/billing_address.html +++ b/subscriptions/templates/subscriptions/join/billing_address.html @@ -30,13 +30,14 @@ {% include "subscriptions/components/billing_address_form.html" %}

Required fields are marked with (*).

+ {{ form.price }}
{% if messages %} {% for message in messages %} -

+

{{ message }}

{% endfor %} 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

-
-
-
- {% csrf_token %} - - {% url 'subscriptions:join-billing-details' plan_variation_id=current_plan_variation.pk as billing_url %} - {% if current_plan_variation.plan.team_properties %} - {% url "subscriptions:join-team" current_plan_variation.pk as plan_url %} - {% else %} - {% url "subscriptions:join" current_plan_variation.pk as plan_url %} - {% endif %} - - {% with form|add_form_classes as form %} -
-
-
- {% include "subscriptions/components/payment_form.html" %} -
- - {% if GOOGLE_RECAPTCHA_SITE_KEY %} -
-
- {% endif %} -
-
-
- {% if messages %} - {% for message in messages %} -

- {{ message }} -

- {% endfor %} - {% endif %} -
- - {% include "subscriptions/components/selected_plan_info.html" with back_url=plan_url %} - -
-
-

Billing:

-
-
-
- {% include "subscriptions/components/billing_address_form_readonly.html"%} -
-

(Change)

-
-
-
- - {% include 'subscriptions/components/total.html' with button_text="Confirm and Pay" %} -
- {% endwith %} - -{% endblock content %} - -{% block scripts %} - {% javascript "subscriptions" %} - - {% include "looper/scripts.html" with with_recaptcha=True %} -{% endblock scripts %} 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 }}

-
-
-
{% csrf_token %} -
-
-
- {% with form|add_form_classes as form %} - Change billing details -
- {% include "subscriptions/components/billing_address_form_readonly.html" %} -
-
- {% include "subscriptions/components/payment_form.html" %} -
- {% endwith %} -
-
- - {# will be enabled when payment gateways are initialized successfully #} -
- -
-
- -
-
-
- {% 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

-
-
- - - {% if current_payment_method %} -

- {{ current_payment_method.recognisable_name }} is used as payment method. - Feel free to change it below. -

- {% else %} - - {% endif %} -
-
{% csrf_token %} - {% with form|add_form_classes as form %} -
- change billing details -
- {% include "subscriptions/components/billing_address_form_readonly.html" %} -
-
- {% include "subscriptions/components/payment_form.html" %} -
-
-
-
- Cancel -
-
- -
-
- {% endwith %} - -
-{% endblock settings %} - -{% block scripts %} - {{ block.super }} - {% javascript "subscriptions" %} - {% include "looper/scripts.html" %} -{% endblock scripts %} diff --git a/subscriptions/urls.py b/subscriptions/urls.py index d8953c1e..92dd79a7 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(), diff --git a/subscriptions/views/join.py b/subscriptions/views/join.py index d4ba4033..69e4565e 100644 --- a/subscriptions/views/join.py +++ b/subscriptions/views/join.py @@ -2,22 +2,21 @@ 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_braintree 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,21 +26,15 @@ 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, + # FIXME(anna): this view uses some functionality of CheckoutStripeView, # 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 + _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 @@ -51,87 +44,114 @@ class _JoinMixin: return existing_subscriptions.first() 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.gateway = looper.models.Gateway.default() + self.user = request.user + self.customer = self.user.customer + self.subscription = self._get_existing_subscription() 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 + 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.""" + """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 gateway_from_form(self, form) -> looper.models.Gateway: + """TODO support the usual bank transfer payments.""" + name = 'bank' in form and 'bank' or 'stripe' + self.gateway = looper.models.Gateway.objects.get(name=name) + return self.gateway + + def _get_or_create_subscription( + self, gateway: looper.models.Gateway + ) -> looper.models.Subscription: + subscription = self.subscription + is_new = False + if not subscription: + subscription = looper.models.Subscription() + is_new = True + logger.debug('Creating an new subscription for %s, %s', gateway) + collection_method = self.plan_variation.collection_method + supported = set(gateway.provider.supported_collection_methods) + if collection_method not in supported: + # FIXME(anna): this breaks plan selector because collection method + # might not match the one selected by the customer. + collection_method = supported.pop() + + with transaction.atomic(): + subscription.plan = self.plan_variation.plan + subscription.user = self.user + # Currency must be set before the price, in case it was changed + subscription.currency = self.plan_variation.currency + subscription.price = self.plan_variation.price + subscription.interval_unit = self.plan_variation.interval_unit + subscription.interval_length = self.plan_variation.interval_length + subscription.collection_method = collection_method + subscription.save() + + # Configure the team if this is a team plan + if hasattr(subscription.plan, 'team_properties'): + team_properties = subscription.plan.team_properties + team, team_is_new = subscriptions.models.Team.objects.get_or_create( + subscription=subscription, + seats=team_properties.seats, + ) + logger.info( + '%s a team for subscription pk=%r, seats: %s', + team_is_new and 'Created' or 'Updated', + subscription.pk, + team.seats and team.seats or 'unlimited', + ) + + logger.debug('%s subscription pk=%r', is_new and 'Created' or 'Updated', subscription.pk) + return subscription + + def form_invalid(self, form, *args, **kwargs): + """Temporarily log all validation errors.""" + logger.exception('Validation error in ConfirmAndPayView: %s', form.errors) + return super().form_invalid(form, *args, **kwargs) + def form_valid(self, form): - """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 @@ -161,136 +181,11 @@ class BillingDetailsView(_JoinMixin, LoginRequiredMixin, FormView): new_taxable = looper.taxes.Taxable(self.plan_variation.price, *new_tax) if old_taxable != new_taxable: # If price has changed, stay on the same page and display a notification - messages.add_message(self.request, messages.INFO, msg) return self.form_invalid(form) - return redirect( - 'subscriptions:join-confirm-and-pay', plan_variation_id=self.plan_variation.pk - ) - - -class ConfirmAndPayView(_JoinMixin, LoginRequiredMixin, FormView): - """Display the payment form and handle the payment flow.""" - - raise_exception = True - template_name = 'subscriptions/join/payment_method.html' - form_class = PaymentForm - - log = logger - gateway: looper.models.Gateway - - # FIXME(anna): this view uses some functionality of AbstractPaymentView/CheckoutView, - # but cannot directly inherit from them. - gateway_from_form = AbstractPaymentView.gateway_from_form - - _check_customer_ip_address = AbstractPaymentView._check_customer_ip_address - _check_payment_method_nonce = CheckoutView._check_payment_method_nonce - _check_recaptcha = CheckoutView._check_recaptcha - - _charge_if_supported = CheckoutView._charge_if_supported - _fetch_or_create_order = CheckoutView._fetch_or_create_order - - def get_form_class(self): - """Override the payment form based on the selected plan variation, before validation.""" - if self.plan_variation.collection_method == 'automatic': - return AutomaticPaymentForm - return PaymentForm - - def get_initial(self) -> dict: - """Prefill default payment gateway, country and selected plan options.""" - product_type = self.plan_variation.plan.product.type - customer_tax = self.customer.get_tax(product_type=product_type) - taxable = looper.taxes.Taxable(self.plan_variation.price, *customer_tax) - return { - **super().get_initial(), - 'price': taxable.price.decimals_string, - 'gateway': self.gateway.name, - } - - def get_context_data(self, **kwargs) -> dict: - """Add an extra form and gateway's client token.""" - currency = self.get_currency() - ctx = { - **super().get_context_data(**kwargs), - 'current_plan_variation': self.plan_variation, - 'subscription': self.subscription, - } - return ctx - - def _get_or_create_subscription( - self, gateway: looper.models.Gateway, payment_method: looper.models.PaymentMethod - ) -> looper.models.Subscription: - subscription = self._get_existing_subscription() - is_new = False - if not subscription: - subscription = looper.models.Subscription() - is_new = True - self.log.debug('Creating an new subscription for %s, %s', gateway, payment_method) - collection_method = self.plan_variation.collection_method - supported = set(gateway.provider.supported_collection_methods) - if collection_method not in supported: - # FIXME(anna): this breaks plan selector because collection method - # might not match the one selected by the customer. - collection_method = supported.pop() - - with transaction.atomic(): - subscription.plan = self.plan_variation.plan - subscription.user = self.user - subscription.payment_method = payment_method - # Currency must be set before the price, in case it was changed - subscription.currency = self.plan_variation.currency - subscription.price = self.plan_variation.price - subscription.interval_unit = self.plan_variation.interval_unit - subscription.interval_length = self.plan_variation.interval_length - subscription.collection_method = collection_method - subscription.save() - - # Configure the team if this is a team plan - if hasattr(subscription.plan, 'team_properties'): - team_properties = subscription.plan.team_properties - team, team_is_new = subscriptions.models.Team.objects.get_or_create( - subscription=subscription, - seats=team_properties.seats, - ) - self.log.info( - '%s a team for subscription pk=%r, seats: %s', - team_is_new and 'Created' or 'Updated', - subscription.pk, - team.seats and team.seats or 'unlimited', - ) - - self.log.debug('%s subscription pk=%r', is_new and 'Created' or 'Updated', subscription.pk) - return subscription - - def form_invalid(self, form): - """Temporarily log all validation errors.""" - logger.exception('Validation error in ConfirmAndPayView: %s', form.errors) - return super().form_invalid(form) - - def form_valid(self, form): - """Handle valid form data. - - Confirm and Pay view doesn't update the billing address, - only displays it for use by payment flow and validates it on submit. - The billing address is assumed to be saved at the previous step. - """ - assert self.request.method == 'POST' - - response = self._check_recaptcha(form) - if response: - return response - - response = self._check_customer_ip_address(form) - if response: - return response - gateway = self.gateway_from_form(form) - 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) + subscription = self._get_or_create_subscription(gateway) # Update the tax info stored on the subscription subscription.update_tax() @@ -300,12 +195,31 @@ class ConfirmAndPayView(_JoinMixin, LoginRequiredMixin, FormView): # 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' + form.add_error('price', msg) + messages.warning(self.request, msg) return self.form_invalid(form) + success_url = self.request.build_absolute_uri( + reverse( + 'looper:stripe_success', + kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'}, + ) + ) + # we have to do it to avoid uri-encoding of curly braces, + # otherwise stripe doesn't do the template substitution + success_url = success_url.replace('CHECKOUT_SESSION_ID', '{CHECKOUT_SESSION_ID}', 1) + cancel_url = self.request.build_absolute_uri(self.request.get_full_path()) + if not gateway.provider.supports_transactions: # Trigger an email with instructions about manual payment: subscription_created_needs_payment.send(sender=subscription) - response = self._charge_if_supported(form, gateway, order) - return response + 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) -- 2.30.2 From 2b29a7ab2adfb8bc85fe3e3f63068b37ede4c7fc Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 7 Jun 2024 18:12:34 +0200 Subject: [PATCH 04/49] Can change payment method --- subscriptions/forms.py | 6 --- .../templates/subscriptions/manage.html | 8 ++-- subscriptions/urls.py | 10 ++++- subscriptions/views/select_plan_variation.py | 4 +- subscriptions/views/settings.py | 40 +++++-------------- subscriptions/views/teams.py | 4 +- 6 files changed, 28 insertions(+), 44 deletions(-) diff --git a/subscriptions/forms.py b/subscriptions/forms.py index a9189461..9f75fc2c 100644 --- a/subscriptions/forms.py +++ b/subscriptions/forms.py @@ -210,12 +210,6 @@ class PayExistingOrderForm(forms.Form): # TODO price = forms.CharField(widget=forms.HiddenInput(), required=True) -class ChangePaymentMethodForm(forms.Form): # TODO - """Add full billing address to the change payment form.""" - - pass - - class CancelSubscriptionForm(forms.Form): """Confirm cancellation of a subscription.""" 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 %}
- - Change - +
+ {% csrf_token %} + +
{% endif %}
diff --git a/subscriptions/urls.py b/subscriptions/urls.py index 92dd79a7..26878958 100644 --- a/subscriptions/urls.py +++ b/subscriptions/urls.py @@ -41,9 +41,17 @@ urlpatterns = [ ), path( 'subscription//payment-method/change/', - settings.PaymentMethodChangeView.as_view(), + looper_settings.PaymentMethodChangeView.as_view( + success_url='subscriptions:payment-method-change-done', + cancel_url='user-settings-billing', # FIXME: go back to subscription manage instead + ), 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(), diff --git a/subscriptions/views/select_plan_variation.py b/subscriptions/views/select_plan_variation.py index c8ca3c29..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_braintree 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 bafff226..770d54c2 100644 --- a/subscriptions/views/settings.py +++ b/subscriptions/views/settings.py @@ -1,6 +1,7 @@ """Views handling subscription management.""" import logging +from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin from django.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 @@ -14,7 +15,6 @@ import looper.views.settings_braintree from subscriptions.forms import ( CancelSubscriptionForm, - ChangePaymentMethodForm, PayExistingOrderForm, TeamForm, ) @@ -47,35 +47,17 @@ class CancelSubscriptionView(SingleSubscriptionMixin, FormView): return super().form_valid(form) -class PaymentMethodChangeView(looper.views.settings_braintree.PaymentMethodChangeView): - """Use the Braintree drop-in UI to switch a subscription's payment method.""" +class PaymentMethodChangeDoneView(looper.views.settings.PaymentMethodChangeDoneView): + """Change payment method in response to a successful payment setup.""" - template_name = 'subscriptions/payment_method_change.html' - form_class = ChangePaymentMethodForm - success_url = reverse_lazy('user-settings-billing') - - 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) + @property + def success_url(self): + """Return to this subscription's manage page.""" + messages.add_message(self.request, messages.INFO, 'Payment method updated') + return reverse( + 'subscriptions:manage', + kwargs={'subscription_id': self.kwargs['subscription_id']}, + ) class PayExistingOrderView(looper.views.checkout_braintree.CheckoutExistingOrderView): diff --git a/subscriptions/views/teams.py b/subscriptions/views/teams.py index 11a8ace6..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_braintree 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 -- 2.30.2 From cefff99876dc80c923d164a40f03c338bd2a0776 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 7 Jun 2024 18:18:22 +0200 Subject: [PATCH 05/49] Fix /join/ --- subscriptions/views/join.py | 2 +- subscriptions/views/mixins.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/subscriptions/views/join.py b/subscriptions/views/join.py index 69e4565e..b6db9d48 100644 --- a/subscriptions/views/join.py +++ b/subscriptions/views/join.py @@ -107,7 +107,7 @@ class JoinView(LoginRequiredMixin, FormView): subscription = self.subscription is_new = False if not subscription: - subscription = looper.models.Subscription() + subscription = looper.models.Subscription(customer=self.customer) is_new = True logger.debug('Creating an new subscription for %s, %s', gateway) collection_method = self.plan_variation.collection_method diff --git a/subscriptions/views/mixins.py b/subscriptions/views/mixins.py index 28556600..ac43f43e 100644 --- a/subscriptions/views/mixins.py +++ b/subscriptions/views/mixins.py @@ -53,7 +53,7 @@ class SingleSubscriptionMixin(LoginRequiredMixin): The AnonymousUser instance doesn't have a 'subscriptions' property, but login checking only happens in the super().dispatch() call. """ - if not hasattr(request.user.customer, 'subscription_set'): + if not hasattr(request.user, 'customer'): return self.handle_no_permission() response = self.pre_dispatch(request, *args, **kwargs) if response: -- 2.30.2 From 03763ae98272ea7b1ceea9d7efc0fa9a6c2e9ee2 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 7 Jun 2024 18:18:38 +0200 Subject: [PATCH 06/49] Remove debug messages --- subscriptions/views/settings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/subscriptions/views/settings.py b/subscriptions/views/settings.py index 770d54c2..5aadb79e 100644 --- a/subscriptions/views/settings.py +++ b/subscriptions/views/settings.py @@ -1,7 +1,6 @@ """Views handling subscription management.""" import logging -from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin from django.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 @@ -53,7 +52,6 @@ class PaymentMethodChangeDoneView(looper.views.settings.PaymentMethodChangeDoneV @property def success_url(self): """Return to this subscription's manage page.""" - messages.add_message(self.request, messages.INFO, 'Payment method updated') return reverse( 'subscriptions:manage', kwargs={'subscription_id': self.kwargs['subscription_id']}, -- 2.30.2 From d7affd44885a3bc951d721d58ac9740e33a7d98f Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 7 Jun 2024 18:40:43 +0200 Subject: [PATCH 07/49] Upgrade responses to make use of _recorder --- poetry.lock | 32 ++++++++++++++++---------------- pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/poetry.lock b/poetry.lock index 36b6cb24..b9896616 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2344,7 +2344,7 @@ resolved_reference = "419abd659ae5a4a6cb6ea9b54aa4bde17aefeb5b" name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2444,21 +2444,21 @@ rlpycairo = ["rlPyCairo (>=0.0.5)"] [[package]] name = "requests" -version = "2.28.1" +version = "2.32.3" description = "Python HTTP for Humans." category = "main" optional = false -python-versions = ">=3.7, <4" +python-versions = ">=3.8" files = [ - {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, - {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<3" +charset-normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<1.27" +urllib3 = ">=1.21.1,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] @@ -2516,23 +2516,23 @@ requests = ">=2.0.1,<3.0.0" [[package]] name = "responses" -version = "0.12.1" +version = "0.24.1" description = "A utility library for mocking out the `requests` Python library." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.8" files = [ - {file = "responses-0.12.1-py2.py3-none-any.whl", hash = "sha256:ef265bd3200bdef5ec17912fc64a23570ba23597fd54ca75c18650fa1699213d"}, - {file = "responses-0.12.1.tar.gz", hash = "sha256:2e5764325c6b624e42b428688f2111fea166af46623cb0127c05f6afb14d3457"}, + {file = "responses-0.24.1-py3-none-any.whl", hash = "sha256:a2b43f4c08bfb9c9bd242568328c65a34b318741d3fab884ac843c5ceeb543f9"}, + {file = "responses-0.24.1.tar.gz", hash = "sha256:b127c6ca3f8df0eb9cc82fd93109a3007a86acb24871834c47b77765152ecf8c"}, ] [package.dependencies] -requests = ">=2.0" -six = "*" -urllib3 = ">=1.25.10" +pyyaml = "*" +requests = ">=2.30.0,<3.0" +urllib3 = ">=1.25.10,<3.0" [package.extras] -tests = ["coverage (>=3.7.1,<6.0.0)", "flake8", "pytest (>=4.6)", "pytest (>=4.6,<5.0)", "pytest-cov", "pytest-localserver"] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"] [[package]] name = "rsa" @@ -2947,4 +2947,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "8ca93ee62a2b6e396676492b70085f90ca192e5a84d742f032e884c3ef691d41" +content-hash = "04753b00e94c929ee66201ce0b11c3119d6ef150ba8e0ca2a6f6c5cf82f49917" diff --git a/pyproject.toml b/pyproject.toml index c1571aff..ca3702e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ markupsafe = "^1.1.1" meilisearch = "^0.18.0" django-taggit = "^1.3.0" boto3 = "1.18.56" -responses = "^0.12.0" +responses = "^0.24.0" attrs = "^19.3.0" Flask = "1.0.3" bleach = "^3.2.1" -- 2.30.2 From 0aa1a7afd6d5aefbab5144e295ffb662bdb7fa3b Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 10 Jun 2024 13:46:58 +0200 Subject: [PATCH 08/49] Test parially passing for the Join page --- stripe_create_checkout_session.yaml | 59 +++++++++++ subscriptions/views/join.py | 39 ++++--- subscriptions/views/tests/test_join.py | 134 +++++++++++++------------ 3 files changed, 156 insertions(+), 76 deletions(-) create mode 100644 stripe_create_checkout_session.yaml diff --git a/stripe_create_checkout_session.yaml b/stripe_create_checkout_session.yaml new file mode 100644 index 00000000..937abfa4 --- /dev/null +++ b/stripe_create_checkout_session.yaml @@ -0,0 +1,59 @@ +responses: +- response: + auto_calculate_content_length: false + body: "{\n \"id\": \"cus_QFaxRrT6ZbD9NN\",\n \"object\": \"customer\",\n \"\ + address\": null,\n \"balance\": 0,\n \"created\": 1717778124,\n \"currency\"\ + : null,\n \"default_source\": null,\n \"delinquent\": false,\n \"description\"\ + : null,\n \"discount\": null,\n \"email\": \"my.billing.email@example.com\"\ + ,\n \"invoice_prefix\": \"046F772E\",\n \"invoice_settings\": {\n \"custom_fields\"\ + : null,\n \"default_payment_method\": null,\n \"footer\": null,\n \"\ + rendering_options\": null\n },\n \"livemode\": false,\n \"metadata\": {},\n\ + \ \"name\": \"New Full Name\",\n \"phone\": null,\n \"preferred_locales\"\ + : [],\n \"shipping\": null,\n \"tax_exempt\": \"none\",\n \"test_clock\"\ + : null\n}" + content_type: text/plain + method: POST + status: 200 + url: https://api.stripe.com/v1/customers +- response: + auto_calculate_content_length: false + body: "{\n \"id\": \"cs_test_a1hoP4Yj4ZmfghAwGoUtWJngVt1XreEVLGAj2n7U5o9BlvqhnDimuA07zh\"\ + ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\ + allow_promotion_codes\": null,\n \"amount_subtotal\": 990,\n \"amount_total\"\ + : 990,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\":\ + \ null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\ + \ \"cancel_url\": \"http://testserver/join/plan-variation/2/billing/\",\n \ + \ \"client_reference_id\": null,\n \"client_secret\": null,\n \"consent\"\ + : null,\n \"consent_collection\": null,\n \"created\": 1717778125,\n \"currency\"\ + : \"eur\",\n \"currency_conversion\": null,\n \"custom_fields\": [],\n \"\ + custom_text\": {\n \"after_submit\": null,\n \"shipping_address\": null,\n\ + \ \"submit\": null,\n \"terms_of_service_acceptance\": null\n },\n \"\ + customer\": \"cus_QFaxRrT6ZbD9NN\",\n \"customer_creation\": null,\n \"customer_details\"\ + : {\n \"address\": null,\n \"email\": \"my.billing.email@example.com\"\ + ,\n \"name\": null,\n \"phone\": null,\n \"tax_exempt\": \"none\",\n\ + \ \"tax_ids\": null\n },\n \"customer_email\": null,\n \"expires_at\"\ + : 1717864524,\n \"invoice\": null,\n \"invoice_creation\": {\n \"enabled\"\ + : false,\n \"invoice_data\": {\n \"account_tax_ids\": null,\n \"\ + custom_fields\": null,\n \"description\": null,\n \"footer\": null,\n\ + \ \"issuer\": null,\n \"metadata\": {},\n \"rendering_options\"\ + : null\n }\n },\n \"livemode\": false,\n \"locale\": null,\n \"metadata\"\ + : {},\n \"mode\": \"payment\",\n \"payment_intent\": null,\n \"payment_link\"\ + : null,\n \"payment_method_collection\": \"if_required\",\n \"payment_method_configuration_details\"\ + : null,\n \"payment_method_options\": {\n \"card\": {\n \"request_three_d_secure\"\ + : \"automatic\"\n }\n },\n \"payment_method_types\": [\n \"card\",\n\ + \ \"link\",\n \"paypal\"\n ],\n \"payment_status\": \"unpaid\",\n \"\ + phone_number_collection\": {\n \"enabled\": false\n },\n \"recovered_from\"\ + : null,\n \"saved_payment_method_options\": {\n \"allow_redisplay_filters\"\ + : [\n \"always\"\n ],\n \"payment_method_remove\": null,\n \"\ + payment_method_save\": null\n },\n \"setup_intent\": null,\n \"shipping_address_collection\"\ + : null,\n \"shipping_cost\": null,\n \"shipping_details\": null,\n \"shipping_options\"\ + : [],\n \"status\": \"open\",\n \"submit_type\": \"pay\",\n \"subscription\"\ + : null,\n \"success_url\": \"http://testserver/looper/stripe_success/1/{CHECKOUT_SESSION_ID}\"\ + ,\n \"total_details\": {\n \"amount_discount\": 0,\n \"amount_shipping\"\ + : 0,\n \"amount_tax\": 0\n },\n \"ui_mode\": \"hosted\",\n \"url\": \"\ + https://checkout.stripe.com/c/pay/cs_test_a1hoP4Yj4ZmfghAwGoUtWJngVt1XreEVLGAj2n7U5o9BlvqhnDimuA07zh#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl\"\ + \n}" + content_type: text/plain + method: POST + status: 200 + url: https://api.stripe.com/v1/checkout/sessions diff --git a/subscriptions/views/join.py b/subscriptions/views/join.py index b6db9d48..c01681a3 100644 --- a/subscriptions/views/join.py +++ b/subscriptions/views/join.py @@ -1,5 +1,4 @@ """Views handling subscription management.""" -from decimal import Decimal import logging from django.contrib import messages @@ -43,6 +42,24 @@ class JoinView(LoginRequiredMixin, FormView): ) return existing_subscriptions.first() + def _set_preferred_currency_and_redirect(self): + # If no country is set in the existing address, use GeoIP's + geoip_country = self.request.session.get(looper.middleware.COUNTRY_CODE_SESSION_KEY) + if geoip_country and (not self.customer or not self.customer.billing_address.country): + country = geoip_country + else: + country = self.customer.billing_address.country + currency = preferred_currency_for_country_code(country) + if self.plan_variation.currency != currency: + # If variation's currency doesn't match, redirect to another plan variation + plan_variation = self.plan_variation.in_other_currency(currency) + self.request.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY] = currency + self.request.session.modified = True + return redirect( + 'subscriptions:join-billing-details', plan_variation_id=plan_variation.pk + ) + return None # nothing to do, no need to redirect + def dispatch(self, request, *args, **kwargs): """Redirect to login or to billing, or prepare plan variation.""" if not request.user.is_authenticated: @@ -62,6 +79,10 @@ class JoinView(LoginRequiredMixin, FormView): self.user = request.user self.customer = self.user.customer self.subscription = self._get_existing_subscription() + + response_redirect = self._set_preferred_currency_and_redirect() + if response_redirect: + return response_redirect return super().dispatch(request, *args, **kwargs) def get_form_kwargs(self, *args, **kwargs): @@ -164,27 +185,21 @@ class JoinView(LoginRequiredMixin, FormView): form.save() msg = 'Pricing has been updated to reflect changes to your billing details' - new_country = self.customer.billing_address.country - new_currency = preferred_currency_for_country_code(new_country) - # Compare currency before and after the billing address is updated - if self.plan_variation.currency != new_currency: - # If currency has changed, find a matching plan variation for this new currency - plan_variation = self.plan_variation.in_other_currency(new_currency) - self.request.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY] = new_currency + response_redirect = self._set_preferred_currency_and_redirect() + if response_redirect: messages.add_message(self.request, messages.INFO, msg) - return redirect( - 'subscriptions:join-billing-details', plan_variation_id=plan_variation.pk - ) + return response_redirect # Compare tax before and after the billing address is updated new_tax = self.customer.get_tax(product_type=product_type) new_taxable = looper.taxes.Taxable(self.plan_variation.price, *new_tax) if old_taxable != new_taxable: # If price has changed, stay on the same page and display a notification + messages.add_message(self.request, messages.INFO, msg) return self.form_invalid(form) gateway = self.gateway_from_form(form) - price_cents = int(Decimal(form.cleaned_data['price']) * 100) + price_cents = new_taxable.price.cents subscription = self._get_or_create_subscription(gateway) # Update the tax info stored on the subscription subscription.update_tax() diff --git a/subscriptions/views/tests/test_join.py b/subscriptions/views/tests/test_join.py index a337039b..79568959 100644 --- a/subscriptions/views/tests/test_join.py +++ b/subscriptions/views/tests/test_join.py @@ -1,13 +1,15 @@ -from typing import Tuple from unittest.mock import patch import os import unittest from django.conf import settings +from django.test import TestCase from django.urls import reverse from freezegun import freeze_time import responses +# from responses import _recorder + from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4, SINGAPORE_IPV4 from looper.money import Money import looper.models @@ -41,7 +43,7 @@ def _get_default_variation(currency='USD'): @freeze_time('2023-05-19 11:41:11') -class TestGETBillingDetailsView(BaseSubscriptionTestCase): +class TestGETJoinView(BaseSubscriptionTestCase): url_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1}) url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2}) @@ -65,7 +67,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): ) def test_get_displays_total_and_billing_details_to_logged_in_nl(self): - user = create_customer_with_billing_address(vat_number='', country='NL') + customer = create_customer_with_billing_address(vat_number='', country='NL') + user = customer.user self.client.force_login(user) response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4) @@ -76,7 +79,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): self._assert_total_default_variation_selected_tax_21_eur(response) def test_get_displays_total_and_billing_details_to_logged_in_de(self): - user = create_customer_with_billing_address(vat_number='', country='DE') + customer = create_customer_with_billing_address(vat_number='', country='DE') + user = customer.user self.client.force_login(user) response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4) @@ -86,9 +90,10 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): self._assert_total_default_variation_selected_tax_19_eur(response) def test_get_displays_total_and_billing_details_to_logged_in_us(self): - user = create_customer_with_billing_address( + customer = create_customer_with_billing_address( vat_number='', country='US', region='NY', postal_code='12001' ) + user = customer.user self.client.force_login(user) response = self.client.get(self.url_usd) @@ -99,16 +104,23 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): self._assert_total_default_variation_selected_usd(response) @unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required') - def test_get_detects_country_us_sets_preferred_currency_usd_invalid_variation(self): - user = create_customer_with_billing_address() + def test_get_detects_country_us_sets_preferred_currency_usd_and_redirects(self): + customer = create_customer_with_billing_address() + user = customer.user self.client.force_login(user) + self.assertTrue(looper.middleware.PREFERRED_CURRENCY_SESSION_KEY not in self.client.session) response = self.client.get(self.url, REMOTE_ADDR=USA_IPV4) - self.assertEqual(response.status_code, 404) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], self.url_usd) + self.assertEqual( + self.client.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY], 'USD' + ) @unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required') def test_get_detects_country_us_sets_preferred_currency_usd(self): - user = create_customer_with_billing_address() + customer = create_customer_with_billing_address() + user = customer.user self.client.force_login(user) response = self.client.get(self.url_usd, REMOTE_ADDR=USA_IPV4) @@ -125,7 +137,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): @unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required') def test_get_detects_country_sg_sets_preferred_currency_eur(self): - user = create_customer_with_billing_address() + customer = create_customer_with_billing_address() + user = customer.user self.client.force_login(user) response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4) @@ -142,7 +155,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): @unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required') def test_get_detects_country_nl_sets_preferred_currency_eur_displays_correct_vat(self): - user = create_customer_with_billing_address() + customer = create_customer_with_billing_address() + user = customer.user self.client.force_login(user) response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4) @@ -158,12 +172,15 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): @freeze_time('2023-05-19 11:41:11') -class TestPOSTBillingDetailsView(BaseSubscriptionTestCase): +@responses.activate +class TestPOSTJoinView(BaseSubscriptionTestCase): url_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1}) url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2}) + responses._add_from_file('stripe_create_checkout_session.yaml') def test_post_updates_billing_address_and_customer_renders_next_form_de(self): - user = create_customer_with_billing_address(vat_number='', country='DE') + customer = create_customer_with_billing_address(vat_number='', country='DE') + user = customer.user self.client.force_login(user) selected_variation = ( @@ -197,33 +214,21 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase): self.assertContains(response, 'Manual ') self.assertContains(response, '/ 1 year', html=True) + # @_recorder.record(file_path='stripe_create_checkout_session.yaml') def test_post_has_correct_price_field_value(self): self.client.force_login(self.user) - default_variation = _get_default_variation('EUR') data = required_address_data response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4) self.assertEqual(response.status_code, 302) self.assertEqual( response['Location'], - reverse( - 'subscriptions:join-confirm-and-pay', - kwargs={'plan_variation_id': default_variation.pk}, - ), - ) - - # Follow the redirect to avoid "Couldn't retrieve content: Response code was 302 (expected 200)" - response = self.client.get(response['Location']) - # Check that we are no longer on the billing details page - self._assert_payment_form_displayed(response) - - self._assert_total_default_variation_selected_tax_21_eur(response) - # The hidden price field must also be set to a matching amount - self.assertContains( - response, - '', - html=True, + 'https://checkout.stripe.com/c/pay/cs_test_a1hoP4Yj4ZmfghAwGoUtWJngVt1XreEVLGAj2' + 'n7U5o9BlvqhnDimuA07zh#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8Zkx' + 'sUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp' + '%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabH' + 'FgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl', ) def test_post_updates_billing_address_and_customer_applies_reverse_charged_tax(self): @@ -240,8 +245,8 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase): self.assertEqual(response.status_code, 200) self.user.refresh_from_db() - self.assertEqual(self.user.customer.vat_number, 'DE260543043') address = self.user.customer.billing_address + self.assertEqual(address.vat_number, 'DE260543043') self.assertEqual(address.full_name, 'New Full Name') self.assertEqual(address.postal_code, '11111') self.assertEqual(address.country, 'DE') @@ -269,9 +274,10 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase): ) def test_post_changing_address_from_with_region_to_without_region_clears_region(self): - user = create_customer_with_billing_address( + customer = create_customer_with_billing_address( vat_number='', country='US', region='NY', postal_code='12001' ) + user = customer.user self.client.force_login(user) response = self.client.get(self.url_usd) @@ -304,22 +310,10 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase): self.assertEqual(user.customer.billing_address.country, 'DE') self.assertEqual(user.customer.billing_address.postal_code, '11111') - -@freeze_time('2023-05-19 11:41:11') -class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): - def _get_url_for(self, **filter_params) -> Tuple[str, looper.models.PlanVariation]: - plan_variation = looper.models.PlanVariation.objects.active().get(**filter_params) - return ( - reverse( - 'subscriptions:join-confirm-and-pay', - kwargs={'plan_variation_id': plan_variation.pk}, - ), - plan_variation, - ) - def test_plan_variation_does_not_match_detected_currency_usd_euro_ip(self): url, _ = self._get_url_for(currency='USD', price=11900) - user = create_customer_with_billing_address(country='NL') + customer = create_customer_with_billing_address(country='NL') + user = customer.user self.client.force_login(user) data = required_address_data @@ -329,7 +323,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): def test_plan_variation_matches_detected_currency_eur_non_eea_ip(self): url, _ = self._get_url_for(currency='EUR', price=990) - user = create_customer_with_billing_address() + customer = create_customer_with_billing_address() + user = customer.user self.client.force_login(user) data = required_address_data @@ -341,7 +336,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): def test_billing_address_country_takes_precedence_over_geo_ip(self): url, _ = self._get_url_for(currency='EUR', price=990) - user = create_customer_with_billing_address(country='NL') + customer = create_customer_with_billing_address(country='NL') + user = customer.user self.client.force_login(user) data = required_address_data @@ -352,7 +348,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): def test_invalid_missing_required_fields(self): url, _ = self._get_url_for(currency='EUR', price=990) - user = create_customer_with_billing_address(country='NL') + customer = create_customer_with_billing_address(country='NL') + user = customer.user self.client.force_login(user) data = required_address_data @@ -371,7 +368,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): def test_invalid_price_does_not_match_selected_plan_variation(self): url, selected_variation = self._get_url_for(currency='EUR', price=990) - user = create_customer_with_billing_address(country='NL') + customer = create_customer_with_billing_address(country='NL') + user = customer.user self.client.force_login(user) data = { @@ -391,7 +389,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): def test_invalid_bank_transfer_cannot_be_selected_for_automatic_payments(self): url, selected_variation = self._get_url_for(currency='EUR', price=990) - user = create_customer_with_billing_address(country='NL') + customer = create_customer_with_billing_address(country='NL') + user = customer.user self.client.force_login(user) data = { @@ -423,7 +422,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): ) @responses.activate def test_pay_with_bank_transfer_creates_order_subscription_on_hold(self): - user = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + user = customer.user self.client.force_login(user) util.mock_blender_id_badger_badger_response( 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id @@ -500,9 +500,10 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): def test_pay_with_bank_transfer_creates_order_subscription_on_hold_shows_reverse_charged_price( self, ): - user = create_customer_with_billing_address( + customer = create_customer_with_billing_address( country='ES', full_name='Jane Doe', vat_number='DE260543043' ) + user = customer.user self.client.force_login(user) util.mock_blender_id_badger_badger_response( 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id @@ -584,7 +585,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): @responses.activate def test_pay_with_credit_card_creates_order_subscription_active(self): url, selected_variation = self._get_url_for(currency='EUR', price=990) - user = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + user = customer.user self.client.force_login(user) util.mock_blender_id_badger_badger_response( 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id @@ -636,7 +638,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): price=9000, plan__name='Automatic renewal, 15 seats', ) - user = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + user = customer.user self.client.force_login(user) util.mock_blender_id_badger_badger_response( 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id @@ -655,7 +658,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): self._assert_done_page_displayed(response) - subscription = user.customer.subscription_set.first() + subscription = customer.subscription_set.first() order = subscription.latest_order() self.assertEqual(subscription.status, 'active') self.assertEqual(subscription.price, Money('EUR', 9000)) @@ -670,7 +673,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): self._assert_team_subscription_activated_email_is_sent(subscription) def test_pay_with_credit_card_creates_order_subscription_active_business_de(self): - user = create_customer_with_billing_address(country='DE', vat_number='DE260543043') + customer = create_customer_with_billing_address(country='DE', vat_number='DE260543043') + user = customer.user self.client.force_login(user) url, selected_variation = self._get_url_for( @@ -716,15 +720,17 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): self.assertIsNotNone(order.number) -class TestJoinConfirmAndPayLoggedInUserOnlyView(BaseSubscriptionTestCase): - url = reverse('subscriptions:join-confirm-and-pay', kwargs={'plan_variation_id': 8}) +class TestJoinViewLoggedInUserOnly(TestCase): + url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2}) - def test_get_anonymous_403(self): + def test_get_anonymous_redirects_to_login_with_next(self): response = self.client.get(self.url) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/oauth/login?next=/join/plan-variation/2/billing/') - def test_join_post_anonymous_403(self): + def test_post_anonymous_redirects_to_login_with_next(self): response = self.client.post(self.url) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/oauth/login?next=/join/plan-variation/2/billing/') -- 2.30.2 From 0349b92fbf92ecbec755d1b521ee12ba688cf6a9 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 10 Jun 2024 13:56:10 +0200 Subject: [PATCH 09/49] Remove now unused Blender Store-related settings --- common/context_processors.py | 2 -- studio/settings.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/common/context_processors.py b/common/context_processors.py index 0d3021a4..921a05ab 100644 --- a/common/context_processors.py +++ b/common/context_processors.py @@ -28,6 +28,4 @@ def extra_context(request: HttpRequest) -> Dict[str, str]: }, 'canonical_url': request.build_absolute_uri(request.path), 'ADMIN_MAIL': settings.ADMIN_MAIL, - 'STORE_PRODUCT_URL': settings.STORE_PRODUCT_URL, - 'STORE_MANAGE_URL': settings.STORE_MANAGE_URL, } diff --git a/studio/settings.py b/studio/settings.py index faea6e2e..fc6203ce 100644 --- a/studio/settings.py +++ b/studio/settings.py @@ -374,8 +374,6 @@ ACTSTREAM_SETTINGS = { } ADMIN_MAIL = _get('ADMIN_MAIL', 'admin@studio') -STORE_PRODUCT_URL = _get('STORE_PRODUCT_URL') -STORE_MANAGE_URL = _get('STORE_MANAGE_URL') SUPPORTED_CURRENCIES = {'EUR', 'USD'} -- 2.30.2 From 8271a8c0aaedd1f4433260b009e0875d25d39753 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 10 Jun 2024 14:47:11 +0200 Subject: [PATCH 10/49] Test passing for payment method change --- .../stripe_create_checkout_session.yaml | 0 .../stripe_create_checkout_session_setup.yaml | 51 ++++++++++++++ ...tripe_retrieve_checkout_session_setup.yaml | 67 +++++++++++++++++++ subscriptions/views/tests/test_join.py | 3 +- subscriptions/views/tests/test_settings.py | 63 +++++++---------- 5 files changed, 143 insertions(+), 41 deletions(-) rename stripe_create_checkout_session.yaml => subscriptions/tests/_responses/stripe_create_checkout_session.yaml (100%) create mode 100644 subscriptions/tests/_responses/stripe_create_checkout_session_setup.yaml create mode 100644 subscriptions/tests/_responses/stripe_retrieve_checkout_session_setup.yaml diff --git a/stripe_create_checkout_session.yaml b/subscriptions/tests/_responses/stripe_create_checkout_session.yaml similarity index 100% rename from stripe_create_checkout_session.yaml rename to subscriptions/tests/_responses/stripe_create_checkout_session.yaml diff --git a/subscriptions/tests/_responses/stripe_create_checkout_session_setup.yaml b/subscriptions/tests/_responses/stripe_create_checkout_session_setup.yaml new file mode 100644 index 00000000..478ec93d --- /dev/null +++ b/subscriptions/tests/_responses/stripe_create_checkout_session_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_retrieve_checkout_session_setup.yaml b/subscriptions/tests/_responses/stripe_retrieve_checkout_session_setup.yaml new file mode 100644 index 00000000..e44e0e1c --- /dev/null +++ b/subscriptions/tests/_responses/stripe_retrieve_checkout_session_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\": \"seti_1PQ72HE4KAUB5djsPthAKRdZ_secret_QGeKD74KJNAouKtjbevE1O3dwVKxxt7\"\ + ,\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/views/tests/test_join.py b/subscriptions/views/tests/test_join.py index 79568959..a066d65d 100644 --- a/subscriptions/views/tests/test_join.py +++ b/subscriptions/views/tests/test_join.py @@ -21,6 +21,7 @@ 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', @@ -176,7 +177,7 @@ class TestGETJoinView(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}) - responses._add_from_file('stripe_create_checkout_session.yaml') + responses._add_from_file(f'{responses_dir}stripe_create_checkout_session.yaml') def test_post_updates_billing_address_and_customer_renders_next_form_de(self): customer = create_customer_with_billing_address(vat_number='', country='DE') diff --git a/subscriptions/views/tests/test_settings.py b/subscriptions/views/tests/test_settings.py index 138150aa..73344d70 100644 --- a/subscriptions/views/tests/test_settings.py +++ b/subscriptions/views/tests/test_settings.py @@ -5,11 +5,15 @@ 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 subscriptions.tests.base import BaseSubscriptionTestCase import subscriptions.tasks +responses_dir = 'subscriptions/tests/_responses/' required_address_data = { 'country': 'NL', 'email': 'my.billing.email@example.com', @@ -99,6 +103,8 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase): url_name = 'subscriptions:payment-method-change' success_url_name = 'user-settings-billing' + # @_recorder.record(file_path=f'{responses_dir}stripe_create_checkout_session_setup.yaml') + # @_recorder.record(file_path=f'{responses_dir}stripe_retrieve_checkout_session_setup.yaml') def test_can_change_payment_method_from_bank_to_credit_card_with_sca(self): bank = Gateway.objects.get(name='bank') subscription = SubscriptionFactory( @@ -112,15 +118,23 @@ 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_create_checkout_session_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']) + + # 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_retrieve_checkout_session_setup.yaml') + response = self.client.get(success_url) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/subscription/1/manage/') + # New payment method was created self.assertEqual(PaymentMethod.objects.count(), 2) @@ -130,40 +144,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( - customer=self.user.customer, - payment_method__customer_id=self.user.customer.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()) -- 2.30.2 From a4f13681ccd7961462010ef2e0bba4710b8fe409 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 10 Jun 2024 14:50:14 +0200 Subject: [PATCH 11/49] Tests: minor fix --- subscriptions/views/tests/test_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/subscriptions/views/tests/test_settings.py b/subscriptions/views/tests/test_settings.py index 73344d70..c2f0f5f5 100644 --- a/subscriptions/views/tests/test_settings.py +++ b/subscriptions/views/tests/test_settings.py @@ -133,7 +133,7 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase): rsps._add_from_file(f'{responses_dir}stripe_retrieve_checkout_session_setup.yaml') response = self.client.get(success_url) self.assertEqual(response.status_code, 302) - self.assertEqual(response['Location'], '/subscription/1/manage/') + self.assertEqual(response['Location'], f'/subscription/{subscription.pk}/manage/') # New payment method was created self.assertEqual(PaymentMethod.objects.count(), 2) -- 2.30.2 From b3c383dd92bc0e495ff99c4074a486fad9ddc2c9 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 10 Jun 2024 16:40:52 +0200 Subject: [PATCH 12/49] Remove CSS rule that breaks vertical alignment of checkboxes --- common/static/common/styles/studio/_web-assets-overrides.sass | 1 - 1 file changed, 1 deletion(-) diff --git a/common/static/common/styles/studio/_web-assets-overrides.sass b/common/static/common/styles/studio/_web-assets-overrides.sass index f9d40d9d..e31f1def 100644 --- a/common/static/common/styles/studio/_web-assets-overrides.sass +++ b/common/static/common/styles/studio/_web-assets-overrides.sass @@ -145,7 +145,6 @@ details input, .form-control height: auto - min-height: calc(var(--spacer) * 2.5) .form-control &:disabled, -- 2.30.2 From cca91cc7a5ebbe3410a7b42ffa475e4de153fd7c Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 10 Jun 2024 17:20:17 +0200 Subject: [PATCH 13/49] Fix signals broken by Customer migration --- subscriptions/signals.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/subscriptions/signals.py b/subscriptions/signals.py index 12682b3f..3eabb7e8 100644 --- a/subscriptions/signals.py +++ b/subscriptions/signals.py @@ -111,8 +111,10 @@ 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') + customer = sender.customer + user = customer.user + if user and not queries.has_active_subscription(user): + users.tasks.revoke_blender_id_role(pk=user.pk, role='cloud_subscriber') if not hasattr(sender, 'team'): return -- 2.30.2 From 34d88e51fe2e653c62810a98dd5210b4673bb9e9 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 10 Jun 2024 18:21:02 +0200 Subject: [PATCH 14/49] Tests passing for settings pages --- subscriptions/forms.py | 38 ++--- subscriptions/tasks.py | 5 +- .../templates/settings/billing_address.html | 7 +- .../components/payment_form.html | 41 ------ .../stripe_create_checkout_session_usd.yaml | 58 ++++++++ .../stripe_retrieve_checkout_session_usd.yaml | 132 ++++++++++++++++++ subscriptions/urls.py | 9 +- subscriptions/views/join.py | 8 +- subscriptions/views/settings.py | 66 ++++----- subscriptions/views/tests/test_settings.py | 88 +++++------- users/templates/users/settings/base.html | 7 +- 11 files changed, 283 insertions(+), 176 deletions(-) delete mode 100644 subscriptions/templates/subscriptions/components/payment_form.html create mode 100644 subscriptions/tests/_responses/stripe_create_checkout_session_usd.yaml create mode 100644 subscriptions/tests/_responses/stripe_retrieve_checkout_session_usd.yaml diff --git a/subscriptions/forms.py b/subscriptions/forms.py index 9f75fc2c..9de7aa06 100644 --- a/subscriptions/forms.py +++ b/subscriptions/forms.py @@ -74,24 +74,8 @@ class BillingAddressForm(forms.ModelForm): def __init__(self, *args, **kwargs): """Load additional model data from Customer and set form placeholders.""" - self.request = kwargs.pop('request') - self.customer = self.request.user.customer - self.plan_variation = kwargs.pop('plan_variation') - super().__init__(*args, **kwargs) - # Only preset country when it's not already selected by the customer - geoip_country = self.request.session.get(COUNTRY_CODE_SESSION_KEY) - if geoip_country and (not self.instance.country): - self.initial['country'] = geoip_country - - # Only set initial values if they aren't already saved to the billing address. - # Initial values always override form data, which leads to confusing issues with views. - if not self.instance.full_name: - # Fall back to user's full name, if no full name set already in the billing address: - if self.request.user.full_name: - self.initial['full_name'] = self.request.user.full_name - # Set placeholder values on all form fields for field_name, field in self.fields.items(): placeholder = BILLING_DETAILS_PLACEHOLDERS.get(field_name) @@ -171,14 +155,30 @@ class PaymentForm(BillingAddressForm): but are still used by the payment flow. """ - # Price value is a decimal number in major units of selected currency. - 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) + def __init__(self, *args, **kwargs): + """Pre-fill additional initial data from request.""" + self.request = kwargs.pop('request') + self.customer = self.request.user.customer + + super().__init__(*args, **kwargs) + + # Only preset country when it's not already selected by the customer + geoip_country = self.request.session.get(COUNTRY_CODE_SESSION_KEY) + if geoip_country and (not self.instance.country): + self.initial['country'] = geoip_country + + # Only set initial values if they aren't already saved to the billing address. + # Initial values always override form data, which leads to confusing issues with views. + if not self.instance.full_name: + # Fall back to user's full name, if no full name set already in the billing address: + if self.request.user.full_name: + self.initial['full_name'] = self.request.user.full_name + class SelectPlanVariationForm(forms.Form): """Form used in the plan selector.""" diff --git a/subscriptions/tasks.py b/subscriptions/tasks.py index 8074ee6c..e4bfb232 100644 --- a/subscriptions/tasks.py +++ b/subscriptions/tasks.py @@ -89,7 +89,8 @@ 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 + customer = subscription.customer + user = customer.user email = user.customer.billing_address.email or user.email assert email, f'Cannot send notification about subscription {subscription.pk} status: no email' if is_noreply(email): @@ -109,7 +110,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(), diff --git a/subscriptions/templates/settings/billing_address.html b/subscriptions/templates/settings/billing_address.html index c9574a92..5687bd98 100644 --- a/subscriptions/templates/settings/billing_address.html +++ b/subscriptions/templates/settings/billing_address.html @@ -1,12 +1,11 @@ {% extends 'users/settings/base.html' %} {% load common_extras %} -{% load pipeline %} {% block settings %}

Settings: Subscription

Billing Address

-
+ {% with form|add_form_classes as form %}
{% csrf_token %} {% include "subscriptions/components/billing_address_form.html" %} @@ -15,7 +14,3 @@ {% endwith %} {% endblock settings %} - -{% block scripts %} - {% javascript "subscriptions" %} -{% endblock scripts %} 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/tests/_responses/stripe_create_checkout_session_usd.yaml b/subscriptions/tests/_responses/stripe_create_checkout_session_usd.yaml new file mode 100644 index 00000000..1c9602c0 --- /dev/null +++ b/subscriptions/tests/_responses/stripe_create_checkout_session_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/stripe_retrieve_checkout_session_usd.yaml b/subscriptions/tests/_responses/stripe_retrieve_checkout_session_usd.yaml new file mode 100644 index 00000000..1c537f40 --- /dev/null +++ b/subscriptions/tests/_responses/stripe_retrieve_checkout_session_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_Phx5lFDf54GRwi2NpHGJBYB8Q\"\ + ,\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/CAcaFwoVYWNjdF8xUE9iMjZFNEtBVUI1ZGpzKMzGnLMGMgaXZmL8WPc6LBa5Ndm2RPGLSlKNdxfFTIgoO_Z2BsGsdDaSQlb1ZFU2skexQxL9D0JFnSV9\"\ + ,\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/urls.py b/subscriptions/urls.py index 26878958..34ee0202 100644 --- a/subscriptions/urls.py +++ b/subscriptions/urls.py @@ -41,10 +41,7 @@ urlpatterns = [ ), path( 'subscription//payment-method/change/', - looper_settings.PaymentMethodChangeView.as_view( - success_url='subscriptions:payment-method-change-done', - cancel_url='user-settings-billing', # FIXME: go back to subscription manage instead - ), + settings.PaymentMethodChangeView.as_view(), name='payment-method-change', ), path( @@ -58,9 +55,7 @@ urlpatterns = [ name='pay-existing-order', ), path( - 'settings/billing-address/', - looper_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 c01681a3..d3bd157b 100644 --- a/subscriptions/views/join.py +++ b/subscriptions/views/join.py @@ -91,7 +91,6 @@ class JoinView(LoginRequiredMixin, FormView): form_kwargs.update( { 'request': self.request, - 'plan_variation': self.plan_variation, 'instance': self.customer.billing_address, } ) @@ -99,12 +98,8 @@ class JoinView(LoginRequiredMixin, FormView): 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, } @@ -168,7 +163,7 @@ class JoinView(LoginRequiredMixin, FormView): def form_invalid(self, form, *args, **kwargs): """Temporarily log all validation errors.""" - logger.exception('Validation error in ConfirmAndPayView: %s', form.errors) + logger.exception('Validation error in ConfirmAndPayView: %s, %s', form.errors, form.data) return super().form_invalid(form, *args, **kwargs) def form_valid(self, form): @@ -212,7 +207,6 @@ class JoinView(LoginRequiredMixin, FormView): if order.price != price: logger.error("Order price %s doesn't match form price %s", order.price, price) msg = 'Please reload the page and try again' - form.add_error('price', msg) messages.warning(self.request, msg) return self.form_invalid(form) diff --git a/subscriptions/views/settings.py b/subscriptions/views/settings.py index 5aadb79e..f43e397f 100644 --- a/subscriptions/views/settings.py +++ b/subscriptions/views/settings.py @@ -2,17 +2,14 @@ import logging 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 import looper.models -import looper.views.checkout_braintree import looper.views.settings -import looper.views.settings_braintree from subscriptions.forms import ( + BillingAddressForm, CancelSubscriptionForm, PayExistingOrderForm, TeamForm, @@ -24,6 +21,14 @@ logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) +class BillingAddressView(looper.views.settings.BillingAddressView): + """Override form class and success URL of looper's view.""" + + template_name = 'settings/billing_address.html' + form_class = BillingAddressForm + success_url = reverse_lazy('subscriptions:billing-address') + + class CancelSubscriptionView(SingleSubscriptionMixin, FormView): """Confirm and cancel a subscription.""" @@ -46,6 +51,19 @@ class CancelSubscriptionView(SingleSubscriptionMixin, FormView): return super().form_valid(form) +class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView): + """Override cancel and success URLs.""" + + success_url = 'subscriptions:payment-method-change-done' + + def get_cancel_url(self): + """Return to this subscription's manage page.""" + return reverse( + 'subscriptions:manage', + kwargs={'subscription_id': self.kwargs['subscription_id']}, + ) + + class PaymentMethodChangeDoneView(looper.views.settings.PaymentMethodChangeDoneView): """Change payment method in response to a successful payment setup.""" @@ -58,48 +76,18 @@ class PaymentMethodChangeDoneView(looper.views.settings.PaymentMethodChangeDoneV ) -class PayExistingOrderView(looper.views.checkout_braintree.CheckoutExistingOrderView): +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_address.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_braintree.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/tests/test_settings.py b/subscriptions/views/tests/test_settings.py index c2f0f5f5..4a854819 100644 --- a/subscriptions/views/tests/test_settings.py +++ b/subscriptions/views/tests/test_settings.py @@ -30,17 +30,18 @@ full_billing_address_data = { } -class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase): - def test_saves_both_address_and_customer(self): +class TestSettingsBillingAddress(BaseSubscriptionTestCase): + url = reverse('subscriptions:billing-address') + + def test_saves_full_billing_address(self): user = UserFactory() self.client.force_login(user) - url = reverse('user-settings-billing') - 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 @@ -62,7 +63,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase): user = UserFactory() self.client.force_login(user) - response = self.client.post(reverse('user-settings-billing'), {}) + response = self.client.post(self.url, {}) self.assertEqual(response.status_code, 200) self.assertContains(response, 'errorlist') @@ -75,7 +76,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase): data = { 'email': 'new@example.com', } - response = self.client.post(reverse('user-settings-billing'), data) + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 200) self.assertContains(response, 'errorlist') @@ -88,7 +89,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase): data = { 'full_name': 'New Full Name', } - response = self.client.post(reverse('user-settings-billing'), data) + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 200) self.assertContains(response, 'errorlist') @@ -222,7 +223,6 @@ 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( @@ -235,11 +235,7 @@ 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}') @@ -256,40 +252,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( - customer=self.user.customer, - payment_method__customer_id=self.user.customer.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_create_checkout_session_usd.yaml') + # @_recorder.record(file_path=f'{responses_dir}stripe_retrieve_checkout_session_usd.yaml') @patch( # Make sure background task is executed as a normal function 'subscriptions.signals.tasks.send_mail_subscription_status_changed', @@ -297,6 +265,7 @@ class TestPayExistingOrder(BaseSubscriptionTestCase): ) def test_can_pay_for_manual_subscription_with_an_order(self): subscription = SubscriptionFactory( + 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'), @@ -304,19 +273,30 @@ class TestPayExistingOrder(BaseSubscriptionTestCase): 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_create_checkout_session_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) + + # 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: + rsps._add_from_file(f'{responses_dir}stripe_retrieve_checkout_session_usd.yaml') + response = self.client.get(url) + self.assertEqual(order.transaction_set.count(), 1) transaction = order.latest_transaction() self.assertEqual( @@ -332,7 +312,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/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 %} -- 2.30.2 From 07a8f05ed79647015c6e646d2730aaf780bcef1c Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 10 Jun 2024 18:24:12 +0200 Subject: [PATCH 15/49] Minor fixes --- subscriptions/signals.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/subscriptions/signals.py b/subscriptions/signals.py index 3eabb7e8..8d9b8c46 100644 --- a/subscriptions/signals.py +++ b/subscriptions/signals.py @@ -80,7 +80,7 @@ def _on_subscription_created_needs_payment(sender: looper.models.Subscription, * tasks.send_mail_bank_transfer_required(subscription_id=sender.pk) user = sender.customer.user if not user: - logger.error('Cannot grand role to an account-less customer pk=%s', sender.customer_id) + logger.error('Cannot grant role to an account-less customer pk=%s', sender.customer_id) return users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription') @@ -95,7 +95,7 @@ def _on_subscription_status_changed(sender: looper.models.Subscription, **kwargs def _on_subscription_status_activated(sender: looper.models.Subscription, **kwargs): user = sender.customer.user if not user: - logger.error('Cannot grand role to an account-less customer pk=%s', sender.customer_id) + logger.error('Cannot grant role to an account-less customer pk=%s', sender.customer_id) return users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription') users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_subscriber') -- 2.30.2 From b59596d193add62569f1fd0c8b2ea59dd5d3eabf Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 10 Jun 2024 18:28:33 +0200 Subject: [PATCH 16/49] Remove unused template --- .../billing_address_form_readonly.html | 66 ------------------- 1 file changed, 66 deletions(-) delete mode 100644 subscriptions/templates/subscriptions/components/billing_address_form_readonly.html 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 %} -
-
-- 2.30.2 From 9b49dd05cd9a932cec19cd8c7fcc62229c8fadf4 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 10 Jun 2024 19:19:02 +0200 Subject: [PATCH 17/49] Production logs: fix 500 caused by a missing static asset --- films/models/production_logs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/films/models/production_logs.py b/films/models/production_logs.py index 2bb8f7bc..c4079a54 100644 --- a/films/models/production_logs.py +++ b/films/models/production_logs.py @@ -142,6 +142,8 @@ class ProductionLogEntry(mixins.CreatedUpdatedMixin, models.Model): contributors_ids = set() contributors = [] for asset in self.assets.all(): + if not asset.static_asset: + return [] for contributor in asset.static_asset.contributors.all(): if contributor.pk in contributors_ids: continue -- 2.30.2 From eca5de0a3e49202cc497b50165cb229f23255182 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 10 Jun 2024 20:00:52 +0200 Subject: [PATCH 18/49] Fix more tests --- characters/tests/test_characters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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) -- 2.30.2 From 7fc6a334c4f2cc97323b45849bed93bca2fe997f Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 10 Jun 2024 20:01:15 +0200 Subject: [PATCH 19/49] Tests passing for clock with Braintree payments --- subscriptions/signals.py | 4 +++- subscriptions/tasks.py | 29 ++++++++++++++++------------- subscriptions/tests/base.py | 17 +++++++++++------ subscriptions/tests/test_clock.py | 29 +++++++++++++++++------------ 4 files changed, 47 insertions(+), 32 deletions(-) diff --git a/subscriptions/signals.py b/subscriptions/signals.py index 8d9b8c46..209ab2ae 100644 --- a/subscriptions/signals.py +++ b/subscriptions/signals.py @@ -152,7 +152,9 @@ 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): + customer = sender.customer + user = customer.user + if user and 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 e4bfb232..79232f2e 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_address.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(), @@ -134,8 +135,8 @@ 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 + customer = order.customer + user = customer.user email = customer.billing_address.email or user.email logger.debug('Sending %r notification to %s', order.status, email) @@ -149,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, @@ -186,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}, @@ -221,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' @@ -230,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 @@ -249,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], @@ -281,8 +284,8 @@ 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 + customer = order.customer + user = customer.user email = customer.billing_address.email or user.email logger.debug('Sending %r notification to %s', order.status, email) @@ -296,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/tests/base.py b/subscriptions/tests/base.py index 870af099..d5e1489d 100644 --- a/subscriptions/tests/base.py +++ b/subscriptions/tests/base.py @@ -440,7 +440,8 @@ 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] @@ -470,7 +471,8 @@ 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] @@ -497,7 +499,8 @@ 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] @@ -523,7 +526,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] @@ -542,11 +546,12 @@ 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') diff --git a/subscriptions/tests/test_clock.py b/subscriptions/tests/test_clock.py index 04ba6d5c..8a637efa 100644 --- a/subscriptions/tests/test_clock.py +++ b/subscriptions/tests/test_clock.py @@ -17,18 +17,20 @@ 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__customer_id=user.customer.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', @@ -111,7 +113,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() @@ -164,7 +166,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), @@ -187,10 +189,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() @@ -251,9 +255,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), @@ -280,15 +284,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() -- 2.30.2 From dbd9a276350075e77125c5db80df07d2c5b74216 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 11 Jun 2024 10:08:41 +0200 Subject: [PATCH 20/49] Fix full name repetitions in the email templates --- subscriptions/templates/subscriptions/emails/base.html | 2 +- subscriptions/templates/subscriptions/emails/base.txt | 2 +- .../subscriptions/emails/paypal_subscription_cancelled.html | 2 +- .../subscriptions/emails/paypal_subscription_cancelled.txt | 2 +- .../templates/subscriptions/emails/subscription_expired.html | 2 +- .../templates/subscriptions/emails/subscription_expired.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/subscriptions/templates/subscriptions/emails/base.html b/subscriptions/templates/subscriptions/emails/base.html index cb8a3f66..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.billing_address.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 019298d7..817d9901 100644 --- a/subscriptions/templates/subscriptions/emails/base.txt +++ b/subscriptions/templates/subscriptions/emails/base.txt @@ -1,4 +1,4 @@ -Dear {% firstof user.customer.billing_address.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/paypal_subscription_cancelled.html b/subscriptions/templates/subscriptions/emails/paypal_subscription_cancelled.html index 8cf1c75d..a28891c5 100644 --- a/subscriptions/templates/subscriptions/emails/paypal_subscription_cancelled.html +++ b/subscriptions/templates/subscriptions/emails/paypal_subscription_cancelled.html @@ -7,7 +7,7 @@ {% endblock header_logo %} {% block body %} -

Dear {% firstof user.customer.billing_address.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 %},

As you may have heard, Blender Studio's subscription system recently got a new shiny update, more on that in the blog post. diff --git a/subscriptions/templates/subscriptions/emails/paypal_subscription_cancelled.txt b/subscriptions/templates/subscriptions/emails/paypal_subscription_cancelled.txt index 9936b984..4fb20653 100644 --- a/subscriptions/templates/subscriptions/emails/paypal_subscription_cancelled.txt +++ b/subscriptions/templates/subscriptions/emails/paypal_subscription_cancelled.txt @@ -1,4 +1,4 @@ -{% load subscriptions %}Dear {% firstof user.customer.billing_address.full_name user.customer.billing_address.full_name user.full_name user.email %}, +{% load subscriptions %}Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %}, As you may have heard, Blender Studio's subscription system recently got a new shiny update, more on that in the blog post https://studio.blender.org/blog/subscription-system-update-2021/ . diff --git a/subscriptions/templates/subscriptions/emails/subscription_expired.html b/subscriptions/templates/subscriptions/emails/subscription_expired.html index cbfc359e..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.billing_address.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 9c2ed097..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.billing_address.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" %} -- 2.30.2 From 7ed078259f3758fdad79fdeec0ff9fb67317245a Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 11 Jun 2024 15:09:32 +0200 Subject: [PATCH 21/49] All but one tests passing --- subscriptions/forms.py | 9 +- subscriptions/models.py | 2 +- subscriptions/tests/base.py | 3 - subscriptions/tests/test_forms.py | 6 -- subscriptions/tests/test_queries.py | 83 ++++++++++++------ subscriptions/tests/test_signals.py | 6 ++ subscriptions/views/tests/test_join.py | 2 +- subscriptions/views/tests/test_receipt_pdf.py | 44 +++++++--- .../views/tests/test_select_plan_variation.py | 16 ++-- users/models.py | 19 ++-- users/tasks.py | 2 +- users/tests/test_tasks.py | 87 +++++++++++-------- users/tests/util.py | 4 +- 13 files changed, 169 insertions(+), 114 deletions(-) diff --git a/subscriptions/forms.py b/subscriptions/forms.py index 9de7aa06..5dc6687c 100644 --- a/subscriptions/forms.py +++ b/subscriptions/forms.py @@ -162,11 +162,16 @@ class PaymentForm(BillingAddressForm): def __init__(self, *args, **kwargs): """Pre-fill additional initial data from request.""" - self.request = kwargs.pop('request') - self.customer = self.request.user.customer + self.request = kwargs.pop('request', None) super().__init__(*args, **kwargs) + 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): 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/tests/base.py b/subscriptions/tests/base.py index d5e1489d..24a5224e 100644 --- a/subscriptions/tests/base.py +++ b/subscriptions/tests/base.py @@ -1,6 +1,5 @@ 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 @@ -11,8 +10,6 @@ import responses from looper.tests.factories import create_customer_with_billing_address import users.tests.util as util -User = get_user_model() - def _write_mail(mail, index=0): email = mail.outbox[index] diff --git a/subscriptions/tests/test_forms.py b/subscriptions/tests/test_forms.py index 71cfda50..1d88bc08 100644 --- a/subscriptions/tests/test_forms.py +++ b/subscriptions/tests/test_forms.py @@ -213,10 +213,7 @@ 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): @@ -243,9 +240,6 @@ class TestPaymentForm(BaseSubscriptionTestCase): '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.'], }, ) diff --git a/subscriptions/tests/test_queries.py b/subscriptions/tests/test_queries.py index 41d0ac00..f169778e 100644 --- a/subscriptions/tests/test_queries.py +++ b/subscriptions/tests/test_queries.py @@ -23,25 +23,27 @@ class TestHasActiveSubscription(TestCase): def test_true_when_subscription_active(self): subscription = SubscriptionFactory( + customer=UserFactory().customer, plan_id=1, 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) + subscription = SubscriptionFactory(customer=UserFactory().customer, 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) + team = TeamFactory(subscription__customer=UserFactory().customer, subscription__plan_id=1) team.team_users.create(user=UserFactory()) self.assertFalse(has_active_subscription(team.team_users.first().user)) def test_true_when_team_subscription_active(self): team = TeamFactory( + subscription__customer=UserFactory().customer, subscription__plan_id=1, subscription__status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) @@ -58,30 +60,34 @@ class TestHasNotYetCancelledSubscription(TestCase): def test_true_when_subscription_active(self): subscription = SubscriptionFactory( + customer=UserFactory().customer, plan_id=1, 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') + subscription = SubscriptionFactory( + customer=UserFactory().customer, 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) + subscription = SubscriptionFactory(customer=UserFactory().customer, 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) + team = TeamFactory(subscription__customer=UserFactory().customer, subscription__plan_id=1) team.team_users.create(user=UserFactory()) self.assertFalse(has_not_yet_cancelled_subscription(team.team_users.first().user)) def test_false_when_team_subscription_active(self): team = TeamFactory( + subscription__customer=UserFactory().customer, subscription__plan_id=1, subscription__status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) @@ -91,6 +97,7 @@ class TestHasNotYetCancelledSubscription(TestCase): def test_false_when_team_subscription_cancelled(self): team = TeamFactory( + subscription__customer=UserFactory().customer, subscription__plan_id=1, subscription__status='cancelled', ) @@ -100,12 +107,13 @@ class TestHasNotYetCancelledSubscription(TestCase): def test_true_when_team_subscription_cancelled_personal_active(self): team = TeamFactory( + subscription__customer=UserFactory().customer, subscription__plan_id=1, subscription__status='cancelled', ) 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], ) @@ -114,12 +122,13 @@ class TestHasNotYetCancelledSubscription(TestCase): def test_false_when_team_subscription_active_personal_cancelled(self): team = TeamFactory( + subscription__customer=UserFactory().customer, subscription__plan_id=1, subscription__status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) 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', ) @@ -135,25 +144,27 @@ class TestHasSubscription(TestCase): def test_true_when_subscription_active(self): subscription = SubscriptionFactory( + customer=UserFactory().customer, plan_id=1, 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) + subscription = SubscriptionFactory(customer=UserFactory().customer, 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) + team = TeamFactory(subscription__customer=UserFactory().customer, subscription__plan_id=1) team.team_users.create(user=UserFactory()) self.assertTrue(has_subscription(team.team_users.first().user)) def test_true_when_team_subscription_active(self): team = TeamFactory( + subscription__customer=UserFactory().customer, subscription__plan_id=1, subscription__status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) @@ -163,20 +174,27 @@ class TestHasSubscription(TestCase): def test_true_when_subscription_active_is_legacy(self): subscription = SubscriptionFactory( + customer=UserFactory().customer, plan_id=1, status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], 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) + subscription = SubscriptionFactory( + customer=UserFactory().customer, 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) + team = TeamFactory( + subscription__customer=UserFactory().customer, + subscription__plan_id=1, + subscription__is_legacy=True, + ) team.team_users.create(user=UserFactory()) self.assertTrue(has_subscription(team.team_users.first().user)) @@ -190,45 +208,54 @@ class TestHasNonLegacySubscription(TestCase): def test_true_when_subscription_active_and_not_is_legacy(self): subscription = SubscriptionFactory( + customer=UserFactory().customer, plan_id=1, 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) + subscription = SubscriptionFactory(customer=UserFactory().customer, 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) + team = TeamFactory(subscription__customer=UserFactory().customer, subscription__plan_id=1) team.team_users.create(user=UserFactory()) self.assertTrue(has_non_legacy_subscription(team.team_users.first().user)) def test_false_when_subscription_inactive_and_is_legacy(self): - subscription = SubscriptionFactory(plan_id=1, is_legacy=True) + subscription = SubscriptionFactory( + customer=UserFactory().customer, 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( + customer=UserFactory().customer, plan_id=1, status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], 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) + team = TeamFactory( + subscription__customer=UserFactory().customer, + subscription__plan_id=1, + subscription__is_legacy=True, + ) team.team_users.create(user=UserFactory()) self.assertFalse(has_non_legacy_subscription(team.team_users.first().user)) def test_false_when_team_subscription_active_and_is_legacy(self): team = TeamFactory( + subscription__customer=UserFactory().customer, subscription__plan_id=1, subscription__status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], subscription__is_legacy=True, diff --git a/subscriptions/tests/test_signals.py b/subscriptions/tests/test_signals.py index 28c12913..a7414bd2 100644 --- a/subscriptions/tests/test_signals.py +++ b/subscriptions/tests/test_signals.py @@ -19,12 +19,14 @@ class TestAddToTeams(TestCase): emails=['test1@example.com', 'test2@example.com'], name='Team Awesome', subscription__status='active', + subscription__customer=UserFactory().customer, ) cls.team_unlimited = TeamFactory( seats=None, name='Team Unlimited', email_domain='my-awesome-blender-studio.org', subscription__status='active', + subscription__customer=UserFactory().customer, ) @responses.activate @@ -150,6 +152,7 @@ class TestAddToTeams(TestCase): name='Team Unlimited', email_domain='some-domain.com', subscription__status='active', + subscription__customer=UserFactory().customer, ) self.assertTrue(has_active_subscription(user)) @@ -182,6 +185,7 @@ class TestAddToTeams(TestCase): name='Team Unlimited', email_domain='some-domain.com', subscription__status='active', + subscription__customer=UserFactory().customer, ) self.assertEqual(team.email_domain, 'some-domain.com') @@ -232,6 +236,7 @@ class TestAddToTeams(TestCase): name='Team Unlimited', email_domain='edu.some-domain.com', subscription__status='active', + subscription__customer=UserFactory().customer, ) self.assertEqual(team.email_domain, 'edu.some-domain.com') @@ -265,6 +270,7 @@ class TestAddToTeams(TestCase): name='Team Unlimited', email_domain='edu.some-domain.com', subscription__status='active', + subscription__customer=UserFactory().customer, ) self.assertEqual(team.email_domain, 'edu.some-domain.com') diff --git a/subscriptions/views/tests/test_join.py b/subscriptions/views/tests/test_join.py index a066d65d..e36a21fe 100644 --- a/subscriptions/views/tests/test_join.py +++ b/subscriptions/views/tests/test_join.py @@ -63,7 +63,7 @@ class TestGETJoinView(BaseSubscriptionTestCase): ) self.assertContains( response, - '', + '', html=True, ) diff --git a/subscriptions/views/tests/test_receipt_pdf.py b/subscriptions/views/tests/test_receipt_pdf.py index d06dbc6e..01d97b7d 100644 --- a/subscriptions/views/tests/test_receipt_pdf.py +++ b/subscriptions/views/tests/test_receipt_pdf.py @@ -57,16 +57,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', ) @@ -87,15 +87,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) @@ -118,7 +118,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) @@ -139,7 +139,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, @@ -147,9 +149,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) @@ -181,7 +184,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, @@ -190,9 +195,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) @@ -229,7 +235,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, @@ -238,9 +246,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) @@ -268,15 +277,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) @@ -307,9 +319,11 @@ class TestReceiptPDFView(TestCase): seats=4, emails=['test1@example.com', 'test2@example.com'], name='Team Awesome', + subscription__customer=UserFactory().customer, subscription__plan_id=1, ) order = OrderFactory( + customer=team.subscription.customer, price=20000, currency='USD', status='paid', @@ -317,7 +331,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) @@ -348,10 +362,12 @@ class TestReceiptPDFView(TestCase): seats=4, emails=['test1@example.com', 'test2@example.com'], name='Team Awesome', + subscription__customer=UserFactory().customer, subscription__plan_id=1, invoice_reference='PO #9876', ) order = OrderFactory( + customer=team.subscription.customer, price=20000, currency='USD', status='paid', @@ -359,7 +375,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 d17d6584..0a7fbfdd 100644 --- a/subscriptions/views/tests/test_select_plan_variation.py +++ b/subscriptions/views/tests/test_select_plan_variation.py @@ -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/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 5528207c..5446d029 100644 --- a/users/tasks.py +++ b/users/tasks.py @@ -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/tests/test_tasks.py b/users/tests/test_tasks.py index ec35f146..909e688a 100644 --- a/users/tests/test_tasks.py +++ b/users/tests/test_tasks.py @@ -10,11 +10,12 @@ from looper.tests.factories import ( TransactionFactory, create_customer_with_billing_address, ) +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 +from common.tests.factories.users import UserFactory, OAuthUserInfoFactory, OAuthUserTokenFactory import users.tasks as tasks import users.tests.util as util @@ -50,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 @@ -103,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 @@ -143,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}', @@ -166,14 +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: @@ -195,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, ) @@ -238,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', ) @@ -249,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() @@ -283,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', ) @@ -297,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..08f83203 100644 --- a/users/tests/util.py +++ b/users/tests/util.py @@ -145,7 +145,9 @@ 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) + admin_user, _ = User.objects.get_or_create( + 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) return admin_user -- 2.30.2 From 92b469bb7c89ee6049c85b2aafc6315f30a1b69c Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 11 Jun 2024 15:21:05 +0200 Subject: [PATCH 22/49] Deal with the flaky test --- subscriptions/views/tests/test_settings.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/subscriptions/views/tests/test_settings.py b/subscriptions/views/tests/test_settings.py index 4a854819..b3277e56 100644 --- a/subscriptions/views/tests/test_settings.py +++ b/subscriptions/views/tests/test_settings.py @@ -127,6 +127,7 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase): 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}/' @@ -286,6 +287,7 @@ class TestPayExistingOrder(BaseSubscriptionTestCase): 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( @@ -295,6 +297,12 @@ class TestPayExistingOrder(BaseSubscriptionTestCase): url = url.replace('CHECKOUT_SESSION_ID', checkout_session_id) with responses.RequestsMock() as rsps: rsps._add_from_file(f'{responses_dir}stripe_retrieve_checkout_session_usd.yaml') + # 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.pk}') response = self.client.get(url) self.assertEqual(order.transaction_set.count(), 1) -- 2.30.2 From da9ebf87d0e3f6bc689a71f3f5665e9385388f9e Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 11 Jun 2024 16:58:02 +0200 Subject: [PATCH 23/49] Clean up tests --- subscriptions/tests/test_queries.py | 53 +++++-------------- subscriptions/tests/test_signals.py | 6 --- subscriptions/views/tests/test_receipt_pdf.py | 2 - 3 files changed, 13 insertions(+), 48 deletions(-) diff --git a/subscriptions/tests/test_queries.py b/subscriptions/tests/test_queries.py index f169778e..12328fb8 100644 --- a/subscriptions/tests/test_queries.py +++ b/subscriptions/tests/test_queries.py @@ -23,7 +23,6 @@ class TestHasActiveSubscription(TestCase): def test_true_when_subscription_active(self): subscription = SubscriptionFactory( - customer=UserFactory().customer, plan_id=1, status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) @@ -31,19 +30,18 @@ class TestHasActiveSubscription(TestCase): self.assertTrue(has_active_subscription(subscription.customer.user)) def test_false_when_subscription_inactive(self): - subscription = SubscriptionFactory(customer=UserFactory().customer, plan_id=1) + subscription = SubscriptionFactory(plan_id=1) self.assertFalse(has_active_subscription(subscription.customer.user)) def test_false_when_team_subscription_inactive(self): - team = TeamFactory(subscription__customer=UserFactory().customer, subscription__plan_id=1) + team = TeamFactory(subscription__plan_id=1) team.team_users.create(user=UserFactory()) self.assertFalse(has_active_subscription(team.team_users.first().user)) def test_true_when_team_subscription_active(self): team = TeamFactory( - subscription__customer=UserFactory().customer, subscription__plan_id=1, subscription__status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) @@ -60,7 +58,6 @@ class TestHasNotYetCancelledSubscription(TestCase): def test_true_when_subscription_active(self): subscription = SubscriptionFactory( - customer=UserFactory().customer, plan_id=1, status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) @@ -68,26 +65,23 @@ class TestHasNotYetCancelledSubscription(TestCase): self.assertTrue(has_not_yet_cancelled_subscription(subscription.customer.user)) def test_false_when_subscription_cancelled(self): - subscription = SubscriptionFactory( - customer=UserFactory().customer, plan_id=1, status='cancelled' - ) + subscription = SubscriptionFactory(plan_id=1, status='cancelled') self.assertFalse(has_not_yet_cancelled_subscription(subscription.customer.user)) def test_true_when_subscription_inactive(self): - subscription = SubscriptionFactory(customer=UserFactory().customer, plan_id=1) + subscription = SubscriptionFactory(plan_id=1) self.assertTrue(has_not_yet_cancelled_subscription(subscription.customer.user)) def test_false_when_team_subscription_inactive(self): - team = TeamFactory(subscription__customer=UserFactory().customer, subscription__plan_id=1) + team = TeamFactory(subscription__plan_id=1) team.team_users.create(user=UserFactory()) self.assertFalse(has_not_yet_cancelled_subscription(team.team_users.first().user)) def test_false_when_team_subscription_active(self): team = TeamFactory( - subscription__customer=UserFactory().customer, subscription__plan_id=1, subscription__status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) @@ -97,7 +91,6 @@ class TestHasNotYetCancelledSubscription(TestCase): def test_false_when_team_subscription_cancelled(self): team = TeamFactory( - subscription__customer=UserFactory().customer, subscription__plan_id=1, subscription__status='cancelled', ) @@ -107,7 +100,6 @@ class TestHasNotYetCancelledSubscription(TestCase): def test_true_when_team_subscription_cancelled_personal_active(self): team = TeamFactory( - subscription__customer=UserFactory().customer, subscription__plan_id=1, subscription__status='cancelled', ) @@ -122,7 +114,6 @@ class TestHasNotYetCancelledSubscription(TestCase): def test_false_when_team_subscription_active_personal_cancelled(self): team = TeamFactory( - subscription__customer=UserFactory().customer, subscription__plan_id=1, subscription__status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) @@ -144,7 +135,6 @@ class TestHasSubscription(TestCase): def test_true_when_subscription_active(self): subscription = SubscriptionFactory( - customer=UserFactory().customer, plan_id=1, status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) @@ -152,19 +142,18 @@ class TestHasSubscription(TestCase): self.assertTrue(has_subscription(subscription.customer.user)) def test_true_when_subscription_inactive(self): - subscription = SubscriptionFactory(customer=UserFactory().customer, plan_id=1) + subscription = SubscriptionFactory(plan_id=1) self.assertTrue(has_subscription(subscription.customer.user)) def test_true_when_team_subscription_inactive(self): - team = TeamFactory(subscription__customer=UserFactory().customer, subscription__plan_id=1) + team = TeamFactory(subscription__plan_id=1) team.team_users.create(user=UserFactory()) self.assertTrue(has_subscription(team.team_users.first().user)) def test_true_when_team_subscription_active(self): team = TeamFactory( - subscription__customer=UserFactory().customer, subscription__plan_id=1, subscription__status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) @@ -174,7 +163,6 @@ class TestHasSubscription(TestCase): def test_true_when_subscription_active_is_legacy(self): subscription = SubscriptionFactory( - customer=UserFactory().customer, plan_id=1, status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], is_legacy=True, @@ -183,18 +171,12 @@ class TestHasSubscription(TestCase): self.assertTrue(has_subscription(subscription.customer.user)) def test_true_when_subscription_inactive_and_is_legacy(self): - subscription = SubscriptionFactory( - customer=UserFactory().customer, plan_id=1, is_legacy=True - ) + subscription = SubscriptionFactory(plan_id=1, is_legacy=True) self.assertTrue(has_subscription(subscription.customer.user)) def test_true_when_team_subscription_inactive_and_is_legacy(self): - team = TeamFactory( - subscription__customer=UserFactory().customer, - subscription__plan_id=1, - subscription__is_legacy=True, - ) + team = TeamFactory(subscription__plan_id=1, subscription__is_legacy=True) team.team_users.create(user=UserFactory()) self.assertTrue(has_subscription(team.team_users.first().user)) @@ -208,7 +190,6 @@ class TestHasNonLegacySubscription(TestCase): def test_true_when_subscription_active_and_not_is_legacy(self): subscription = SubscriptionFactory( - customer=UserFactory().customer, plan_id=1, status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) @@ -216,26 +197,23 @@ class TestHasNonLegacySubscription(TestCase): self.assertTrue(has_non_legacy_subscription(subscription.customer.user)) def test_true_when_subscription_inactive_and_not_is_legacy(self): - subscription = SubscriptionFactory(customer=UserFactory().customer, plan_id=1) + subscription = SubscriptionFactory(plan_id=1) self.assertTrue(has_non_legacy_subscription(subscription.customer.user)) def test_true_when_team_subscription_inactive_and_not_is_legacy(self): - team = TeamFactory(subscription__customer=UserFactory().customer, subscription__plan_id=1) + team = TeamFactory(subscription__plan_id=1) team.team_users.create(user=UserFactory()) self.assertTrue(has_non_legacy_subscription(team.team_users.first().user)) def test_false_when_subscription_inactive_and_is_legacy(self): - subscription = SubscriptionFactory( - customer=UserFactory().customer, plan_id=1, is_legacy=True - ) + subscription = SubscriptionFactory(plan_id=1, is_legacy=True) self.assertFalse(has_non_legacy_subscription(subscription.customer.user)) def test_false_when_subscription_active_and_is_legacy(self): subscription = SubscriptionFactory( - customer=UserFactory().customer, plan_id=1, status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], is_legacy=True, @@ -244,18 +222,13 @@ class TestHasNonLegacySubscription(TestCase): self.assertFalse(has_non_legacy_subscription(subscription.customer.user)) def test_false_when_team_subscription_inactive_and_is_legacy(self): - team = TeamFactory( - subscription__customer=UserFactory().customer, - subscription__plan_id=1, - subscription__is_legacy=True, - ) + team = TeamFactory(subscription__plan_id=1, subscription__is_legacy=True) team.team_users.create(user=UserFactory()) self.assertFalse(has_non_legacy_subscription(team.team_users.first().user)) def test_false_when_team_subscription_active_and_is_legacy(self): team = TeamFactory( - subscription__customer=UserFactory().customer, subscription__plan_id=1, subscription__status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], subscription__is_legacy=True, diff --git a/subscriptions/tests/test_signals.py b/subscriptions/tests/test_signals.py index a7414bd2..28c12913 100644 --- a/subscriptions/tests/test_signals.py +++ b/subscriptions/tests/test_signals.py @@ -19,14 +19,12 @@ class TestAddToTeams(TestCase): emails=['test1@example.com', 'test2@example.com'], name='Team Awesome', subscription__status='active', - subscription__customer=UserFactory().customer, ) cls.team_unlimited = TeamFactory( seats=None, name='Team Unlimited', email_domain='my-awesome-blender-studio.org', subscription__status='active', - subscription__customer=UserFactory().customer, ) @responses.activate @@ -152,7 +150,6 @@ class TestAddToTeams(TestCase): name='Team Unlimited', email_domain='some-domain.com', subscription__status='active', - subscription__customer=UserFactory().customer, ) self.assertTrue(has_active_subscription(user)) @@ -185,7 +182,6 @@ class TestAddToTeams(TestCase): name='Team Unlimited', email_domain='some-domain.com', subscription__status='active', - subscription__customer=UserFactory().customer, ) self.assertEqual(team.email_domain, 'some-domain.com') @@ -236,7 +232,6 @@ class TestAddToTeams(TestCase): name='Team Unlimited', email_domain='edu.some-domain.com', subscription__status='active', - subscription__customer=UserFactory().customer, ) self.assertEqual(team.email_domain, 'edu.some-domain.com') @@ -270,7 +265,6 @@ class TestAddToTeams(TestCase): name='Team Unlimited', email_domain='edu.some-domain.com', subscription__status='active', - subscription__customer=UserFactory().customer, ) self.assertEqual(team.email_domain, 'edu.some-domain.com') diff --git a/subscriptions/views/tests/test_receipt_pdf.py b/subscriptions/views/tests/test_receipt_pdf.py index 01d97b7d..bd3514ac 100644 --- a/subscriptions/views/tests/test_receipt_pdf.py +++ b/subscriptions/views/tests/test_receipt_pdf.py @@ -319,7 +319,6 @@ class TestReceiptPDFView(TestCase): seats=4, emails=['test1@example.com', 'test2@example.com'], name='Team Awesome', - subscription__customer=UserFactory().customer, subscription__plan_id=1, ) order = OrderFactory( @@ -362,7 +361,6 @@ class TestReceiptPDFView(TestCase): seats=4, emails=['test1@example.com', 'test2@example.com'], name='Team Awesome', - subscription__customer=UserFactory().customer, subscription__plan_id=1, invoice_reference='PO #9876', ) -- 2.30.2 From 7c6503377d8856d327b381950bd4294e7f2b328a Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 11 Jun 2024 17:00:00 +0200 Subject: [PATCH 24/49] Add Stripe config vars into playbook's template as well --- playbooks/templates/dotenv | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/playbooks/templates/dotenv b/playbooks/templates/dotenv index dee31b93..03368e16 100644 --- a/playbooks/templates/dotenv +++ b/playbooks/templates/dotenv @@ -55,3 +55,7 @@ MAILGUN_WEBHOOK_SIGNING_KEY= MAILGUN_WEBHOOK_SECRET= GOOGLE_ANALYTICS_TRACKING_ID= + +STRIPE_API_PUBLISHABLE_KEY= +STRIPE_API_SECRET_KEY= +STRIPE_ENDPOINT_SECRET= -- 2.30.2 From d18c100f9d8846813ac8e5731bd89a38fd1b94b1 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 11 Jun 2024 17:08:01 +0200 Subject: [PATCH 25/49] Remove unused form --- subscriptions/forms.py | 6 ------ subscriptions/views/settings.py | 2 -- 2 files changed, 8 deletions(-) diff --git a/subscriptions/forms.py b/subscriptions/forms.py index 5dc6687c..8306d387 100644 --- a/subscriptions/forms.py +++ b/subscriptions/forms.py @@ -209,12 +209,6 @@ class SelectPlanVariationForm(forms.Form): ) -class PayExistingOrderForm(forms.Form): # TODO - """Same as AutomaticPaymentForm, but doesn't validate or update billing details.""" - - price = forms.CharField(widget=forms.HiddenInput(), required=True) - - class CancelSubscriptionForm(forms.Form): """Confirm cancellation of a subscription.""" diff --git a/subscriptions/views/settings.py b/subscriptions/views/settings.py index f43e397f..05d35f80 100644 --- a/subscriptions/views/settings.py +++ b/subscriptions/views/settings.py @@ -11,7 +11,6 @@ import looper.views.settings from subscriptions.forms import ( BillingAddressForm, CancelSubscriptionForm, - PayExistingOrderForm, TeamForm, ) from subscriptions.views.mixins import SingleSubscriptionMixin, BootstrapErrorListMixin @@ -82,7 +81,6 @@ class PayExistingOrderView(looper.views.checkout_stripe.CheckoutExistingOrderVie # Redirect to LOGIN_URL instead of raising an exception raise_exception = False template_name = 'subscriptions/pay_existing_order.html' - form_class = PayExistingOrderForm def get_cancel_url(self): """Return to this subscription's manage page.""" -- 2.30.2 From 06157b6a77285f2f986cba5160573fcf7092f93d Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 11 Jun 2024 17:08:11 +0200 Subject: [PATCH 26/49] Clean up signals --- subscriptions/signals.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/subscriptions/signals.py b/subscriptions/signals.py index 209ab2ae..80c1cafd 100644 --- a/subscriptions/signals.py +++ b/subscriptions/signals.py @@ -111,8 +111,7 @@ 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 - customer = sender.customer - user = customer.user + user = sender.customer.user if user and not queries.has_active_subscription(user): users.tasks.revoke_blender_id_role(pk=user.pk, role='cloud_subscriber') @@ -152,8 +151,7 @@ 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 - customer = sender.customer - user = customer.user + user = sender.customer.user if user and not queries.has_active_subscription(user): tasks.send_mail_subscription_expired(subscription_id=sender.pk) -- 2.30.2 From 416b16261f865521e4d0a305f9d74b86c1206079 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 11 Jun 2024 17:19:15 +0200 Subject: [PATCH 27/49] Fix emails admin --- emails/admin.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/emails/admin.py b/emails/admin.py index 8bb6a662..135a769e 100644 --- a/emails/admin.py +++ b/emails/admin.py @@ -93,12 +93,14 @@ 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.customer = customer + customer.user = user 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 +114,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 -- 2.30.2 From 8748f8169f73946cf5000c4f35e70f5b53615e41 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 11 Jun 2024 17:19:36 +0200 Subject: [PATCH 28/49] Fix full_name in managed notification template --- .../templates/subscriptions/emails/managed_notification.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/subscriptions/templates/subscriptions/emails/managed_notification.txt b/subscriptions/templates/subscriptions/emails/managed_notification.txt index 632e9c56..a1bc6664 100644 --- a/subscriptions/templates/subscriptions/emails/managed_notification.txt +++ b/subscriptions/templates/subscriptions/emails/managed_notification.txt @@ -1,3 +1,3 @@ -{{ user.customer.billing_address.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. -- 2.30.2 From 113c8f94460744b20223b5da826d80ebbc478c83 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 11 Jun 2024 17:20:02 +0200 Subject: [PATCH 29/49] Minor fix to the join view --- subscriptions/views/join.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/subscriptions/views/join.py b/subscriptions/views/join.py index d3bd157b..0fe08f19 100644 --- a/subscriptions/views/join.py +++ b/subscriptions/views/join.py @@ -135,7 +135,7 @@ class JoinView(LoginRequiredMixin, FormView): with transaction.atomic(): subscription.plan = self.plan_variation.plan - subscription.user = self.user + 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 -- 2.30.2 From df77bc616f4f056f0669cd73528cb573192f8b72 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 11 Jun 2024 17:25:10 +0200 Subject: [PATCH 30/49] Remove deleted templates from the migration guide --- docs/web-assets-migrate-guidelines.md | 4 ---- subscriptions/views/settings.py | 1 - 2 files changed, 5 deletions(-) diff --git a/docs/web-assets-migrate-guidelines.md b/docs/web-assets-migrate-guidelines.md index 6c45afa1..3b90b650 100644 --- a/docs/web-assets-migrate-guidelines.md +++ b/docs/web-assets-migrate-guidelines.md @@ -249,7 +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 @@ -265,11 +264,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/subscriptions/views/settings.py b/subscriptions/views/settings.py index 05d35f80..a344cb94 100644 --- a/subscriptions/views/settings.py +++ b/subscriptions/views/settings.py @@ -80,7 +80,6 @@ class PayExistingOrderView(looper.views.checkout_stripe.CheckoutExistingOrderVie # Redirect to LOGIN_URL instead of raising an exception raise_exception = False - template_name = 'subscriptions/pay_existing_order.html' def get_cancel_url(self): """Return to this subscription's manage page.""" -- 2.30.2 From cf912f4cd9e05e66900f5f06684e99b60ff6ee95 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Tue, 11 Jun 2024 19:16:01 +0200 Subject: [PATCH 31/49] Minor change --- subscriptions/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/subscriptions/tasks.py b/subscriptions/tasks.py index 79232f2e..af650fc6 100644 --- a/subscriptions/tasks.py +++ b/subscriptions/tasks.py @@ -92,7 +92,7 @@ def send_mail_subscription_status_changed(subscription_id: int): subscription = looper.models.Subscription.objects.get(pk=subscription_id) customer = subscription.customer user = customer.user - email = user.customer.billing_address.email or user.email + 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 -- 2.30.2 From e1da0b0381e7c794ee015dc40ac79aec1e6413a9 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Thu, 13 Jun 2024 18:08:46 +0200 Subject: [PATCH 32/49] Minor changes to bank checkout-done page --- .../templates/checkout/checkout_done_transactionless.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.

-- 2.30.2 From ba4019fcc351e17d84e226f1938d98be05a75361 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Thu, 13 Jun 2024 18:41:06 +0200 Subject: [PATCH 33/49] Show subscriptions even if got "demo" account --- users/templates/users/settings/billing.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/users/templates/users/settings/billing.html b/users/templates/users/settings/billing.html index f7380339..98c8d9dd 100644 --- a/users/templates/users/settings/billing.html +++ b/users/templates/users/settings/billing.html @@ -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" %} -- 2.30.2 From aea40f57d79a411b8c45f05bcf6652b34df5d575 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Thu, 13 Jun 2024 19:36:13 +0200 Subject: [PATCH 34/49] Working "Pay via bank transfer" option --- .../static/common/styles/studio/_custom.sass | 16 ++++++++ subscriptions/forms.py | 6 +++ subscriptions/signals.py | 38 ------------------ .../subscriptions/components/total.html | 7 +++- .../subscriptions/join/billing_address.html | 12 +++++- subscriptions/tests/base.py | 26 ++++++++++--- subscriptions/tests/test_forms.py | 2 + subscriptions/views/join.py | 39 ++++++++++--------- subscriptions/views/tests/test_join.py | 20 ++++++++-- subscriptions/views/tests/test_settings.py | 2 +- users/signals.py | 39 +++++++++++++++++++ users/tests/util.py | 8 +--- 12 files changed, 141 insertions(+), 74 deletions(-) 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/subscriptions/forms.py b/subscriptions/forms.py index 8306d387..201f90ba 100644 --- a/subscriptions/forms.py +++ b/subscriptions/forms.py @@ -155,6 +155,12 @@ class PaymentForm(BillingAddressForm): but are still used by the payment flow. """ + gateway = looper.form_fields.GatewayChoiceField( + queryset=looper.models.Gateway.objects.filter(name__in={'stripe', 'bank'}).order_by( + '-is_default' + ) + ) + # These are used when a payment fails, so that the next attempt to pay can reuse # the already-created subscription and order. subscription_pk = forms.CharField(widget=forms.HiddenInput(), required=False) diff --git a/subscriptions/signals.py b/subscriptions/signals.py index 80c1cafd..282bd2e0 100644 --- a/subscriptions/signals.py +++ b/subscriptions/signals.py @@ -3,7 +3,6 @@ from typing import Set import logging from django.contrib.auth import get_user_model -from django.db import transaction from django.db.models import Q from django.dispatch import receiver import alphabetic_timestamp as ats @@ -30,43 +29,6 @@ def timebased_order_number(): return ats.base36.now(time_unit=ats.TimeUnit.milliseconds).upper() -@receiver(django_signals.post_save, sender=User) -def create_customer(sender, instance: User, created, raw, **kwargs): - """Create Customer on User creation.""" - from looper.models import Customer - - if raw: - return - - if not created: - return - - my_log = logger.getChild('create_customer') - try: - customer = instance.customer - except Customer.DoesNotExist: - pass - else: - my_log.debug( - 'Newly created User %d already has a Customer %d, not creating new one', - instance.pk, - customer.pk, - ) - billing_address = customer.billing_address - my_log.info('Creating new billing address due to creation of user %s', instance.pk) - if not billing_address.pk: - billing_address.email = instance.email - billing_address.save() - return - - my_log.info('Creating new Customer due to creation of user %s', instance.pk) - with transaction.atomic(): - customer = Customer.objects.create(user=instance) - billing_address = customer.billing_address - billing_address.email = instance.email - billing_address.save() - - @receiver(django_signals.pre_save, sender=Order) def _set_order_number(sender, instance: Order, **kwargs): if instance.pk or instance.number or instance.is_legacy: 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 %} - + {% with gw=form.gateway.field.queryset.first %} + + {% endwith %} {% else %} {% endif %} diff --git a/subscriptions/templates/subscriptions/join/billing_address.html b/subscriptions/templates/subscriptions/join/billing_address.html index 1ecc9fed..c8048b46 100644 --- a/subscriptions/templates/subscriptions/join/billing_address.html +++ b/subscriptions/templates/subscriptions/join/billing_address.html @@ -30,7 +30,6 @@ {% include "subscriptions/components/billing_address_form.html" %}

Required fields are marked with (*).

- {{ form.price }}
@@ -54,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" %} +
+
+ + +
+
+ {% endif %} + {% endwith %}
{% endwith %} diff --git a/subscriptions/tests/base.py b/subscriptions/tests/base.py index 24a5224e..2a17c428 100644 --- a/subscriptions/tests/base.py +++ b/subscriptions/tests/base.py @@ -1,3 +1,4 @@ +from typing import Tuple import os from django.core import mail @@ -8,6 +9,8 @@ import factory import responses from looper.tests.factories import create_customer_with_billing_address +import looper.models + import users.tests.util as util @@ -22,6 +25,16 @@ def _write_mail(mail, index=0): class BaseSubscriptionTestCase(TestCase): + def _get_url_for(self, **filter_params) -> Tuple[str, looper.models.PlanVariation]: + plan_variation = looper.models.PlanVariation.objects.active().get(**filter_params) + return ( + reverse( + 'subscriptions:join-billing-details', + kwargs={'plan_variation_id': plan_variation.pk}, + ), + plan_variation, + ) + @factory.django.mute_signals(signals.pre_save, signals.post_save) def setUp(self): # Allow requests to Braintree Sandbox @@ -83,12 +96,15 @@ class BaseSubscriptionTestCase(TestCase): self._assert_continue_to_payment_displayed(response) self.assertContains(response, 'id_street_address') self.assertContains(response, 'id_full_name') + self.assertContains(response, 'name="gateway" value="stripe"') + self.assertNotContains(response, 'name="gateway" value="bank"') - def _assert_payment_form_displayed(self, response): - self.assertNotContains(response, 'Pricing has been updated') - self.assertNotContains(response, 'Continue to Payment') - self.assertContains(response, 'payment method') - self.assertContains(response, 'Confirm and Pay') + def _assert_billing_details_form_with_pay_via_bank_displayed(self, response): + self._assert_continue_to_payment_displayed(response) + self.assertContains(response, 'id_street_address') + self.assertContains(response, 'id_full_name') + self.assertContains(response, 'name="gateway" value="stripe"') + self.assertContains(response, 'name="gateway" value="bank"') def _assert_pricing_has_been_updated(self, response): self.assertContains(response, 'Pricing has been updated') diff --git a/subscriptions/tests/test_forms.py b/subscriptions/tests/test_forms.py index 1d88bc08..7aa57de0 100644 --- a/subscriptions/tests/test_forms.py +++ b/subscriptions/tests/test_forms.py @@ -213,6 +213,7 @@ class TestBillingAddressForm(BaseSubscriptionTestCase): class TestPaymentForm(BaseSubscriptionTestCase): required_payment_form_data = { + 'gateway': 'bank', 'plan_variation_id': 1, } @@ -240,6 +241,7 @@ class TestPaymentForm(BaseSubscriptionTestCase): 'country': ['This field is required.'], 'email': ['This field is required.'], 'full_name': ['This field is required.'], + 'gateway': ['This field is required.'], }, ) diff --git a/subscriptions/views/join.py b/subscriptions/views/join.py index 0fe08f19..13319a59 100644 --- a/subscriptions/views/join.py +++ b/subscriptions/views/join.py @@ -29,7 +29,7 @@ class JoinView(LoginRequiredMixin, FormView): """Fill in billing details and initiate Stripe checkout session.""" # FIXME(anna): this view uses some functionality of CheckoutStripeView, - # but cannot directly inherit from them, since JoinView supports creating only one subscription. + # but cannot directly inherit it, since JoinView supports creating only one subscription. _fetch_or_create_order = CheckoutStripeView._fetch_or_create_order template_name = 'subscriptions/join/billing_address.html' @@ -75,7 +75,6 @@ class JoinView(LoginRequiredMixin, FormView): is_active=True, ) - self.gateway = looper.models.Gateway.default() self.user = request.user self.customer = self.user.customer self.subscription = self._get_existing_subscription() @@ -96,13 +95,6 @@ class JoinView(LoginRequiredMixin, FormView): ) return form_kwargs - def get_initial(self) -> dict: - """Prefill default payment gateway, country and selected plan options.""" - return { - **super().get_initial(), - 'gateway': self.gateway.name, - } - def get_context_data(self, **kwargs) -> dict: """Add existing subscription to the view and the context.""" return { @@ -112,9 +104,8 @@ class JoinView(LoginRequiredMixin, FormView): } def gateway_from_form(self, form) -> looper.models.Gateway: - """TODO support the usual bank transfer payments.""" - name = 'bank' in form and 'bank' or 'stripe' - self.gateway = looper.models.Gateway.objects.get(name=name) + """Use Stripe by default, but allow bank transfer payments for manual plan variations.""" + self.gateway = looper.models.Gateway.objects.get(name=form.cleaned_data['gateway']) return self.gateway def _get_or_create_subscription( @@ -125,7 +116,8 @@ class JoinView(LoginRequiredMixin, FormView): if not subscription: subscription = looper.models.Subscription(customer=self.customer) is_new = True - logger.debug('Creating an new subscription for %s, %s', gateway) + args = [self.customer.pk, gateway] + logger.debug('Creating a new subscription for customer pk=%s, %s', *args) collection_method = self.plan_variation.collection_method supported = set(gateway.provider.supported_collection_methods) if collection_method not in supported: @@ -163,7 +155,7 @@ class JoinView(LoginRequiredMixin, FormView): def form_invalid(self, form, *args, **kwargs): """Temporarily log all validation errors.""" - logger.exception('Validation error in ConfirmAndPayView: %s, %s', form.errors, form.data) + logger.exception('Validation error in JoinView: %s, %s', form.errors, form.data) return super().form_invalid(form, *args, **kwargs) def form_valid(self, form): @@ -210,6 +202,21 @@ class JoinView(LoginRequiredMixin, FormView): messages.warning(self.request, msg) return self.form_invalid(form) + if not gateway.provider.supports_transactions: + logger.info( + 'Not creating transaction for order pk=%r because gateway %r does ' + 'not support it', + order.pk, + gateway.name, + ) + url = reverse( + 'looper:transactionless_checkout_done', + kwargs={'pk': order.pk, 'gateway_name': gateway.name}, + ) + # Trigger an email with instructions about manual payment: + subscription_created_needs_payment.send(sender=subscription) + return redirect(url) + success_url = self.request.build_absolute_uri( reverse( 'looper:stripe_success', @@ -221,10 +228,6 @@ class JoinView(LoginRequiredMixin, FormView): success_url = success_url.replace('CHECKOUT_SESSION_ID', '{CHECKOUT_SESSION_ID}', 1) cancel_url = self.request.build_absolute_uri(self.request.get_full_path()) - if not gateway.provider.supports_transactions: - # Trigger an email with instructions about manual payment: - subscription_created_needs_payment.send(sender=subscription) - session = looper.stripe_utils.create_stripe_checkout_session_for_order( order, success_url, diff --git a/subscriptions/views/tests/test_join.py b/subscriptions/views/tests/test_join.py index e36a21fe..f26df0c7 100644 --- a/subscriptions/views/tests/test_join.py +++ b/subscriptions/views/tests/test_join.py @@ -39,15 +39,27 @@ full_billing_address_data = { # **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database. -def _get_default_variation(currency='USD'): - return looper.models.Plan.objects.first().variation_for_currency(currency) - - @freeze_time('2023-05-19 11:41:11') class TestGETJoinView(BaseSubscriptionTestCase): url_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1}) url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2}) + def test_pay_via_bank_transfer_button_is_shown_for_manual_plan_variation(self): + customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + user = customer.user + self.client.force_login(user) + + url, selected_variation = self._get_url_for( + currency='EUR', + interval_length=1, + interval_unit='month', + plan__name='Manual renewal', + ) + response = self.client.get(url, REMOTE_ADDR=EURO_IPV4) + + self.assertEqual(response.status_code, 200) + self._assert_billing_details_form_with_pay_via_bank_displayed(response) + def test_get_prefills_full_name_and_billing_email_from_user(self): user = UserFactory(full_name="Jane До", email='jane.doe@example.com') self.client.force_login(user) diff --git a/subscriptions/views/tests/test_settings.py b/subscriptions/views/tests/test_settings.py index b3277e56..c710ac12 100644 --- a/subscriptions/views/tests/test_settings.py +++ b/subscriptions/views/tests/test_settings.py @@ -30,7 +30,7 @@ full_billing_address_data = { } -class TestSettingsBillingAddress(BaseSubscriptionTestCase): +class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase): url = reverse('subscriptions:billing-address') def test_saves_full_billing_address(self): diff --git a/users/signals.py b/users/signals.py index a8f3d8a9..14de8f5a 100644 --- a/users/signals.py +++ b/users/signals.py @@ -4,6 +4,7 @@ import logging from actstream.models import Action from anymail.signals import tracking from django.contrib.auth import get_user_model +from django.db import transaction from django.db.models.signals import pre_save, post_save from django.dispatch import receiver from django.utils.dateparse import parse_datetime @@ -68,6 +69,44 @@ def _sync_is_subscribed_to_newsletter(sender: object, instance: User, **kwargs): tasks.handle_is_subscribed_to_newsletter(pk=instance.pk) +@receiver(post_save, sender=User) +def create_customer(sender, instance: User, created, raw, **kwargs): + """Create Customer on User creation.""" + from looper.models import Customer + + if raw: + return + + if not created: + return + + try: + customer = instance.customer + except Customer.DoesNotExist: + pass + else: + logger.debug( + 'Newly created User %d already has a Customer %d, not creating new one', + instance.pk, + customer.pk, + ) + billing_address = customer.billing_address + logger.info('Creating new billing address due to creation of user %s', instance.pk) + if not billing_address.pk: + billing_address.email = instance.email + billing_address.full_name = instance.full_name + billing_address.save() + return + + logger.info('Creating new Customer due to creation of user %s', instance.pk) + with transaction.atomic(): + customer = Customer.objects.create(user=instance) + billing_address = customer.billing_address + billing_address.email = instance.email + billing_address.full_name = instance.full_name + billing_address.save() + + @receiver(tracking) def _handle_mailgun_tracking_event(sender, event, esp_name, **kwargs): event_type = event.event_type diff --git a/users/tests/util.py b/users/tests/util.py index 08f83203..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,9 +143,7 @@ def mock_mailgun_responses() -> None: def create_admin_log_user() -> User: """Create the admin user used for logging.""" - admin_user, _ = User.objects.get_or_create( - id=1, email='admin@blender.studio', is_staff=True, is_superuser=True + admin_user, _ = User.objects.update_or_create( + id=1, defaults={'email': 'admin@blender.studio', 'is_staff': True, 'is_superuser': True} ) - # Reset ID sequence to avoid clashing with an already used ID 1 - UserFactory.reset_sequence(100, force=True) return admin_user -- 2.30.2 From f349708819bbf51af3bafbf98dfd5bb212459acf Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 14 Jun 2024 16:15:43 +0200 Subject: [PATCH 35/49] Tests passing (bank transfer working again) --- studio/settings.py | 2 +- subscriptions/forms.py | 16 +- .../tests/_responses/stripe_get_cs_eur.yaml | 158 +++++++++ ...on_setup.yaml => stripe_get_cs_setup.yaml} | 0 ...ession_usd.yaml => stripe_get_cs_usd.yaml} | 0 ...ut_session.yaml => stripe_new_cs_eur.yaml} | 26 +- ...on_setup.yaml => stripe_new_cs_setup.yaml} | 0 ...ession_usd.yaml => stripe_new_cs_usd.yaml} | 0 .../tests/_responses/vies_valid.yaml | 9 + subscriptions/tests/_responses/vies_wsdl.yaml | 163 ++++++++++ subscriptions/tests/base.py | 32 +- subscriptions/tests/test_clock.py | 3 + subscriptions/views/join.py | 23 +- subscriptions/views/tests/test_join.py | 300 ++++++++++-------- subscriptions/views/tests/test_settings.py | 24 +- 15 files changed, 574 insertions(+), 182 deletions(-) create mode 100644 subscriptions/tests/_responses/stripe_get_cs_eur.yaml rename subscriptions/tests/_responses/{stripe_retrieve_checkout_session_setup.yaml => stripe_get_cs_setup.yaml} (100%) rename subscriptions/tests/_responses/{stripe_retrieve_checkout_session_usd.yaml => stripe_get_cs_usd.yaml} (100%) rename subscriptions/tests/_responses/{stripe_create_checkout_session.yaml => stripe_new_cs_eur.yaml} (79%) rename subscriptions/tests/_responses/{stripe_create_checkout_session_setup.yaml => stripe_new_cs_setup.yaml} (100%) rename subscriptions/tests/_responses/{stripe_create_checkout_session_usd.yaml => stripe_new_cs_usd.yaml} (100%) create mode 100644 subscriptions/tests/_responses/vies_valid.yaml create mode 100644 subscriptions/tests/_responses/vies_wsdl.yaml diff --git a/studio/settings.py b/studio/settings.py index fc6203ce..75911632 100644 --- a/studio/settings.py +++ b/studio/settings.py @@ -508,7 +508,7 @@ GATEWAYS = { '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'}, + 'supported_collection_methods': {'automatic', 'manual'}, }, } diff --git a/subscriptions/forms.py b/subscriptions/forms.py index 201f90ba..92e806e4 100644 --- a/subscriptions/forms.py +++ b/subscriptions/forms.py @@ -161,14 +161,10 @@ class PaymentForm(BillingAddressForm): ) ) - # 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) - def __init__(self, *args, **kwargs): """Pre-fill additional initial data from request.""" self.request = kwargs.pop('request', None) + self.plan_variation = kwargs.pop('plan_variation', None) super().__init__(*args, **kwargs) @@ -190,6 +186,16 @@ class PaymentForm(BillingAddressForm): 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): """Form used in the plan selector.""" 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..74efbd3e --- /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_BClI8ssVIUSjybh2UvIeFa6v0\"\ + ,\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/CAcaFwoVYWNjdF8xUE9iMjZFNEtBVUI1ZGpzKPOJsLMGMgbH3ZicbJc6LBYdfpRMBgTew5GCKCPV-K4DC44oir_sd03RTaqsJpsM5qstpEWJU0oqii03\"\ + ,\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_retrieve_checkout_session_setup.yaml b/subscriptions/tests/_responses/stripe_get_cs_setup.yaml similarity index 100% rename from subscriptions/tests/_responses/stripe_retrieve_checkout_session_setup.yaml rename to subscriptions/tests/_responses/stripe_get_cs_setup.yaml diff --git a/subscriptions/tests/_responses/stripe_retrieve_checkout_session_usd.yaml b/subscriptions/tests/_responses/stripe_get_cs_usd.yaml similarity index 100% rename from subscriptions/tests/_responses/stripe_retrieve_checkout_session_usd.yaml rename to subscriptions/tests/_responses/stripe_get_cs_usd.yaml diff --git a/subscriptions/tests/_responses/stripe_create_checkout_session.yaml b/subscriptions/tests/_responses/stripe_new_cs_eur.yaml similarity index 79% rename from subscriptions/tests/_responses/stripe_create_checkout_session.yaml rename to subscriptions/tests/_responses/stripe_new_cs_eur.yaml index 937abfa4..ff5b857e 100644 --- a/subscriptions/tests/_responses/stripe_create_checkout_session.yaml +++ b/subscriptions/tests/_responses/stripe_new_cs_eur.yaml @@ -1,11 +1,11 @@ responses: - response: auto_calculate_content_length: false - body: "{\n \"id\": \"cus_QFaxRrT6ZbD9NN\",\n \"object\": \"customer\",\n \"\ - address\": null,\n \"balance\": 0,\n \"created\": 1717778124,\n \"currency\"\ + 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\": \"046F772E\",\n \"invoice_settings\": {\n \"custom_fields\"\ + ,\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\"\ @@ -17,22 +17,22 @@ responses: url: https://api.stripe.com/v1/customers - response: auto_calculate_content_length: false - body: "{\n \"id\": \"cs_test_a1hoP4Yj4ZmfghAwGoUtWJngVt1XreEVLGAj2n7U5o9BlvqhnDimuA07zh\"\ + body: "{\n \"id\": \"cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW\"\ ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\ - allow_promotion_codes\": null,\n \"amount_subtotal\": 990,\n \"amount_total\"\ - : 990,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\":\ - \ null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\ - \ \"cancel_url\": \"http://testserver/join/plan-variation/2/billing/\",\n \ - \ \"client_reference_id\": null,\n \"client_secret\": null,\n \"consent\"\ - : null,\n \"consent_collection\": null,\n \"created\": 1717778125,\n \"currency\"\ + 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_QFaxRrT6ZbD9NN\",\n \"customer_creation\": null,\n \"customer_details\"\ + 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\"\ - : 1717864524,\n \"invoice\": null,\n \"invoice_creation\": {\n \"enabled\"\ + : 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\"\ @@ -51,7 +51,7 @@ responses: : null,\n \"success_url\": \"http://testserver/looper/stripe_success/1/{CHECKOUT_SESSION_ID}\"\ ,\n \"total_details\": {\n \"amount_discount\": 0,\n \"amount_shipping\"\ : 0,\n \"amount_tax\": 0\n },\n \"ui_mode\": \"hosted\",\n \"url\": \"\ - https://checkout.stripe.com/c/pay/cs_test_a1hoP4Yj4ZmfghAwGoUtWJngVt1XreEVLGAj2n7U5o9BlvqhnDimuA07zh#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl\"\ + https://checkout.stripe.com/c/pay/cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl\"\ \n}" content_type: text/plain method: POST diff --git a/subscriptions/tests/_responses/stripe_create_checkout_session_setup.yaml b/subscriptions/tests/_responses/stripe_new_cs_setup.yaml similarity index 100% rename from subscriptions/tests/_responses/stripe_create_checkout_session_setup.yaml rename to subscriptions/tests/_responses/stripe_new_cs_setup.yaml diff --git a/subscriptions/tests/_responses/stripe_create_checkout_session_usd.yaml b/subscriptions/tests/_responses/stripe_new_cs_usd.yaml similarity index 100% rename from subscriptions/tests/_responses/stripe_create_checkout_session_usd.yaml rename to subscriptions/tests/_responses/stripe_new_cs_usd.yaml 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: DE2605430432024-06-14+02:00true------ + 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..a60138d4 --- /dev/null +++ b/subscriptions/tests/_responses/vies_wsdl.yaml @@ -0,0 +1,163 @@ +responses: +- response: + auto_calculate_content_length: false + body: "\n\n \n\ + \t The objective of this Internet site is to allow persons involved in the intra-Community\ + \ supply of goods or of services to obtain confirmation of the validity of the\ + \ VAT identification number of any specified person, in accordance to article\ + \ 31 of Council Regulation (EC) No. 904/2010 of 7 October 2010.\n Any other\ + \ use and any extraction and use of the data which is not in conformity with\ + \ the objective of this site is strictly forbidden. \n Any retransmission\ + \ of the contents of this site, whether for a commercial purpose or otherwise,\ + \ as well as any more general use other than as far as is necessary to support\ + \ the activity of a legitimate user (for example: to draw up their own invoices)\ + \ is expressly forbidden. In addition, any copying or reproduction of the contents\ + \ of this site is strictly forbidden. \n The European Commission maintains\ + \ this website to enhance the access by taxable persons making intra-Community\ + \ supplies to verification of their customers' VAT identification numbers. Our\ + \ goal is to supply instantaneous and accurate information. \n However the\ + \ Commission accepts no responsibility or liability whatsoever with regard to\ + \ the information obtained using this site. This information: \n - is obtained\ + \ from Member States' databases over which the Commission services have no control\ + \ and for which the Commission assumes no responsibility; it is the responsibility\ + \ of the Member States to keep their databases complete, accurate and up to\ + \ date; \n - is not professional or legal advice (if you need specific advice,\ + \ you should always consult a suitably qualified professional); \n - does\ + \ not in itself give a right to exempt intra-Community supplies from Value Added\ + \ Tax; \n - does not change any obligations imposed on taxable persons in\ + \ relation to intra-Community supplies. \n It is our goal to minimise disruption\ + \ caused by technical errors. However some data or information on our site may\ + \ have been created or structured in files or formats which are not error-free\ + \ and we cannot guarantee that our service will not be interrupted or otherwise\ + \ affected by such problems. The Commission accepts no responsibility with regard\ + \ to such problems incurred as a result of using this site or any linked external\ + \ sites. \n This disclaimer is not intended to limit the liability of the\ + \ Commission in contravention of any requirements laid down in applicable national\ + \ law nor to exclude its liability for matters which may not be excluded under\ + \ that law. \n Collecting or handling personal data falls under the Data\ + \ Protection Notice. This data protection declaration explains the Processing\ + \ in the VIES-on-the-web Internet Website of VAT Identification Numbers for\ + \ intra-Community Transaction on Goods or Services. Details of your legal rights\ + \ associated with the collection, processing and use of this data are also provided:\ + \ http://ec.europa.eu/dpo-register/details.htm?id=40647 . \n \n Usage:\ + \ \n The countryCode input parameter must follow the pattern [A-Z]{2} \n\ + \ The vatNumber input parameter must follow the pattern [0-9A-Za-z\\+\\*\\\ + .]{2,12} \n In case of problems, the returned FaultString can take the following\ + \ specific values: \n - INVALID_INPUT: The provided CountryCode is invalid\ + \ or the VAT number is empty; \n - GLOBAL_MAX_CONCURRENT_REQ: Your Request\ + \ for VAT validation has not been processed; the maximum number of concurrent\ + \ requests has been reached. Please re-submit your request later or contact\ + \ TAXUD-VIESWEB@ec.europa.eu for further information\": Your request cannot\ + \ be processed due to high traffic on the web application. Please try again\ + \ later; \n - MS_MAX_CONCURRENT_REQ: Your Request for VAT validation has\ + \ not been processed; the maximum number of concurrent requests for this Member\ + \ State has been reached. Please re-submit your request later or contact TAXUD-VIESWEB@ec.europa.eu\ + \ for further information\": Your request cannot be processed due to high traffic\ + \ towards the Member State you are trying to reach. Please try again later.\ + \ \n - SERVICE_UNAVAILABLE: an error was encountered either at the network\ + \ level or the Web application level, try again later; \n - MS_UNAVAILABLE:\ + \ The application at the Member State is not replying or not available. Please\ + \ refer to the Technical Information page to check the status of the requested\ + \ Member State, try again later; \n - TIMEOUT: The application did not receive\ + \ a reply within the allocated 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 2a17c428..ad54574b 100644 --- a/subscriptions/tests/base.py +++ b/subscriptions/tests/base.py @@ -13,6 +13,8 @@ import looper.models import users.tests.util as util +responses_dir = 'subscriptions/tests/_responses/' + def _write_mail(mail, index=0): email = mail.outbox[index] @@ -24,7 +26,20 @@ 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 ( @@ -37,6 +52,8 @@ class BaseSubscriptionTestCase(TestCase): @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/') @@ -58,6 +75,13 @@ class BaseSubscriptionTestCase(TestCase): 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') @@ -97,13 +121,11 @@ class BaseSubscriptionTestCase(TestCase): self.assertContains(response, 'id_street_address') self.assertContains(response, 'id_full_name') self.assertContains(response, 'name="gateway" value="stripe"') + + def _assert_pay_via_bank_not_displayed(self, response): self.assertNotContains(response, 'name="gateway" value="bank"') - def _assert_billing_details_form_with_pay_via_bank_displayed(self, response): - self._assert_continue_to_payment_displayed(response) - self.assertContains(response, 'id_street_address') - self.assertContains(response, 'id_full_name') - self.assertContains(response, 'name="gateway" value="stripe"') + def _assert_pay_via_bank_displayed(self, response): self.assertContains(response, 'name="gateway" value="bank"') def _assert_pricing_has_been_updated(self, response): diff --git a/subscriptions/tests/test_clock.py b/subscriptions/tests/test_clock.py index 8a637efa..d60fcac5 100644 --- a/subscriptions/tests/test_clock.py +++ b/subscriptions/tests/test_clock.py @@ -51,6 +51,9 @@ class TestClockBraintree(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( diff --git a/subscriptions/views/join.py b/subscriptions/views/join.py index 13319a59..bf1a4069 100644 --- a/subscriptions/views/join.py +++ b/subscriptions/views/join.py @@ -90,6 +90,7 @@ class JoinView(LoginRequiredMixin, FormView): form_kwargs.update( { 'request': self.request, + 'plan_variation': self.plan_variation, 'instance': self.customer.billing_address, } ) @@ -103,11 +104,6 @@ class JoinView(LoginRequiredMixin, FormView): 'subscription': self.subscription, } - def gateway_from_form(self, form) -> looper.models.Gateway: - """Use Stripe by default, but allow bank transfer payments for manual plan variations.""" - self.gateway = looper.models.Gateway.objects.get(name=form.cleaned_data['gateway']) - return self.gateway - def _get_or_create_subscription( self, gateway: looper.models.Gateway ) -> looper.models.Subscription: @@ -116,8 +112,8 @@ class JoinView(LoginRequiredMixin, FormView): if not subscription: subscription = looper.models.Subscription(customer=self.customer) is_new = True - args = [self.customer.pk, gateway] - logger.debug('Creating a new subscription for customer pk=%s, %s', *args) + 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 supported = set(gateway.provider.supported_collection_methods) if collection_method not in supported: @@ -136,6 +132,15 @@ class JoinView(LoginRequiredMixin, FormView): 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 @@ -185,7 +190,7 @@ class JoinView(LoginRequiredMixin, FormView): messages.add_message(self.request, messages.INFO, msg) return self.form_invalid(form) - gateway = self.gateway_from_form(form) + 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 @@ -193,6 +198,8 @@ class JoinView(LoginRequiredMixin, FormView): 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) diff --git a/subscriptions/views/tests/test_join.py b/subscriptions/views/tests/test_join.py index f26df0c7..5a722c3f 100644 --- a/subscriptions/views/tests/test_join.py +++ b/subscriptions/views/tests/test_join.py @@ -15,8 +15,8 @@ from looper.money import Money import looper.models from looper.tests.factories import create_customer_with_billing_address -from common.tests.factories.users import UserFactory -from subscriptions.tests.base import BaseSubscriptionTestCase +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 @@ -58,7 +58,7 @@ class TestGETJoinView(BaseSubscriptionTestCase): response = self.client.get(url, REMOTE_ADDR=EURO_IPV4) self.assertEqual(response.status_code, 200) - self._assert_billing_details_form_with_pay_via_bank_displayed(response) + 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') @@ -68,6 +68,7 @@ class TestGETJoinView(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, '', @@ -183,13 +184,38 @@ class TestGETJoinView(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') -@responses.activate class TestPOSTJoinView(BaseSubscriptionTestCase): url_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1}) url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2}) - responses._add_from_file(f'{responses_dir}stripe_create_checkout_session.yaml') + + 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): customer = create_customer_with_billing_address(vat_number='', country='DE') @@ -204,7 +230,7 @@ class TestPOSTJoinView(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}, @@ -227,31 +253,27 @@ class TestPOSTJoinView(BaseSubscriptionTestCase): self.assertContains(response, 'Manual ') self.assertContains(response, '/ 1 year', html=True) - # @_recorder.record(file_path='stripe_create_checkout_session.yaml') - 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) - 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'], - 'https://checkout.stripe.com/c/pay/cs_test_a1hoP4Yj4ZmfghAwGoUtWJngVt1XreEVLGAj2' - 'n7U5o9BlvqhnDimuA07zh#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8Zkx' - 'sUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp' - '%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabH' - 'FgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl', - ) + 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) @@ -259,6 +281,7 @@ class TestPOSTJoinView(BaseSubscriptionTestCase): self.user.refresh_from_db() 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') @@ -273,18 +296,8 @@ class TestPOSTJoinView(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): customer = create_customer_with_billing_address( @@ -304,6 +317,7 @@ class TestPOSTJoinView(BaseSubscriptionTestCase): # Post an new address that doesn't require a region data = { **required_address_data, + 'gateway': 'stripe', 'country': 'DE', 'postal_code': '11111', } @@ -329,77 +343,41 @@ class TestPOSTJoinView(BaseSubscriptionTestCase): 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) - customer = create_customer_with_billing_address() - user = customer.user - 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) - customer = create_customer_with_billing_address(country='NL') - user = customer.user - 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) customer = create_customer_with_billing_address(country='NL') - user = customer.user - self.client.force_login(user) + 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) - customer = create_customer_with_billing_address(country='NL') - user = customer.user - 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) customer = create_customer_with_billing_address(country='NL') @@ -435,8 +413,9 @@ class TestPOSTJoinView(BaseSubscriptionTestCase): ) @responses.activate def test_pay_with_bank_transfer_creates_order_subscription_on_hold(self): - customer = 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 @@ -448,12 +427,7 @@ class TestPOSTJoinView(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) @@ -513,10 +487,17 @@ class TestPOSTJoinView(BaseSubscriptionTestCase): def test_pay_with_bank_transfer_creates_order_subscription_on_hold_shows_reverse_charged_price( self, ): - customer = 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 @@ -528,12 +509,7 @@ class TestPOSTJoinView(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) @@ -547,6 +523,8 @@ class TestPOSTJoinView(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') @@ -559,6 +537,8 @@ class TestPOSTJoinView(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) @@ -567,6 +547,7 @@ class TestPOSTJoinView(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) @@ -595,35 +576,44 @@ class TestPOSTJoinView(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) 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.customer.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) @@ -644,7 +634,6 @@ class TestPOSTJoinView(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', @@ -653,21 +642,35 @@ class TestPOSTJoinView(BaseSubscriptionTestCase): ) 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) @@ -698,19 +701,46 @@ class TestPOSTJoinView(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.customer.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)) diff --git a/subscriptions/views/tests/test_settings.py b/subscriptions/views/tests/test_settings.py index c710ac12..5fd34e11 100644 --- a/subscriptions/views/tests/test_settings.py +++ b/subscriptions/views/tests/test_settings.py @@ -10,7 +10,7 @@ import responses # from responses import _recorder from common.tests.factories.users import UserFactory -from subscriptions.tests.base import BaseSubscriptionTestCase +from subscriptions.tests.base import BaseSubscriptionTestCase, responses_from_file import subscriptions.tasks responses_dir = 'subscriptions/tests/_responses/' @@ -104,8 +104,8 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase): url_name = 'subscriptions:payment-method-change' success_url_name = 'user-settings-billing' - # @_recorder.record(file_path=f'{responses_dir}stripe_create_checkout_session_setup.yaml') - # @_recorder.record(file_path=f'{responses_dir}stripe_retrieve_checkout_session_setup.yaml') + # @_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( @@ -120,7 +120,7 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase): url = reverse(self.url_name, kwargs={'subscription_id': subscription.pk}) with responses.RequestsMock() as rsps: - rsps._add_from_file(f'{responses_dir}stripe_create_checkout_session_setup.yaml') + rsps._add_from_file(f'{responses_dir}stripe_new_cs_setup.yaml') response = self.client.post(url) self.assertEqual(response.status_code, 302) @@ -132,7 +132,7 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase): 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_retrieve_checkout_session_setup.yaml') + 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/') @@ -257,8 +257,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase): self.assertEqual(response.status_code, 404) - # @_recorder.record(file_path=f'{responses_dir}stripe_create_checkout_session_usd.yaml') - # @_recorder.record(file_path=f'{responses_dir}stripe_retrieve_checkout_session_usd.yaml') + # @_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', @@ -280,7 +280,7 @@ class TestPayExistingOrder(BaseSubscriptionTestCase): url = reverse(self.url_name, kwargs={'order_id': order.pk}) with responses.RequestsMock() as rsps: - rsps._add_from_file(f'{responses_dir}stripe_create_checkout_session_usd.yaml') + rsps._add_from_file(f'{responses_dir}stripe_new_cs_usd.yaml') response = self.client.get(url) self.assertEqual(response.status_code, 302) @@ -296,13 +296,7 @@ class TestPayExistingOrder(BaseSubscriptionTestCase): ) url = url.replace('CHECKOUT_SESSION_ID', checkout_session_id) with responses.RequestsMock() as rsps: - rsps._add_from_file(f'{responses_dir}stripe_retrieve_checkout_session_usd.yaml') - # 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.pk}') + 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) -- 2.30.2 From e44567a8b1efa139319891fbeed10b9ea44fef40 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 14 Jun 2024 16:20:46 +0200 Subject: [PATCH 36/49] Unintended change --- subscriptions/templates/settings/billing_address.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/subscriptions/templates/settings/billing_address.html b/subscriptions/templates/settings/billing_address.html index 5687bd98..158b849e 100644 --- a/subscriptions/templates/settings/billing_address.html +++ b/subscriptions/templates/settings/billing_address.html @@ -5,7 +5,7 @@

Settings: Subscription

Billing Address

-
+ {% with form|add_form_classes as form %}
{% csrf_token %} {% include "subscriptions/components/billing_address_form.html" %} -- 2.30.2 From 55b7565f55d395cf4183ce6879ada52b64936d3a Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 14 Jun 2024 16:21:09 +0200 Subject: [PATCH 37/49] Tests: shorten VIES response mock --- subscriptions/tests/_responses/vies_wsdl.yaml | 59 +------------------ 1 file changed, 1 insertion(+), 58 deletions(-) diff --git a/subscriptions/tests/_responses/vies_wsdl.yaml b/subscriptions/tests/_responses/vies_wsdl.yaml index a60138d4..1f79b9d9 100644 --- a/subscriptions/tests/_responses/vies_wsdl.yaml +++ b/subscriptions/tests/_responses/vies_wsdl.yaml @@ -7,64 +7,7 @@ responses: urn:ec.europa.eu:taxud:vies:services:checkVat\" xmlns:apachesoap=\"http://xml.apache.org/xml-soap\"\ \ xmlns:wsdl=\"http://schemas.xmlsoap.org/wsdl/\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\"\ \ xmlns:wsdlsoap=\"http://schemas.xmlsoap.org/wsdl/soap/\">\n \n\ - \t The objective of this Internet site is to allow persons involved in the intra-Community\ - \ supply of goods or of services to obtain confirmation of the validity of the\ - \ VAT identification number of any specified person, in accordance to article\ - \ 31 of Council Regulation (EC) No. 904/2010 of 7 October 2010.\n Any other\ - \ use and any extraction and use of the data which is not in conformity with\ - \ the objective of this site is strictly forbidden. \n Any retransmission\ - \ of the contents of this site, whether for a commercial purpose or otherwise,\ - \ as well as any more general use other than as far as is necessary to support\ - \ the activity of a legitimate user (for example: to draw up their own invoices)\ - \ is expressly forbidden. In addition, any copying or reproduction of the contents\ - \ of this site is strictly forbidden. \n The European Commission maintains\ - \ this website to enhance the access by taxable persons making intra-Community\ - \ supplies to verification of their customers' VAT identification numbers. Our\ - \ goal is to supply instantaneous and accurate information. \n However the\ - \ Commission accepts no responsibility or liability whatsoever with regard to\ - \ the information obtained using this site. This information: \n - is obtained\ - \ from Member States' databases over which the Commission services have no control\ - \ and for which the Commission assumes no responsibility; it is the responsibility\ - \ of the Member States to keep their databases complete, accurate and up to\ - \ date; \n - is not professional or legal advice (if you need specific advice,\ - \ you should always consult a suitably qualified professional); \n - does\ - \ not in itself give a right to exempt intra-Community supplies from Value Added\ - \ Tax; \n - does not change any obligations imposed on taxable persons in\ - \ relation to intra-Community supplies. \n It is our goal to minimise disruption\ - \ caused by technical errors. However some data or information on our site may\ - \ have been created or structured in files or formats which are not error-free\ - \ and we cannot guarantee that our service will not be interrupted or otherwise\ - \ affected by such problems. The Commission accepts no responsibility with regard\ - \ to such problems incurred as a result of using this site or any linked external\ - \ sites. \n This disclaimer is not intended to limit the liability of the\ - \ Commission in contravention of any requirements laid down in applicable national\ - \ law nor to exclude its liability for matters which may not be excluded under\ - \ that law. \n Collecting or handling personal data falls under the Data\ - \ Protection Notice. This data protection declaration explains the Processing\ - \ in the VIES-on-the-web Internet Website of VAT Identification Numbers for\ - \ intra-Community Transaction on Goods or Services. Details of your legal rights\ - \ associated with the collection, processing and use of this data are also provided:\ - \ http://ec.europa.eu/dpo-register/details.htm?id=40647 . \n \n Usage:\ - \ \n The countryCode input parameter must follow the pattern [A-Z]{2} \n\ - \ The vatNumber input parameter must follow the pattern [0-9A-Za-z\\+\\*\\\ - .]{2,12} \n In case of problems, the returned FaultString can take the following\ - \ specific values: \n - INVALID_INPUT: The provided CountryCode is invalid\ - \ or the VAT number is empty; \n - GLOBAL_MAX_CONCURRENT_REQ: Your Request\ - \ for VAT validation has not been processed; the maximum number of concurrent\ - \ requests has been reached. Please re-submit your request later or contact\ - \ TAXUD-VIESWEB@ec.europa.eu for further information\": Your request cannot\ - \ be processed due to high traffic on the web application. Please try again\ - \ later; \n - MS_MAX_CONCURRENT_REQ: Your Request for VAT validation has\ - \ not been processed; the maximum number of concurrent requests for this Member\ - \ State has been reached. Please re-submit your request later or contact TAXUD-VIESWEB@ec.europa.eu\ - \ for further information\": Your request cannot be processed due to high traffic\ - \ towards the Member State you are trying to reach. Please try again later.\ - \ \n - SERVICE_UNAVAILABLE: an error was encountered either at the network\ - \ level or the Web application level, try again later; \n - MS_UNAVAILABLE:\ - \ The application at the Member State is not replying or not available. Please\ - \ refer to the Technical Information page to check the status of the requested\ - \ Member State, try again later; \n - TIMEOUT: The application did not receive\ - \ a reply within the allocated time period, try again later. \n\t\n\ + \ blah blah blah time period, try again later. \n\t\n\ \ \n \n \n\t\t\t Date: Fri, 14 Jun 2024 16:24:10 +0200 Subject: [PATCH 38/49] Minor changes to tests --- subscriptions/tests/_responses/stripe_get_cs_eur.yaml | 4 ++-- subscriptions/tests/_responses/stripe_get_cs_setup.yaml | 2 +- subscriptions/tests/_responses/stripe_get_cs_usd.yaml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/subscriptions/tests/_responses/stripe_get_cs_eur.yaml b/subscriptions/tests/_responses/stripe_get_cs_eur.yaml index 74efbd3e..0853077d 100644 --- a/subscriptions/tests/_responses/stripe_get_cs_eur.yaml +++ b/subscriptions/tests/_responses/stripe_get_cs_eur.yaml @@ -29,7 +29,7 @@ responses: : 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_BClI8ssVIUSjybh2UvIeFa6v0\"\ + 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\"\ @@ -82,7 +82,7 @@ responses: 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/CAcaFwoVYWNjdF8xUE9iMjZFNEtBVUI1ZGpzKPOJsLMGMgbH3ZicbJc6LBYdfpRMBgTew5GCKCPV-K4DC44oir_sd03RTaqsJpsM5qstpEWJU0oqii03\"\ + : 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\ diff --git a/subscriptions/tests/_responses/stripe_get_cs_setup.yaml b/subscriptions/tests/_responses/stripe_get_cs_setup.yaml index e44e0e1c..97d35be8 100644 --- a/subscriptions/tests/_responses/stripe_get_cs_setup.yaml +++ b/subscriptions/tests/_responses/stripe_get_cs_setup.yaml @@ -26,7 +26,7 @@ responses: : {\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\": \"seti_1PQ72HE4KAUB5djsPthAKRdZ_secret_QGeKD74KJNAouKtjbevE1O3dwVKxxt7\"\ + : 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 \"\ diff --git a/subscriptions/tests/_responses/stripe_get_cs_usd.yaml b/subscriptions/tests/_responses/stripe_get_cs_usd.yaml index 1c537f40..e2ed2e9b 100644 --- a/subscriptions/tests/_responses/stripe_get_cs_usd.yaml +++ b/subscriptions/tests/_responses/stripe_get_cs_usd.yaml @@ -29,7 +29,7 @@ responses: : 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_Phx5lFDf54GRwi2NpHGJBYB8Q\"\ + 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\"\ @@ -73,7 +73,7 @@ responses: 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/CAcaFwoVYWNjdF8xUE9iMjZFNEtBVUI1ZGpzKMzGnLMGMgaXZmL8WPc6LBa5Ndm2RPGLSlKNdxfFTIgoO_Z2BsGsdDaSQlb1ZFU2skexQxL9D0JFnSV9\"\ + \ \"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\ -- 2.30.2 From 2d2b52f2204ae9941db8c9eaea5570190efdcf62 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 14 Jun 2024 16:27:28 +0200 Subject: [PATCH 39/49] Use "info" for bank transfer tooltip --- subscriptions/templates/subscriptions/join/billing_address.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/subscriptions/templates/subscriptions/join/billing_address.html b/subscriptions/templates/subscriptions/join/billing_address.html index c8048b46..f1df9208 100644 --- a/subscriptions/templates/subscriptions/join/billing_address.html +++ b/subscriptions/templates/subscriptions/join/billing_address.html @@ -59,7 +59,7 @@
- +
{% endif %} -- 2.30.2 From 33e3e06b8388fa8116929cb32da930a35dfdc897 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 14 Jun 2024 16:31:33 +0200 Subject: [PATCH 40/49] Minor change --- subscriptions/signals.py | 39 +++++++++++++++++++++++++++++++++++++++ users/signals.py | 39 --------------------------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/subscriptions/signals.py b/subscriptions/signals.py index 282bd2e0..1eccc76f 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 @@ -29,6 +30,44 @@ def timebased_order_number(): return ats.base36.now(time_unit=ats.TimeUnit.milliseconds).upper() +@receiver(django_signals.post_save, sender=User) +def create_customer(sender, instance: User, created, raw, **kwargs): + """Create Customer on User creation.""" + from looper.models import Customer + + if raw: + return + + if not created: + return + + 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) def _set_order_number(sender, instance: Order, **kwargs): if instance.pk or instance.number or instance.is_legacy: diff --git a/users/signals.py b/users/signals.py index 14de8f5a..a8f3d8a9 100644 --- a/users/signals.py +++ b/users/signals.py @@ -4,7 +4,6 @@ import logging from actstream.models import Action from anymail.signals import tracking from django.contrib.auth import get_user_model -from django.db import transaction from django.db.models.signals import pre_save, post_save from django.dispatch import receiver from django.utils.dateparse import parse_datetime @@ -69,44 +68,6 @@ def _sync_is_subscribed_to_newsletter(sender: object, instance: User, **kwargs): tasks.handle_is_subscribed_to_newsletter(pk=instance.pk) -@receiver(post_save, sender=User) -def create_customer(sender, instance: User, created, raw, **kwargs): - """Create Customer on User creation.""" - from looper.models import Customer - - if raw: - return - - if not created: - return - - try: - customer = instance.customer - except Customer.DoesNotExist: - pass - else: - logger.debug( - 'Newly created User %d already has a Customer %d, not creating new one', - instance.pk, - customer.pk, - ) - billing_address = customer.billing_address - logger.info('Creating new billing address due to creation of user %s', instance.pk) - if not billing_address.pk: - billing_address.email = instance.email - billing_address.full_name = instance.full_name - billing_address.save() - return - - logger.info('Creating new Customer due to creation of user %s', instance.pk) - with transaction.atomic(): - customer = Customer.objects.create(user=instance) - billing_address = customer.billing_address - billing_address.email = instance.email - billing_address.full_name = instance.full_name - billing_address.save() - - @receiver(tracking) def _handle_mailgun_tracking_event(sender, event, esp_name, **kwargs): event_type = event.event_type -- 2.30.2 From 9ac0293c980c9cfe53f8ce69f5490f30e706b414 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 14 Jun 2024 16:39:00 +0200 Subject: [PATCH 41/49] Minor change --- subscriptions/signals.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/subscriptions/signals.py b/subscriptions/signals.py index 1eccc76f..86e99b56 100644 --- a/subscriptions/signals.py +++ b/subscriptions/signals.py @@ -9,7 +9,7 @@ from django.dispatch import receiver import alphabetic_timestamp as ats import django.db.models.signals as django_signals -from looper.models import Order +from looper.models import Customer, Order import looper.admin_log import looper.signals @@ -33,8 +33,6 @@ def timebased_order_number(): @receiver(django_signals.post_save, sender=User) def create_customer(sender, instance: User, created, raw, **kwargs): """Create Customer on User creation.""" - from looper.models import Customer - if raw: return -- 2.30.2 From 3cd73f87d11ccefa186e2dbcd6a274a645d1d693 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 14 Jun 2024 17:05:10 +0200 Subject: [PATCH 42/49] Remove confusing assignment --- emails/admin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/emails/admin.py b/emails/admin.py index 951ab308..6d98ab7f 100644 --- a/emails/admin.py +++ b/emails/admin.py @@ -94,9 +94,8 @@ 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(full_name='Jane Doe') - customer = looper.models.Customer() + customer = looper.models.Customer(user=user) user.customer = customer - customer.user = user now = timezone.now() subscription = looper.models.Subscription( id=1234567890, -- 2.30.2 From bb45dda51f44d01bcac9648ad0bdb8621d6f7b8e Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 14 Jun 2024 17:19:00 +0200 Subject: [PATCH 43/49] Change subscription queries --- subscriptions/queries.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/subscriptions/queries.py b/subscriptions/queries.py index 3d4e8af9..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(customer__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() @@ -34,7 +34,7 @@ def has_non_legacy_subscription(user: User) -> bool: subscriptions: 'QuerySet[Subscription]' = Subscription.objects.filter(is_legacy=False) return subscriptions.filter( - Q(customer__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() @@ -44,7 +44,7 @@ def has_subscription(user: User) -> bool: return False return Subscription.objects.filter( - Q(customer__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() @@ -79,4 +79,4 @@ def has_not_yet_cancelled_subscription(user: User) -> bool: status__in=Subscription._CANCELLED_STATUSES ) - return not_yet_cancelled_subscriptions.filter(Q(customer__user_id=user.id)).exists() + return not_yet_cancelled_subscriptions.filter(Q(customer=user.customer)).exists() -- 2.30.2 From a68320d744ed99ac0bd1fcb3ca91289e96b1c603 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 14 Jun 2024 17:29:56 +0200 Subject: [PATCH 44/49] Always expect customers to have an account in signals --- subscriptions/signals.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/subscriptions/signals.py b/subscriptions/signals.py index 86e99b56..4f520d63 100644 --- a/subscriptions/signals.py +++ b/subscriptions/signals.py @@ -78,9 +78,6 @@ def _set_order_number(sender, instance: Order, **kwargs): def _on_subscription_created_needs_payment(sender: looper.models.Subscription, **kwargs): tasks.send_mail_bank_transfer_required(subscription_id=sender.pk) user = sender.customer.user - if not user: - logger.error('Cannot grant role to an account-less customer pk=%s', sender.customer_id) - return users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription') @@ -93,9 +90,6 @@ 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): user = sender.customer.user - if not user: - logger.error('Cannot grant role to an account-less customer pk=%s', sender.customer_id) - return users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription') users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_subscriber') @@ -111,7 +105,7 @@ def _on_subscription_status_activated(sender: looper.models.Subscription, **kwar def _on_subscription_status_deactivated(sender: looper.models.Subscription, **kwargs): # No other active subscription exists, subscriber badge can be revoked user = sender.customer.user - if user and not queries.has_active_subscription(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'): @@ -151,7 +145,7 @@ def _on_subscription_expired(sender: looper.models.Subscription, **kwargs): # Only send a "subscription expired" email when there are no other active subscriptions user = sender.customer.user - if user and not queries.has_active_subscription(user): + if not queries.has_active_subscription(user): tasks.send_mail_subscription_expired(subscription_id=sender.pk) -- 2.30.2 From 504344a0e8584394f147ce8103833ffd35dc4ae6 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 14 Jun 2024 17:58:56 +0200 Subject: [PATCH 45/49] Minor change --- subscriptions/views/join.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/subscriptions/views/join.py b/subscriptions/views/join.py index bf1a4069..0aedc2e2 100644 --- a/subscriptions/views/join.py +++ b/subscriptions/views/join.py @@ -115,11 +115,10 @@ class JoinView(LoginRequiredMixin, FormView): 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 - supported = set(gateway.provider.supported_collection_methods) - if collection_method not in supported: + 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 = supported.pop() + collection_method = next(iter(gateway.provider.supported_collection_methods)) with transaction.atomic(): subscription.plan = self.plan_variation.plan -- 2.30.2 From ac08acae00e9dcb2db5b3de77c05aed6e793696d Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 14 Jun 2024 18:13:22 +0200 Subject: [PATCH 46/49] Remove unused code --- subscriptions/views/mixins.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/subscriptions/views/mixins.py b/subscriptions/views/mixins.py index ac43f43e..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 @@ -47,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, 'customer'): - 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.""" -- 2.30.2 From 3d70b43ced204b9ad255513d73fd77bc212a4504 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Fri, 14 Jun 2024 18:22:55 +0200 Subject: [PATCH 47/49] Remove another unused template --- docs/web-assets-migrate-guidelines.md | 1 - .../components/current_plan_variation.html | 23 ------------------- 2 files changed, 24 deletions(-) delete mode 100644 subscriptions/templates/subscriptions/components/current_plan_variation.html diff --git a/docs/web-assets-migrate-guidelines.md b/docs/web-assets-migrate-guidelines.md index 3b90b650..1048df14 100644 --- a/docs/web-assets-migrate-guidelines.md +++ b/docs/web-assets-migrate-guidelines.md @@ -249,7 +249,6 @@ Symbol | Description │   ├── 🔴 bank_transfer_details.txt │   ├── 🔴 bank_transfer_reference.txt │   ├── 🟢 billing_address_form.html -│   ├── 🟢 current_plan_variation.html │   ├── 🟢 footer.html │   ├── ❌ header_jumbotron.html │   ├── 🟢 info.html 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 }}

-
-
-
-- 2.30.2 From 59012cf5ff1f235e40110d17465ca670f6d13f6a Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 17 Jun 2024 15:36:03 +0200 Subject: [PATCH 48/49] Playbooks: add empty robots.txt, add missing favicon.ico Applied manually, checked with ./ansible.sh -i environments/production install.yaml --diff --tags=nginx --check --- playbooks/templates/nginx/http.conf | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/playbooks/templates/nginx/http.conf b/playbooks/templates/nginx/http.conf index a89ddb52..4b295227 100644 --- a/playbooks/templates/nginx/http.conf +++ b/playbooks/templates/nginx/http.conf @@ -25,6 +25,16 @@ server { rewrite ^/wing-it /films/wing-it permanent; rewrite ^/films/pet-projects/(.*)$ /films/wing-it/$1 permanent; + location /robots.txt { + add_header Content-Type text/plain; + return 200 ""; + } + + location /favicon.ico { + root {{ dir.static }}/common/images/favicon; + } + + include {{ dir.config }}/studio.common.conf; location / { -- 2.30.2 From 69c59c3d47abf3b2e58ce3f352d708356e186ca8 Mon Sep 17 00:00:00 2001 From: Anna Sirota Date: Mon, 17 Jun 2024 18:03:29 +0200 Subject: [PATCH 49/49] Deps: update looper --- poetry.lock | 6 +++--- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 36d5d054..20edfea0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1329,8 +1329,8 @@ zeep = "4.0.0" [package.source] type = "git" url = "https://projects.blender.org/infrastructure/looper.git" -reference = "e3190f3e19" -resolved_reference = "e3190f3e19548949cc01c17fbe1168c1b5c55f7a" +reference = "8cd4da9" +resolved_reference = "8cd4da950b21b85e0a5ecfa8e8e3d62774215a67" [[package]] name = "lxml" @@ -2957,4 +2957,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "01778defae9b5d605eff86fe5dcd70376622fb35a67353dd9678c093de86137d" +content-hash = "69a6d187007cb4d99d90cf4472b9295a03c6c18518efcea4b6c50dc0d3dfef9f" diff --git a/pyproject.toml b/pyproject.toml index b43482af..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 = "e3190f3e19"} +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" -- 2.30.2
{% if subscription.team %}Team {% endif %}Subscription #{{ subscription.pk }} {% include "subscriptions/components/pretty_status.html" %}