diff --git a/.env.example b/.env.example index 955d37fe..476a0f00 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,8 @@ MAILGUN_API_KEY= MAILGUN_WEBHOOK_SIGNING_KEY= MAILGUN_WEBHOOK_SECRET= -GOOGLE_RECAPTCHA_SITE_KEY= -GOOGLE_RECAPTCHA_SECRET_KEY= GOOGLE_ANALYTICS_TRACKING_ID= + +STRIPE_API_PUBLISHABLE_KEY= +STRIPE_API_SECRET_KEY= +STRIPE_ENDPOINT_SECRET= diff --git a/characters/tests/test_characters.py b/characters/tests/test_characters.py index a468aa07..2c1af2c4 100644 --- a/characters/tests/test_characters.py +++ b/characters/tests/test_characters.py @@ -174,7 +174,7 @@ class TestCharacterVersionDownload(TestCase): def test_can_download_non_free_when_subscribed(self): user = UserFactory() - SubscriptionFactory(user=user, status='active') + SubscriptionFactory(customer=user.customer, status='active') character_version = CharacterVersionFactory(is_free=False) self.client.force_login(user) @@ -222,7 +222,7 @@ class TestCharacterShowcaseDownload(TestCase): def test_can_download_non_free_when_subscribed(self): user = UserFactory() - SubscriptionFactory(user=user, status='active') + SubscriptionFactory(customer=user.customer, status='active') character_showcase = CharacterShowcaseFactory(is_free=False) self.client.force_login(user) diff --git a/common/context_processors.py b/common/context_processors.py index caccb012..921a05ab 100644 --- a/common/context_processors.py +++ b/common/context_processors.py @@ -28,5 +28,4 @@ def extra_context(request: HttpRequest) -> Dict[str, str]: }, 'canonical_url': request.build_absolute_uri(request.path), 'ADMIN_MAIL': settings.ADMIN_MAIL, - 'GOOGLE_RECAPTCHA_SITE_KEY': settings.GOOGLE_RECAPTCHA_SITE_KEY, } diff --git a/common/static/common/styles/studio/_custom.sass b/common/static/common/styles/studio/_custom.sass index 3eb2fc43..2ad270d8 100644 --- a/common/static/common/styles/studio/_custom.sass +++ b/common/static/common/styles/studio/_custom.sass @@ -714,3 +714,19 @@ button, &.rounded-lg .plyr border-radius: var(--border-radius-lg) + + +/* Simple CSS-only tooltips. */ +[data-tooltip] + &:hover + &:before, &:after + display: block + position: absolute + color: var(--color-text-primary) + &:before + border-radius: var(--spacer-1) + content: attr(title) + background-color: var(--color-bg-primary-subtle) + margin-top: var(--spacer) + padding: var(--spacer) + font-size: var(--fs-sm) diff --git a/common/tests/factories/subscriptions.py b/common/tests/factories/subscriptions.py index 5fb2a7cd..db56328e 100644 --- a/common/tests/factories/subscriptions.py +++ b/common/tests/factories/subscriptions.py @@ -1,53 +1,10 @@ -from django.contrib.auth import get_user_model -from django.db.models import signals - from factory.django import DjangoModelFactory import factory -import looper.models +from looper.tests.factories import SubscriptionFactory -from common.tests.factories.users import UserFactory from subscriptions.models import Team -User = get_user_model() - - -class PaymentMethodFactory(DjangoModelFactory): - class Meta: - model = looper.models.PaymentMethod - - gateway_id = factory.LazyAttribute(lambda _: looper.models.Gateway.objects.first().pk) - - user = factory.SubFactory('common.tests.factories.users.UserFactory') - - -class SubscriptionFactory(DjangoModelFactory): - class Meta: - model = looper.models.Subscription - - plan_id = factory.LazyAttribute(lambda _: looper.models.Plan.objects.first().pk) - payment_method = factory.SubFactory(PaymentMethodFactory) - - user = factory.SubFactory('common.tests.factories.users.UserFactory') - - -class OrderFactory(DjangoModelFactory): - class Meta: - model = looper.models.Order - - user = factory.SubFactory('common.tests.factories.users.UserFactory') - subscription = factory.SubFactory(SubscriptionFactory) - payment_method = factory.SubFactory(PaymentMethodFactory) - - -class TransactionFactory(DjangoModelFactory): - class Meta: - model = looper.models.Transaction - - user = factory.SubFactory('common.tests.factories.users.UserFactory') - order = factory.SubFactory(OrderFactory) - payment_method = factory.SubFactory(PaymentMethodFactory) - class TeamFactory(DjangoModelFactory): class Meta: @@ -55,38 +12,3 @@ class TeamFactory(DjangoModelFactory): name = factory.Faker('text', max_nb_chars=15) subscription = factory.SubFactory(SubscriptionFactory) - - -@factory.django.mute_signals(signals.pre_save, signals.post_save) -class CustomerFactory(DjangoModelFactory): - class Meta: - model = looper.models.Customer - - billing_email = factory.LazyAttribute(lambda o: '%s.billing@example.com' % o.user.username) - user = factory.SubFactory('common.tests.factories.users.UserFactory') - - -@factory.django.mute_signals(signals.pre_save, signals.post_save) -class AddressFactory(DjangoModelFactory): - class Meta: - model = looper.models.Address - - user = factory.SubFactory('common.tests.factories.users.UserFactory') - - -# TODO(anna): this should probably move to looper -@factory.django.mute_signals(signals.pre_save, signals.post_save) -def create_customer_with_billing_address(**data): - """Use factories to create a User with a Customer and Address records.""" - customer_field_names = {f.name for f in looper.models.Customer._meta.get_fields()} - address_field_names = {f.name for f in looper.models.Address._meta.get_fields()} - user_field_names = {f.name for f in User._meta.get_fields()} - - address_kwargs = {k: v for k, v in data.items() if k in address_field_names} - customer_kwargs = {k: v for k, v in data.items() if k in customer_field_names} - user_kwargs = {k: v for k, v in data.items() if k in user_field_names} - - user = UserFactory(**user_kwargs) - AddressFactory(user=user, **address_kwargs) - CustomerFactory(user=user, **customer_kwargs) - return user diff --git a/docs/web-assets-migrate-guidelines.md b/docs/web-assets-migrate-guidelines.md index 6c45afa1..1048df14 100644 --- a/docs/web-assets-migrate-guidelines.md +++ b/docs/web-assets-migrate-guidelines.md @@ -249,8 +249,6 @@ Symbol | Description │   ├── 🔴 bank_transfer_details.txt │   ├── 🔴 bank_transfer_reference.txt │   ├── 🟢 billing_address_form.html -│   ├── 🟢 billing_address_form_readonly.html -│   ├── 🟢 current_plan_variation.html │   ├── 🟢 footer.html │   ├── ❌ header_jumbotron.html │   ├── 🟢 info.html @@ -265,11 +263,8 @@ Symbol | Description │   └── 🟢 total.html ├── join │   ├── 🟢 billing_address.html -│   ├── 🟢 payment_method.html │   └── ⚪ select_plan_variation.html ├── 🟢 manage.html -├── 🟢 pay_existing_order.html -├── 🟢 payment_method_change.html └── widgets └── ⚪ region_select.html ``` diff --git a/emails/admin.py b/emails/admin.py index 21102ac8..6d98ab7f 100644 --- a/emails/admin.py +++ b/emails/admin.py @@ -93,12 +93,13 @@ class SubscriptionEmailPreviewAdmin(looper.admin.mixins.NoAddDeleteMixin, EmailA def get_object(self, request, object_id, from_field=None): """Construct the Email on th fly from known subscription email templates.""" - user = User() - user.customer = looper.models.Customer(full_name='Jane Doe') + user = User(full_name='Jane Doe') + customer = looper.models.Customer(user=user) + user.customer = customer now = timezone.now() subscription = looper.models.Subscription( id=1234567890, - user=user, + customer=user.customer, payment_method=looper.models.PaymentMethod( method_type='cc', gateway_id=1, @@ -112,7 +113,7 @@ class SubscriptionEmailPreviewAdmin(looper.admin.mixins.NoAddDeleteMixin, EmailA ) order = looper.models.Order(subscription=subscription) context = { - 'user': subscription.user, + 'user': user, 'subscription': subscription, 'order': order, # Also add context for the expired email diff --git a/playbooks/templates/dotenv b/playbooks/templates/dotenv index d79d2c68..03368e16 100644 --- a/playbooks/templates/dotenv +++ b/playbooks/templates/dotenv @@ -54,6 +54,8 @@ MAILGUN_API_KEY= MAILGUN_WEBHOOK_SIGNING_KEY= MAILGUN_WEBHOOK_SECRET= -GOOGLE_RECAPTCHA_SITE_KEY= -GOOGLE_RECAPTCHA_SECRET_KEY= GOOGLE_ANALYTICS_TRACKING_ID= + +STRIPE_API_PUBLISHABLE_KEY= +STRIPE_API_SECRET_KEY= +STRIPE_ENDPOINT_SECRET= diff --git a/poetry.lock b/poetry.lock index 77825334..20edfea0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1300,7 +1300,7 @@ six = ">=1.11.0" [[package]] name = "looper" -version = "2.1.3" +version = "3.2.9" description = "" category = "main" optional = false @@ -1316,19 +1316,21 @@ braintree = "4.17.1" colorhash = "^1.0.3" django = "^2.2.0 || 3.0 || 3.0.* || 3.2.*" django-countries = "^7.2.1" +django-nested-admin = "^4.0.2" django-pipeline = "^2.0.6" geoip2 = "^3.0" python-dateutil = "^2.7" python-stdnum = "^1.16" requests = "^2.22" +stripe = "7.1.0" xhtml2pdf = "^0.2" zeep = "4.0.0" [package.source] type = "git" url = "https://projects.blender.org/infrastructure/looper.git" -reference = "10bca5a012" -resolved_reference = "10bca5a012f3deeede160b3520db630da6b9dfa5" +reference = "8cd4da9" +resolved_reference = "8cd4da950b21b85e0a5ecfa8e8e3d62774215a67" [[package]] name = "lxml" @@ -2687,6 +2689,22 @@ files = [ {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"}, ] +[[package]] +name = "stripe" +version = "7.1.0" +description = "Python bindings for the Stripe API" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "stripe-7.1.0-py2.py3-none-any.whl", hash = "sha256:efd1e54825752c41bb311497cb5b6ae745464a57ca63bbe2847984a2409bcb0a"}, + {file = "stripe-7.1.0.tar.gz", hash = "sha256:9cc2632230d5742eeb779af2b41c1510e724f498a296dfb40507de98d563f9a2"}, +] + +[package.dependencies] +requests = {version = ">=2.20", markers = "python_version >= \"3.0\""} +typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""} + [[package]] name = "tblib" version = "3.0.0" @@ -2783,14 +2801,14 @@ files = [ [[package]] name = "typing-extensions" -version = "4.4.0" -description = "Backported and Experimental Type Hints for Python 3.7+" +version = "4.12.1" +description = "Backported and Experimental Type Hints for Python 3.8+" category = "main" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, - {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, + {file = "typing_extensions-4.12.1-py3-none-any.whl", hash = "sha256:6024b58b69089e5a89c347397254e35f1bf02a907728ec7fee9bf0fe837d203a"}, + {file = "typing_extensions-4.12.1.tar.gz", hash = "sha256:915f5e35ff76f56588223f15fdd5938f9a1cf9195c0de25130c627e4d597f6d1"}, ] [[package]] @@ -2939,4 +2957,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "1039e10638790d46a95584572ec99691f740e74be5166fe4c86ebe52b4e953c5" +content-hash = "69a6d187007cb4d99d90cf4472b9295a03c6c18518efcea4b6c50dc0d3dfef9f" diff --git a/pyproject.toml b/pyproject.toml index 2f905855..0bc94d9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ libsasscompiler = "^0.1.5" jsmin = "3.0.0" sorl-thumbnail = "^12.10.0" mistune = "2.0.0a4" -looper = {git = "https://projects.blender.org/infrastructure/looper.git", rev = "10bca5a012"} +looper = {git = "https://projects.blender.org/infrastructure/looper.git", rev = "8cd4da9"} Pillow = "^8.0" django-storages = {extras = ["google"], version = "1.11.1"} pymongo = "^3.10.1" diff --git a/studio/settings.py b/studio/settings.py index 5331d441..75911632 100644 --- a/studio/settings.py +++ b/studio/settings.py @@ -504,6 +504,12 @@ GATEWAYS = { 'supported_collection_methods': {'automatic', 'manual'}, }, 'bank': {'supported_collection_methods': {'manual'}}, + 'stripe': { + 'api_publishable_key': _get('STRIPE_API_PUBLISHABLE_KEY'), + 'api_secret_key': _get('STRIPE_API_SECRET_KEY'), + 'endpoint_secret': _get('STRIPE_ENDPOINT_SECRET'), + 'supported_collection_methods': {'automatic', 'manual'}, + }, } # Optional Sentry configuration @@ -630,8 +636,6 @@ if MAILGUN_SENDER_DOMAIN: GEOIP2_DB = _get('GEOIP2_DB') GOOGLE_ANALYTICS_TRACKING_ID = _get('GOOGLE_ANALYTICS_TRACKING_ID') -GOOGLE_RECAPTCHA_SECRET_KEY = _get('GOOGLE_RECAPTCHA_SECRET_KEY') -GOOGLE_RECAPTCHA_SITE_KEY = _get('GOOGLE_RECAPTCHA_SITE_KEY') S3DIRECT_DESTINATIONS = { 'default': { @@ -676,5 +680,15 @@ S3DIRECT_DESTINATIONS = { }, } +# A list of payment method types used with stripe for setting a recurring payment: +# https://stripe.com/docs/api/checkout/sessions/create#create_checkout_session-payment_method_types +STRIPE_OFF_SESSION_PAYMENT_METHOD_TYPES = [ + 'card', + 'link', + 'paypal', +] + +STRIPE_CHECKOUT_SUBMIT_TYPE = 'pay' + # Maximum number of attempts for failing background tasks MAX_ATTEMPTS = 3 diff --git a/subscriptions/forms.py b/subscriptions/forms.py index 63076d68..92e806e4 100644 --- a/subscriptions/forms.py +++ b/subscriptions/forms.py @@ -5,13 +5,12 @@ import logging from django import forms from django.core.exceptions import ValidationError from django.forms.fields import Field -from django.forms.models import model_to_dict from localflavor.administrative_areas import ADMINISTRATIVE_AREAS from localflavor.generic.validators import validate_country_postcode +from looper.middleware import COUNTRY_CODE_SESSION_KEY from stdnum.eu import vat import localflavor.exceptions - import looper.form_fields import looper.forms import looper.models @@ -48,17 +47,11 @@ REQUIRED_FIELDS = { class BillingAddressForm(forms.ModelForm): - """Unify Customer and Address in a single form.""" - - # Customer.billing_email is exposed as email in the Form - # because Looper scripts and forms already use "email" everywhere. - __customer_fields = {'billing_email': 'email', 'vat_number': 'vat_number'} - # Colliding "full_name" and "company" values are taken from and saved to the Address. - # FIXME(anna): do we need to use company and full_name on the Customer or only Address? + """Fill in billing address and prepare for intitiating Stripe checkout session.""" class Meta: model = looper.models.Address - fields = looper.models.Address.PUBLIC_FIELDS + exclude = ['category', 'customer', 'tax_exempt'] # What kind of choices are allowed depends on the selected country # and is not yet known when the form is rendered. @@ -81,20 +74,6 @@ class BillingAddressForm(forms.ModelForm): def __init__(self, *args, **kwargs): """Load additional model data from Customer and set form placeholders.""" - instance: looper.models.Address = kwargs.get('instance') - if instance: - assert isinstance(instance, looper.models.Address), 'Must be an instance of Address' - customer = instance.user.customer - initial = kwargs.get('initial') or {} - customer_data = model_to_dict(customer, self.__customer_fields.keys(), {}) - # Remap the fields, e.g. turning "billing_email" into "email" - customer_form_data = {v: customer_data[k] for k, v in self.__customer_fields.items()} - # Add Customer data into initial, - # making sure that it still overrides the instance data, as it's supposed to - kwargs['initial'] = { - **customer_form_data, - **initial, - } super().__init__(*args, **kwargs) # Set placeholder values on all form fields @@ -160,43 +139,62 @@ class BillingAddressForm(forms.ModelForm): ) def save(self, commit=True): - """Save Customer data as well.""" + """Save cleared region field.""" # Validation against region choices is already done, because choices are set on __init__, # however Django won't set the updated blank region value if was omitted from the form. if self.cleaned_data['region'] == '': self.instance.region = '' instance = super().save(commit=commit) - - customer = instance.user.customer - for model_field, form_field in self.__customer_fields.items(): - setattr(customer, model_field, self.cleaned_data[form_field]) - if commit: - customer.save(update_fields=self.__customer_fields) return instance -class BillingAddressReadonlyForm(forms.ModelForm): - """Display the billing details in a payment form but neither validate nor update them. +class PaymentForm(BillingAddressForm): + """Handle PlanVariation ID and payment method details in the second step of the checkout. - Used in PaymentMethodChangeView and PayExistingOrderView. + Billing details are displayed as read-only and cannot be edited, + but are still used by the payment flow. """ - class Meta: - model = looper.models.Address - fields = looper.models.Address.PUBLIC_FIELDS + gateway = looper.form_fields.GatewayChoiceField( + queryset=looper.models.Gateway.objects.filter(name__in={'stripe', 'bank'}).order_by( + '-is_default' + ) + ) def __init__(self, *args, **kwargs): - """Disable all the billing details fields. + """Pre-fill additional initial data from request.""" + self.request = kwargs.pop('request', None) + self.plan_variation = kwargs.pop('plan_variation', None) - The billing details are only for display and for use by the payment flow. - """ super().__init__(*args, **kwargs) - for field_name, field in self.fields.items(): - if field_name not in BILLING_DETAILS_PLACEHOLDERS: - continue - field.disabled = True - email = forms.EmailField(required=False, disabled=True) + self._set_initial_from_request() + + def _set_initial_from_request(self): + if not self.request: + return + + # Only preset country when it's not already selected by the customer + geoip_country = self.request.session.get(COUNTRY_CODE_SESSION_KEY) + if geoip_country and (not self.instance.country): + self.initial['country'] = geoip_country + + # Only set initial values if they aren't already saved to the billing address. + # Initial values always override form data, which leads to confusing issues with views. + if not self.instance.full_name: + # Fall back to user's full name, if no full name set already in the billing address: + if self.request.user.full_name: + self.initial['full_name'] = self.request.user.full_name + + def clean_gateway(self): + """Validate gateway against selected plan variation.""" + gw = self.cleaned_data['gateway'] + if not self.plan_variation: + return gw + if self.plan_variation.collection_method not in gw.provider.supported_collection_methods: + msg = self.fields['gateway'].default_error_messages['invalid_choice'] + self.add_error('gateway', msg) + return gw class SelectPlanVariationForm(forms.Form): @@ -223,58 +221,6 @@ class SelectPlanVariationForm(forms.Form): ) -class PaymentForm(BillingAddressForm): - """Handle PlanVariation ID and payment method details in the second step of the checkout. - - Billing details are displayed as read-only and cannot be edited, - but are still used by the payment flow. - """ - - payment_method_nonce = forms.CharField(initial='set-in-javascript', widget=forms.HiddenInput()) - gateway = looper.form_fields.GatewayChoiceField() - device_data = forms.CharField( - initial='set-in-javascript', widget=forms.HiddenInput(), required=False - ) - - price = forms.CharField(widget=forms.HiddenInput(), required=True) - - # These are used when a payment fails, so that the next attempt to pay can reuse - # the already-created subscription and order. - subscription_pk = forms.CharField(widget=forms.HiddenInput(), required=False) - order_pk = forms.CharField(widget=forms.HiddenInput(), required=False) - - -class AutomaticPaymentForm(PaymentForm): - """Same as the PaymentForm, but only allows payment gateways that support transactions.""" - - gateway = looper.form_fields.GatewayChoiceField( - queryset=looper.models.Gateway.objects.filter( - name__in=looper.gateways.Registry.gateway_names_supports_transactions() - ) - ) - - -class PayExistingOrderForm(BillingAddressReadonlyForm): - """Same as AutomaticPaymentForm, but doesn't validate or update billing details.""" - - payment_method_nonce = forms.CharField(initial='set-in-javascript', widget=forms.HiddenInput()) - gateway = looper.form_fields.GatewayChoiceField( - queryset=looper.models.Gateway.objects.filter( - name__in=looper.gateways.Registry.gateway_names_supports_transactions() - ) - ) - device_data = forms.CharField( - initial='set-in-javascript', widget=forms.HiddenInput(), required=False - ) - price = forms.CharField(widget=forms.HiddenInput(), required=True) - - -class ChangePaymentMethodForm(BillingAddressReadonlyForm, looper.forms.ChangePaymentMethodForm): - """Add full billing address to the change payment form.""" - - pass - - class CancelSubscriptionForm(forms.Form): """Confirm cancellation of a subscription.""" diff --git a/subscriptions/migrations/0002_add_gateways.py b/subscriptions/migrations/0002_add_gateways.py index 43402321..e7d4eb7b 100644 --- a/subscriptions/migrations/0002_add_gateways.py +++ b/subscriptions/migrations/0002_add_gateways.py @@ -1,29 +1,10 @@ from django.conf import settings +from django.core.management import call_command from django.db import migrations -form_description = { - 'bank': 'When choosing to pay by bank, you will be required to manually perform payments and the subscription will be activated only when we receive the funds.', - 'braintree': 'Automatic charges', -} -frontend_names = { - 'bank': 'Bank Transfer', - 'braintree': 'Credit Card or PayPal', -} - - def add_gateways(apps, schema_editor): - Gateway = apps.get_model('looper', 'Gateway') - - for (name, _) in Gateway._meta.get_field('name').choices: - if name == 'mock': - continue - Gateway.objects.get_or_create( - name=name, - frontend_name=frontend_names.get(name), - is_default=name.lower() == 'braintree', - form_description=form_description.get(name), - ) + call_command('loaddata', 'gateways.json', app_label='looper') def remove_gateways(apps, schema_editor): diff --git a/subscriptions/models.py b/subscriptions/models.py index 651b3475..1290084a 100644 --- a/subscriptions/models.py +++ b/subscriptions/models.py @@ -90,7 +90,7 @@ class Team(mixins.CreatedUpdatedMixin, models.Model): """Add given user to the team.""" seats_taken = self.users.count() # Not adding the team manager to the team - if user.pk == self.subscription.user_id: + if user.pk == self.subscription.customer.user_id: return if self.seats is not None and seats_taken >= self.seats: logger.warning( diff --git a/subscriptions/queries.py b/subscriptions/queries.py index 67e9984c..5723c34a 100644 --- a/subscriptions/queries.py +++ b/subscriptions/queries.py @@ -19,7 +19,7 @@ def has_active_subscription(user: User) -> bool: active_subscriptions: 'QuerySet[Subscription]' = Subscription.objects.active() return active_subscriptions.filter( - Q(user_id=user.id) | Q(team__team_users__user_id=user.id) + Q(customer=user.customer) | Q(team__team_users__user_id=user.id) ).exists() @@ -33,7 +33,9 @@ def has_non_legacy_subscription(user: User) -> bool: subscriptions: 'QuerySet[Subscription]' = Subscription.objects.filter(is_legacy=False) - return subscriptions.filter(Q(user_id=user.id) | Q(team__team_users__user_id=user.id)).exists() + return subscriptions.filter( + Q(customer=user.customer) | Q(team__team_users__user_id=user.id) + ).exists() def has_subscription(user: User) -> bool: @@ -42,7 +44,7 @@ def has_subscription(user: User) -> bool: return False return Subscription.objects.filter( - Q(user_id=user.id) | Q(team__team_users__user_id=user.id) + Q(customer=user.customer) | Q(team__team_users__user_id=user.id) ).exists() @@ -51,7 +53,8 @@ def should_redirect_to_billing(user: User) -> bool: if not user.is_authenticated: return False - if user.subscription_set.exclude(status__in=Subscription._CANCELLED_STATUSES).count() == 0: + customer = user.customer + if customer.subscription_set.exclude(status__in=Subscription._CANCELLED_STATUSES).count() == 0: # Only cancelled subscriptions, no need to redirect to billing return False @@ -60,7 +63,7 @@ def should_redirect_to_billing(user: User) -> bool: # so this seems to be the only currently available way to tell # when to stop showing the checkout to the customer. subscription.latest_order() and subscription.payment_method - for subscription in user.subscription_set.all() + for subscription in customer.subscription_set.all() ) @@ -76,4 +79,4 @@ def has_not_yet_cancelled_subscription(user: User) -> bool: status__in=Subscription._CANCELLED_STATUSES ) - return not_yet_cancelled_subscriptions.filter(Q(user_id=user.id)).exists() + return not_yet_cancelled_subscriptions.filter(Q(customer=user.customer)).exists() diff --git a/subscriptions/signals.py b/subscriptions/signals.py index 994e214d..4f520d63 100644 --- a/subscriptions/signals.py +++ b/subscriptions/signals.py @@ -3,6 +3,7 @@ from typing import Set import logging from django.contrib.auth import get_user_model +from django.db import transaction from django.db.models import Q from django.dispatch import receiver import alphabetic_timestamp as ats @@ -30,17 +31,39 @@ def timebased_order_number(): @receiver(django_signals.post_save, sender=User) -def create_customer(sender, instance: User, created, **kwargs): +def create_customer(sender, instance: User, created, raw, **kwargs): """Create Customer on User creation.""" + if raw: + return + if not created: return - logger.debug("Creating Customer for user %i" % instance.id) - # Assume billing name and email are the same, they should be able to change them later - Customer.objects.create( - user_id=instance.pk, - billing_email=instance.email, - full_name=instance.full_name, - ) + + try: + customer = instance.customer + except Customer.DoesNotExist: + pass + else: + logger.debug( + 'Newly created User %d already has a Customer %d, not creating new one', + instance.pk, + customer.pk, + ) + billing_address = customer.billing_address + logger.info('Creating new billing address due to creation of user %s', instance.pk) + if not billing_address.pk: + billing_address.email = instance.email + billing_address.full_name = instance.full_name + billing_address.save() + return + + logger.info('Creating new Customer due to creation of user %s', instance.pk) + with transaction.atomic(): + customer = Customer.objects.create(user=instance) + billing_address = customer.billing_address + billing_address.email = instance.email + billing_address.full_name = instance.full_name + billing_address.save() @receiver(django_signals.pre_save, sender=Order) @@ -54,7 +77,8 @@ def _set_order_number(sender, instance: Order, **kwargs): @receiver(subscription_created_needs_payment) def _on_subscription_created_needs_payment(sender: looper.models.Subscription, **kwargs): tasks.send_mail_bank_transfer_required(subscription_id=sender.pk) - users.tasks.grant_blender_id_role(pk=sender.user_id, role='cloud_has_subscription') + user = sender.customer.user + users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription') @receiver(looper.signals.subscription_activated) @@ -65,8 +89,9 @@ def _on_subscription_status_changed(sender: looper.models.Subscription, **kwargs @receiver(looper.signals.subscription_activated) def _on_subscription_status_activated(sender: looper.models.Subscription, **kwargs): - users.tasks.grant_blender_id_role(pk=sender.user_id, role='cloud_has_subscription') - users.tasks.grant_blender_id_role(pk=sender.user_id, role='cloud_subscriber') + user = sender.customer.user + users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription') + users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_subscriber') if not hasattr(sender, 'team'): return @@ -79,8 +104,9 @@ def _on_subscription_status_activated(sender: looper.models.Subscription, **kwar @receiver(looper.signals.subscription_expired) def _on_subscription_status_deactivated(sender: looper.models.Subscription, **kwargs): # No other active subscription exists, subscriber badge can be revoked - if not queries.has_active_subscription(sender.user): - users.tasks.revoke_blender_id_role(pk=sender.user_id, role='cloud_subscriber') + user = sender.customer.user + if not queries.has_active_subscription(user): + users.tasks.revoke_blender_id_role(pk=user.pk, role='cloud_subscriber') if not hasattr(sender, 'team'): return @@ -118,7 +144,8 @@ def _on_subscription_expired(sender: looper.models.Subscription, **kwargs): assert sender.status == 'expired', f'Expected expired, got "{sender.status} (pk={sender.pk})"' # Only send a "subscription expired" email when there are no other active subscriptions - if not queries.has_active_subscription(sender.user): + user = sender.customer.user + if not queries.has_active_subscription(user): tasks.send_mail_subscription_expired(subscription_id=sender.pk) diff --git a/subscriptions/tasks.py b/subscriptions/tasks.py index 697b97c8..af650fc6 100644 --- a/subscriptions/tasks.py +++ b/subscriptions/tasks.py @@ -44,8 +44,9 @@ def _construct_subscription_mail(mail_name: str, context: Dict[str, Any]) -> Tup def send_mail_bank_transfer_required(subscription_id: int): """Send out an email notifying about the required bank transfer payment.""" subscription = looper.models.Subscription.objects.get(pk=subscription_id) - user = subscription.user - email = user.customer.billing_email or user.email + customer = subscription.customer + user = customer.user + email = customer.billing_address.email or user.email assert ( email ), f'Cannot send notification about bank payment for subscription {subscription.pk}: no email' @@ -65,7 +66,7 @@ def send_mail_bank_transfer_required(subscription_id: int): assert order, "Can't send a notificaton about bank transfer without an existing order" context = { - 'user': subscription.user, + 'user': user, 'subscription': subscription, 'order': order, **get_template_context(), @@ -89,8 +90,9 @@ def send_mail_bank_transfer_required(subscription_id: int): def send_mail_subscription_status_changed(subscription_id: int): """Send out an email notifying about the activated subscription.""" subscription = looper.models.Subscription.objects.get(pk=subscription_id) - user = subscription.user - email = user.customer.billing_email or user.email + customer = subscription.customer + user = customer.user + email = customer.billing_address.email or user.email assert email, f'Cannot send notification about subscription {subscription.pk} status: no email' if is_noreply(email): raise @@ -109,7 +111,7 @@ def send_mail_subscription_status_changed(subscription_id: int): verb = 'deactivated' context = { - 'user': subscription.user, + 'user': user, 'subscription': subscription, 'verb': verb, **get_template_context(), @@ -133,9 +135,9 @@ def send_mail_automatic_payment_performed(order_id: int, transaction_id: int): """Send out an email notifying about the soft-failed payment.""" order = looper.models.Order.objects.get(pk=order_id) transaction = looper.models.Transaction.objects.get(pk=transaction_id) - user = order.user - customer = user.customer - email = customer.billing_email or user.email + customer = order.customer + user = customer.user + email = customer.billing_address.email or user.email logger.debug('Sending %r notification to %s', order.status, email) # An Unsubscribe record will prevent this message from being delivered by Mailgun. @@ -148,7 +150,7 @@ def send_mail_automatic_payment_performed(order_id: int, transaction_id: int): receipt_url = absolute_url('subscriptions:receipt', kwargs={'order_id': order.pk}) context = { - 'user': subscription.user, + 'user': user, 'email': email, 'order': order, 'subscription': subscription, @@ -185,7 +187,8 @@ def send_mail_managed_subscription_notification(subscription_id: int): subscription.pk, ) - user = subscription.user + customer = subscription.customer + user = customer.user admin_url = absolute_url( 'admin:looper_subscription_change', kwargs={'object_id': subscription.id}, @@ -220,7 +223,8 @@ def send_mail_managed_subscription_notification(subscription_id: int): def send_mail_subscription_expired(subscription_id: int): """Send out an email notifying about an expired subscription.""" subscription = looper.models.Subscription.objects.get(pk=subscription_id) - user = subscription.user + customer = subscription.customer + user = customer.user assert ( subscription.status == 'expired' @@ -229,7 +233,7 @@ def send_mail_subscription_expired(subscription_id: int): if queries.has_active_subscription(user): logger.error( 'Not sending subscription-expired notification: pk=%s has other active subscriptions', - subscription.user_id, + user.pk, ) return @@ -248,7 +252,7 @@ def send_mail_subscription_expired(subscription_id: int): logger.debug('Sending subscription-expired notification to %s', email) context = { - 'user': subscription.user, + 'user': user, 'subscription': subscription, 'latest_trainings': get_latest_trainings_and_production_lessons(), 'latest_posts': Post.objects.filter(is_published=True)[:5], @@ -280,9 +284,9 @@ def send_mail_no_payment_method(order_id: int): ), 'send_mail_no_payment_method expects automatic subscription' assert 'fail' in order.status, f'Unexpected order pk={order_id} status: {order.status}' - user = order.user - customer = user.customer - email = customer.billing_email or user.email + customer = order.customer + user = customer.user + email = customer.billing_address.email or user.email logger.debug('Sending %r notification to %s', order.status, email) # An Unsubscribe record will prevent this message from being delivered by Mailgun. @@ -295,7 +299,7 @@ def send_mail_no_payment_method(order_id: int): receipt_url = absolute_url('subscriptions:receipt', kwargs={'order_id': order.pk}) context = { - 'user': subscription.user, + 'user': user, 'email': email, 'order': order, 'subscription': subscription, diff --git a/subscriptions/templates/checkout/checkout_done_transactionless.html b/subscriptions/templates/checkout/checkout_done_transactionless.html index 907059ef..ce4b1c27 100644 --- a/subscriptions/templates/checkout/checkout_done_transactionless.html +++ b/subscriptions/templates/checkout/checkout_done_transactionless.html @@ -14,7 +14,7 @@
-
+

Thanks for subscribing to Blender Studio!

Make sure to follow the instructions below to activate your account.

diff --git a/subscriptions/templates/settings/billing_address.html b/subscriptions/templates/settings/billing_address.html index c9574a92..158b849e 100644 --- a/subscriptions/templates/settings/billing_address.html +++ b/subscriptions/templates/settings/billing_address.html @@ -1,6 +1,5 @@ {% extends 'users/settings/base.html' %} {% load common_extras %} -{% load pipeline %} {% block settings %}

Settings: Subscription

@@ -15,7 +14,3 @@ {% endwith %} {% endblock settings %} - -{% block scripts %} - {% javascript "subscriptions" %} -{% endblock scripts %} diff --git a/subscriptions/templates/subscriptions/components/billing_address_form_readonly.html b/subscriptions/templates/subscriptions/components/billing_address_form_readonly.html deleted file mode 100644 index 072e856a..00000000 --- a/subscriptions/templates/subscriptions/components/billing_address_form_readonly.html +++ /dev/null @@ -1,66 +0,0 @@ -
-
- - {% with field=form.full_name %} - {{ field.value }} - {{ field.as_hidden }} - {% endwith %} - - - {% with field=form.company %} -
- {{ field.value }} - {{ field.as_hidden }} -
- {% endwith %} -
-
- {% with field=form.email %} - {{ field.value }} - {{ field.as_hidden }} - {% endwith %} -
-
- {% with field=form.street_address %} - {{ field.value }} - {{ field.as_hidden }} - {% endwith %} - - {% with field=form.extended_address %} -
- {{ field.value }} - {{ field.as_hidden }} -
- {% endwith %} -
-
- {% with field=form.postal_code %} - {{ field.value }} - {{ field.as_hidden }} - {% endwith %} - - {% with field=form.region %} - - {{ field.value }} - {{ field.as_hidden }} - - {% endwith %} - - {% with field=form.locality %} - {{ field.value }} - {{ field.as_hidden }} - {% endwith %} - {% with field=form.country %} - {{ field.value }} - {{ field.as_hidden }} - {% endwith %} -
-
- {% with field=form.vat_number %} -
- VATIN: {{ field.value }} - {{ field.as_hidden }} -
- {% endwith %} -
-
diff --git a/subscriptions/templates/subscriptions/components/current_plan_variation.html b/subscriptions/templates/subscriptions/components/current_plan_variation.html deleted file mode 100644 index b1adb85c..00000000 --- a/subscriptions/templates/subscriptions/components/current_plan_variation.html +++ /dev/null @@ -1,23 +0,0 @@ -{% load looper %} -{% load subscriptions %} -{% get_taxable current_plan_variation.price current_plan_variation.plan.product.type as current_price %} - -
-
-
-

Renewal

-
-
- {{ current_plan_variation.collection_method|capfirst }}, {{ current_plan_variation|recurring_pricing:current_price.price }} -
-
-
-
-

Total

-
-
-

{{ current_price.price.with_currency_symbol }}

-

{{ current_price.format_tax_amount }}

-
-
-
diff --git a/subscriptions/templates/subscriptions/components/list.html b/subscriptions/templates/subscriptions/components/list.html index 6d179246..ccff322f 100644 --- a/subscriptions/templates/subscriptions/components/list.html +++ b/subscriptions/templates/subscriptions/components/list.html @@ -3,7 +3,7 @@ {# Owned subscriptions #} - {% for subscription in user.subscription_set.all %} + {% for subscription in user.customer.subscription_set.all %} diff --git a/subscriptions/templates/subscriptions/components/payment_form.html b/subscriptions/templates/subscriptions/components/payment_form.html deleted file mode 100644 index 5d8664ba..00000000 --- a/subscriptions/templates/subscriptions/components/payment_form.html +++ /dev/null @@ -1,41 +0,0 @@ -{% if form.non_field_errors %} -
-

Unable to proceed due to the following issue:

-

{{ form.non_field_errors }}

-
-{% endif %} - -{# Payment process specific form fields, most of them a hidden and don't require special templating. #} -
- {{ form.next_url_after_done }} - {{ form.payment_method_nonce }} - {{ form.device_data }} - {{ form.price }} -
-
    - {% with field=form.gateway %} - {% for radio in field %} -
  • - {{ radio }} -
  • - {% endfor %} - {% endwith %} -
-
-
-
-
-
-
-
-{# The content of below script must be valid JSON, as type suggests. #} - diff --git a/subscriptions/templates/subscriptions/components/total.html b/subscriptions/templates/subscriptions/components/total.html index 18005913..91259a2a 100644 --- a/subscriptions/templates/subscriptions/components/total.html +++ b/subscriptions/templates/subscriptions/components/total.html @@ -43,7 +43,12 @@
{% if user.is_authenticated %} - + {% with gw=form.gateway.field.queryset.first %} + + {% endwith %} {% else %} {% endif %} diff --git a/subscriptions/templates/subscriptions/emails/base.html b/subscriptions/templates/subscriptions/emails/base.html index d38f763d..be37980c 100644 --- a/subscriptions/templates/subscriptions/emails/base.html +++ b/subscriptions/templates/subscriptions/emails/base.html @@ -6,7 +6,7 @@ {% endblock header_logo %} {% block body %} -

Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},

+

Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %},

{% block content %}{% endblock content %}

Manage subscription in your billing settings: {{ billing_url }}.

diff --git a/subscriptions/templates/subscriptions/emails/base.txt b/subscriptions/templates/subscriptions/emails/base.txt index 0345ca5a..817d9901 100644 --- a/subscriptions/templates/subscriptions/emails/base.txt +++ b/subscriptions/templates/subscriptions/emails/base.txt @@ -1,4 +1,4 @@ -Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %}, +Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %}, {% block content %}{% endblock content %} Manage subscription in your billing settings: {{ billing_url }}. diff --git a/subscriptions/templates/subscriptions/emails/managed_notification.html b/subscriptions/templates/subscriptions/emails/managed_notification.html index 7e85c10b..4a5dcf36 100644 --- a/subscriptions/templates/subscriptions/emails/managed_notification.html +++ b/subscriptions/templates/subscriptions/emails/managed_notification.html @@ -1,7 +1,7 @@ {% extends "subscriptions/emails/base.html" %} {% block body %}

- {{ user.customer.full_name|default:user.email }} has a {% include "subscriptions/components/info.html" %} that just passed its next payment date. + {{ user.customer.billing_address.full_name|default:user.email }} has a {% include "subscriptions/components/info.html" %} that just passed its next payment date.

diff --git a/subscriptions/templates/subscriptions/emails/managed_notification.txt b/subscriptions/templates/subscriptions/emails/managed_notification.txt index 761276cc..a1bc6664 100644 --- a/subscriptions/templates/subscriptions/emails/managed_notification.txt +++ b/subscriptions/templates/subscriptions/emails/managed_notification.txt @@ -1,3 +1,3 @@ -{{ user.customer.full_name|default:user.email }} has a {% include "subscriptions/components/info.txt" %} that just passed its next payment date. +{% firstof user.customer.billing_address.full_name user.full_name user.email %} has a {% include "subscriptions/components/info.txt" %} that just passed its next payment date. See {{ admin_url }} in the Blender Studio admin. diff --git a/subscriptions/templates/subscriptions/emails/subscription_expired.html b/subscriptions/templates/subscriptions/emails/subscription_expired.html index d5d8710f..1335656d 100644 --- a/subscriptions/templates/subscriptions/emails/subscription_expired.html +++ b/subscriptions/templates/subscriptions/emails/subscription_expired.html @@ -6,7 +6,7 @@ {% endblock header_logo %} {% block body %} -

Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},

+

Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %},

Your Blender Studio subscription #{{subscription.pk}} has expired a while back. We miss you -- and you are missing some exciting content on Blender Studio as well.

{% if latest_posts or latest_trainings %}

Just recently, we've published:

diff --git a/subscriptions/templates/subscriptions/emails/subscription_expired.txt b/subscriptions/templates/subscriptions/emails/subscription_expired.txt index c33deee6..db31c065 100644 --- a/subscriptions/templates/subscriptions/emails/subscription_expired.txt +++ b/subscriptions/templates/subscriptions/emails/subscription_expired.txt @@ -1,4 +1,4 @@ -Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %}, +Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %}, Your Blender Studio subscription #{{subscription.pk}} has expired a while back. We miss you -- and you are missing some exciting content on Blender Studio as well.{% if latest_posts or latest_trainings %} Just recently, we've published: {% for post in latest_posts|slice:":2" %} diff --git a/subscriptions/templates/subscriptions/join/billing_address.html b/subscriptions/templates/subscriptions/join/billing_address.html index 01c24abe..f1df9208 100644 --- a/subscriptions/templates/subscriptions/join/billing_address.html +++ b/subscriptions/templates/subscriptions/join/billing_address.html @@ -36,7 +36,7 @@
{% if messages %} {% for message in messages %} -

+

{{ message }}

{% endfor %} @@ -53,6 +53,17 @@
{% include 'subscriptions/components/total.html' with button_text="Continue to Payment" %} + + {% with gw_bank=form.gateway.field.queryset.last %} + {% if current_plan_variation.collection_method == "manual" and gw_bank.name == "bank" %} +
+
+ + +
+
+ {% endif %} + {% endwith %}
{% endwith %} diff --git a/subscriptions/templates/subscriptions/join/payment_method.html b/subscriptions/templates/subscriptions/join/payment_method.html deleted file mode 100644 index 1e8734f3..00000000 --- a/subscriptions/templates/subscriptions/join/payment_method.html +++ /dev/null @@ -1,73 +0,0 @@ -{% extends 'checkout/checkout_base.html' %} -{% load common_extras %} -{% load looper %} -{% load pipeline %} - -{% block content %} -
-

Payment

-
-

Step 3: Select a payment method.

-

3 of 3

-
-
-
- {% 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/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/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/templatetags/subscriptions.py b/subscriptions/templatetags/subscriptions.py index 5a8b2fcf..7e95dc22 100644 --- a/subscriptions/templatetags/subscriptions.py +++ b/subscriptions/templatetags/subscriptions.py @@ -5,7 +5,6 @@ from django import template from looper.models import PlanVariation, Subscription import looper.money -import looper.taxes import subscriptions.queries diff --git a/subscriptions/tests/_responses/stripe_get_cs_eur.yaml b/subscriptions/tests/_responses/stripe_get_cs_eur.yaml new file mode 100644 index 00000000..0853077d --- /dev/null +++ b/subscriptions/tests/_responses/stripe_get_cs_eur.yaml @@ -0,0 +1,158 @@ +responses: +- response: + auto_calculate_content_length: false + body: "{\n \"id\": \"cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW\"\ + ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\ + allow_promotion_codes\": null,\n \"amount_subtotal\": 1252,\n \"amount_total\"\ + : 1252,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\"\ + : null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\ + \ \"cancel_url\": \"http://testserver/join/plan-variation/10/billing/\",\n\ + \ \"client_reference_id\": null,\n \"client_secret\": null,\n \"consent\"\ + : null,\n \"consent_collection\": null,\n \"created\": 1718354548,\n \"currency\"\ + : \"eur\",\n \"currency_conversion\": null,\n \"custom_fields\": [],\n \"\ + custom_text\": {\n \"after_submit\": null,\n \"shipping_address\": null,\n\ + \ \"submit\": null,\n \"terms_of_service_acceptance\": null\n },\n \"\ + customer\": \"cus_QI5uhaeNfeXwYP\",\n \"customer_creation\": null,\n \"customer_details\"\ + : {\n \"address\": {\n \"city\": null,\n \"country\": \"NL\",\n\ + \ \"line1\": null,\n \"line2\": null,\n \"postal_code\": null,\n\ + \ \"state\": null\n },\n \"email\": \"my.billing.email@example.com\"\ + ,\n \"name\": \"New Full Name\",\n \"phone\": null,\n \"tax_exempt\"\ + : \"none\",\n \"tax_ids\": []\n },\n \"customer_email\": null,\n \"expires_at\"\ + : 1718440948,\n \"invoice\": null,\n \"invoice_creation\": {\n \"enabled\"\ + : false,\n \"invoice_data\": {\n \"account_tax_ids\": null,\n \"\ + custom_fields\": null,\n \"description\": null,\n \"footer\": null,\n\ + \ \"issuer\": null,\n \"metadata\": {},\n \"rendering_options\"\ + : null\n }\n },\n \"livemode\": false,\n \"locale\": null,\n \"metadata\"\ + : {},\n \"mode\": \"payment\",\n \"payment_intent\": {\n \"id\": \"pi_3PRVj3E4KAUB5djs1xbsGdDP\"\ + ,\n \"object\": \"payment_intent\",\n \"amount\": 1252,\n \"amount_capturable\"\ + : 0,\n \"amount_details\": {\n \"tip\": {}\n },\n \"amount_received\"\ + : 1252,\n \"application\": null,\n \"application_fee_amount\": null,\n\ + \ \"automatic_payment_methods\": null,\n \"canceled_at\": null,\n \"\ + cancellation_reason\": null,\n \"capture_method\": \"automatic\",\n \"\ + client_secret\": \"pi_3PRVj3E4KAUB5djs1xbsGdDP_secret_foobar\"\ + ,\n \"confirmation_method\": \"automatic\",\n \"created\": 1718354593,\n\ + \ \"currency\": \"eur\",\n \"customer\": {\n \"id\": \"cus_QI5uhaeNfeXwYP\"\ + ,\n \"object\": \"customer\",\n \"address\": null,\n \"balance\"\ + : 0,\n \"created\": 1718354548,\n \"currency\": null,\n \"default_source\"\ + : null,\n \"delinquent\": false,\n \"description\": null,\n \"\ + discount\": null,\n \"email\": \"my.billing.email@example.com\",\n \ + \ \"invoice_prefix\": \"8D38744D\",\n \"invoice_settings\": {\n \ + \ \"custom_fields\": null,\n \"default_payment_method\": null,\n \ + \ \"footer\": null,\n \"rendering_options\": null\n },\n \ + \ \"livemode\": false,\n \"metadata\": {},\n \"name\": \"New Full\ + \ Name\",\n \"phone\": null,\n \"preferred_locales\": [],\n \"\ + shipping\": null,\n \"tax_exempt\": \"none\",\n \"test_clock\": null\n\ + \ },\n \"description\": null,\n \"invoice\": null,\n \"last_payment_error\"\ + : null,\n \"latest_charge\": {\n \"id\": \"ch_3PRVj3E4KAUB5djs16uXWpi8\"\ + ,\n \"object\": \"charge\",\n \"amount\": 1252,\n \"amount_captured\"\ + : 1252,\n \"amount_refunded\": 0,\n \"application\": null,\n \ + \ \"application_fee\": null,\n \"application_fee_amount\": null,\n \ + \ \"balance_transaction\": \"txn_3PRVj3E4KAUB5djs1sxkERoM\",\n \"billing_details\"\ + : {\n \"address\": {\n \"city\": null,\n \"country\"\ + : \"NL\",\n \"line1\": null,\n \"line2\": null,\n \ + \ \"postal_code\": null,\n \"state\": null\n },\n \"\ + email\": \"my.billing.email@example.com\",\n \"name\": \"Jane Doe\",\n\ + \ \"phone\": null\n },\n \"calculated_statement_descriptor\"\ + : \"BLENDER STUDIO\",\n \"captured\": true,\n \"created\": 1718354593,\n\ + \ \"currency\": \"eur\",\n \"customer\": \"cus_QI5uhaeNfeXwYP\",\n\ + \ \"description\": null,\n \"destination\": null,\n \"dispute\"\ + : null,\n \"disputed\": false,\n \"failure_balance_transaction\":\ + \ null,\n \"failure_code\": null,\n \"failure_message\": null,\n \ + \ \"fraud_details\": {},\n \"invoice\": null,\n \"livemode\":\ + \ false,\n \"metadata\": {\n \"order_id\": \"1\"\n },\n \ + \ \"on_behalf_of\": null,\n \"order\": null,\n \"outcome\": {\n\ + \ \"network_status\": \"approved_by_network\",\n \"reason\": null,\n\ + \ \"risk_level\": \"normal\",\n \"risk_score\": 16,\n \"\ + seller_message\": \"Payment complete.\",\n \"type\": \"authorized\"\n\ + \ },\n \"paid\": true,\n \"payment_intent\": \"pi_3PRVj3E4KAUB5djs1xbsGdDP\"\ + ,\n \"payment_method\": \"pm_1PRVj2E4KAUB5djsNQr0k105\",\n \"payment_method_details\"\ + : {\n \"card\": {\n \"amount_authorized\": 1252,\n \ + \ \"brand\": \"visa\",\n \"checks\": {\n \"address_line1_check\"\ + : null,\n \"address_postal_code_check\": null,\n \"cvc_check\"\ + : \"pass\"\n },\n \"country\": \"US\",\n \"exp_month\"\ + : 12,\n \"exp_year\": 2033,\n \"extended_authorization\":\ + \ {\n \"status\": \"disabled\"\n },\n \"fingerprint\"\ + : \"YcmpGi38fZZuBsh4\",\n \"funding\": \"credit\",\n \"incremental_authorization\"\ + : {\n \"status\": \"unavailable\"\n },\n \"installments\"\ + : null,\n \"last4\": \"4242\",\n \"mandate\": null,\n \ + \ \"multicapture\": {\n \"status\": \"unavailable\"\n \ + \ },\n \"network\": \"visa\",\n \"network_token\": {\n\ + \ \"used\": false\n },\n \"overcapture\": {\n \ + \ \"maximum_amount_capturable\": 1252,\n \"status\": \"\ + unavailable\"\n },\n \"three_d_secure\": null,\n \ + \ \"wallet\": null\n },\n \"type\": \"card\"\n },\n \ + \ \"radar_options\": {},\n \"receipt_email\": null,\n \"receipt_number\"\ + : null,\n \"receipt_url\": \"https://pay.stripe.com/receipts/payment/foobar\"\ + ,\n \"refunded\": false,\n \"review\": null,\n \"shipping\":\ + \ null,\n \"source\": null,\n \"source_transfer\": null,\n \"\ + statement_descriptor\": null,\n \"statement_descriptor_suffix\": null,\n\ + \ \"status\": \"succeeded\",\n \"transfer_data\": null,\n \"\ + transfer_group\": null\n },\n \"livemode\": false,\n \"metadata\":\ + \ {\n \"order_id\": \"1\"\n },\n \"next_action\": null,\n \"on_behalf_of\"\ + : null,\n \"payment_method\": {\n \"id\": \"pm_1PRVj2E4KAUB5djsNQr0k105\"\ + ,\n \"object\": \"payment_method\",\n \"allow_redisplay\": \"limited\"\ + ,\n \"billing_details\": {\n \"address\": {\n \"city\"\ + : null,\n \"country\": \"NL\",\n \"line1\": null,\n \ + \ \"line2\": null,\n \"postal_code\": null,\n \"state\"\ + : null\n },\n \"email\": \"my.billing.email@example.com\",\n \ + \ \"name\": \"Jane Doe\",\n \"phone\": null\n },\n \"\ + card\": {\n \"brand\": \"visa\",\n \"checks\": {\n \"\ + address_line1_check\": null,\n \"address_postal_code_check\": null,\n\ + \ \"cvc_check\": \"pass\"\n },\n \"country\": \"US\"\ + ,\n \"display_brand\": \"visa\",\n \"exp_month\": 12,\n \ + \ \"exp_year\": 2033,\n \"fingerprint\": \"YcmpGi38fZZuBsh4\",\n \ + \ \"funding\": \"credit\",\n \"generated_from\": null,\n \"\ + last4\": \"4242\",\n \"networks\": {\n \"available\": [\n \ + \ \"visa\"\n ],\n \"preferred\": null\n },\n\ + \ \"three_d_secure_usage\": {\n \"supported\": true\n \ + \ },\n \"wallet\": null\n },\n \"created\": 1718354592,\n\ + \ \"customer\": \"cus_QI5uhaeNfeXwYP\",\n \"livemode\": false,\n \ + \ \"metadata\": {},\n \"type\": \"card\"\n },\n \"payment_method_configuration_details\"\ + : null,\n \"payment_method_options\": {\n \"card\": {\n \"installments\"\ + : null,\n \"mandate_options\": null,\n \"network\": null,\n \ + \ \"request_three_d_secure\": \"automatic\"\n }\n },\n \"payment_method_types\"\ + : [\n \"card\"\n ],\n \"processing\": null,\n \"receipt_email\"\ + : null,\n \"review\": null,\n \"setup_future_usage\": \"off_session\"\ + ,\n \"shipping\": null,\n \"source\": null,\n \"statement_descriptor\"\ + : null,\n \"statement_descriptor_suffix\": null,\n \"status\": \"succeeded\"\ + ,\n \"transfer_data\": null,\n \"transfer_group\": null\n },\n \"payment_link\"\ + : null,\n \"payment_method_collection\": \"if_required\",\n \"payment_method_configuration_details\"\ + : null,\n \"payment_method_options\": {\n \"card\": {\n \"request_three_d_secure\"\ + : \"automatic\"\n }\n },\n \"payment_method_types\": [\n \"card\",\n\ + \ \"link\",\n \"paypal\"\n ],\n \"payment_status\": \"paid\",\n \"\ + phone_number_collection\": {\n \"enabled\": false\n },\n \"recovered_from\"\ + : null,\n \"saved_payment_method_options\": {\n \"allow_redisplay_filters\"\ + : [\n \"always\"\n ],\n \"payment_method_remove\": null,\n \"\ + payment_method_save\": null\n },\n \"setup_intent\": null,\n \"shipping_address_collection\"\ + : null,\n \"shipping_cost\": null,\n \"shipping_details\": null,\n \"shipping_options\"\ + : [],\n \"status\": \"complete\",\n \"submit_type\": \"pay\",\n \"subscription\"\ + : null,\n \"success_url\": \"http://testserver/looper/stripe_success/1/{CHECKOUT_SESSION_ID}\"\ + ,\n \"total_details\": {\n \"amount_discount\": 0,\n \"amount_shipping\"\ + : 0,\n \"amount_tax\": 0\n },\n \"ui_mode\": \"hosted\",\n \"url\": null\n\ + }" + content_type: text/plain + method: GET + status: 200 + url: https://api.stripe.com/v1/checkout/sessions/cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW?expand%5B0%5D=payment_intent&expand%5B1%5D=payment_intent.customer&expand%5B2%5D=payment_intent.latest_charge&expand%5B3%5D=payment_intent.latest_charge.payment_method_details&expand%5B4%5D=payment_intent.payment_method +- response: + auto_calculate_content_length: false + body: "{\n \"id\": \"pm_1PRVj2E4KAUB5djsNQr0k105\",\n \"object\": \"payment_method\"\ + ,\n \"allow_redisplay\": \"limited\",\n \"billing_details\": {\n \"address\"\ + : {\n \"city\": null,\n \"country\": \"NL\",\n \"line1\": null,\n\ + \ \"line2\": null,\n \"postal_code\": null,\n \"state\": null\n\ + \ },\n \"email\": \"my.billing.email@example.com\",\n \"name\": \"\ + Jane Doe\",\n \"phone\": null\n },\n \"card\": {\n \"brand\": \"visa\"\ + ,\n \"checks\": {\n \"address_line1_check\": null,\n \"address_postal_code_check\"\ + : null,\n \"cvc_check\": \"pass\"\n },\n \"country\": \"US\",\n \ + \ \"display_brand\": \"visa\",\n \"exp_month\": 12,\n \"exp_year\":\ + \ 2033,\n \"fingerprint\": \"YcmpGi38fZZuBsh4\",\n \"funding\": \"credit\"\ + ,\n \"generated_from\": null,\n \"last4\": \"4242\",\n \"networks\"\ + : {\n \"available\": [\n \"visa\"\n ],\n \"preferred\"\ + : null\n },\n \"three_d_secure_usage\": {\n \"supported\": true\n\ + \ },\n \"wallet\": null\n },\n \"created\": 1718354592,\n \"customer\"\ + : \"cus_QI5uhaeNfeXwYP\",\n \"livemode\": false,\n \"metadata\": {},\n \"\ + type\": \"card\"\n}" + content_type: text/plain + method: GET + status: 200 + url: https://api.stripe.com/v1/payment_methods/pm_1PRVj2E4KAUB5djsNQr0k105 diff --git a/subscriptions/tests/_responses/stripe_get_cs_setup.yaml b/subscriptions/tests/_responses/stripe_get_cs_setup.yaml new file mode 100644 index 00000000..97d35be8 --- /dev/null +++ b/subscriptions/tests/_responses/stripe_get_cs_setup.yaml @@ -0,0 +1,67 @@ +responses: +- response: + auto_calculate_content_length: false + body: "{\n \"id\": \"cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9\"\ + ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\ + allow_promotion_codes\": null,\n \"amount_subtotal\": null,\n \"amount_total\"\ + : null,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\"\ + : null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\ + \ \"cancel_url\": \"http://testserver/settings/billing/\",\n \"client_reference_id\"\ + : null,\n \"client_secret\": null,\n \"consent\": null,\n \"consent_collection\"\ + : null,\n \"created\": 1718021357,\n \"currency\": null,\n \"currency_conversion\"\ + : null,\n \"custom_fields\": [],\n \"custom_text\": {\n \"after_submit\"\ + : null,\n \"shipping_address\": null,\n \"submit\": null,\n \"terms_of_service_acceptance\"\ + : null\n },\n \"customer\": \"cus_QGeKULCHd4p9o2\",\n \"customer_creation\"\ + : null,\n \"customer_details\": {\n \"address\": null,\n \"email\": \"\ + billing@example.com\",\n \"name\": \"\u0410\u043B\u0435\u043A\u0441\u0435\ + \u0439 \u041D.\",\n \"phone\": null,\n \"tax_exempt\": null,\n \"tax_ids\"\ + : []\n },\n \"customer_email\": null,\n \"expires_at\": 1718107757,\n \"\ + invoice\": null,\n \"invoice_creation\": null,\n \"livemode\": false,\n \"\ + locale\": null,\n \"metadata\": {},\n \"mode\": \"setup\",\n \"payment_intent\"\ + : null,\n \"payment_link\": null,\n \"payment_method_collection\": \"always\"\ + ,\n \"payment_method_configuration_details\": null,\n \"payment_method_options\"\ + : {\n \"card\": {\n \"request_three_d_secure\": \"automatic\"\n }\n\ + \ },\n \"payment_method_types\": [\n \"card\",\n \"link\",\n \"paypal\"\ + \n ],\n \"payment_status\": \"no_payment_required\",\n \"phone_number_collection\"\ + : {\n \"enabled\": false\n },\n \"recovered_from\": null,\n \"saved_payment_method_options\"\ + : null,\n \"setup_intent\": {\n \"id\": \"seti_1PQ72HE4KAUB5djsPthAKRdZ\"\ + ,\n \"object\": \"setup_intent\",\n \"application\": null,\n \"automatic_payment_methods\"\ + : null,\n \"cancellation_reason\": null,\n \"client_secret\": \"foobar\"\ + ,\n \"created\": 1718021357,\n \"customer\": \"cus_QGeKULCHd4p9o2\",\n\ + \ \"description\": null,\n \"flow_directions\": null,\n \"last_setup_error\"\ + : null,\n \"latest_attempt\": \"setatt_1PQ7DuE4KAUB5djsZtPVk4XB\",\n \"\ + livemode\": false,\n \"mandate\": null,\n \"metadata\": {\n \"subscription_id\"\ + : \"1\"\n },\n \"next_action\": null,\n \"on_behalf_of\": null,\n \ + \ \"payment_method\": {\n \"id\": \"pm_1PQ7DsE4KAUB5djsw4z0K5PS\",\n\ + \ \"object\": \"payment_method\",\n \"allow_redisplay\": \"always\"\ + ,\n \"billing_details\": {\n \"address\": {\n \"city\"\ + : null,\n \"country\": \"NL\",\n \"line1\": null,\n \ + \ \"line2\": null,\n \"postal_code\": null,\n \"state\"\ + : null\n },\n \"email\": \"billing@example.com\",\n \"\ + name\": \"John Smith\",\n \"phone\": null\n },\n \"card\":\ + \ {\n \"brand\": \"visa\",\n \"checks\": {\n \"address_line1_check\"\ + : null,\n \"address_postal_code_check\": null,\n \"cvc_check\"\ + : \"pass\"\n },\n \"country\": \"US\",\n \"display_brand\"\ + : \"visa\",\n \"exp_month\": 12,\n \"exp_year\": 2033,\n \ + \ \"fingerprint\": \"YcmpGi38fZZuBsh4\",\n \"funding\": \"credit\"\ + ,\n \"generated_from\": null,\n \"last4\": \"4242\",\n \ + \ \"networks\": {\n \"available\": [\n \"visa\"\n \ + \ ],\n \"preferred\": null\n },\n \"three_d_secure_usage\"\ + : {\n \"supported\": true\n },\n \"wallet\": null\n \ + \ },\n \"created\": 1718022076,\n \"customer\": \"cus_QGeKULCHd4p9o2\"\ + ,\n \"livemode\": false,\n \"metadata\": {},\n \"type\": \"card\"\ + \n },\n \"payment_method_configuration_details\": null,\n \"payment_method_options\"\ + : {\n \"card\": {\n \"mandate_options\": null,\n \"network\"\ + : null,\n \"request_three_d_secure\": \"automatic\"\n }\n },\n\ + \ \"payment_method_types\": [\n \"card\",\n \"link\",\n \"\ + paypal\"\n ],\n \"single_use_mandate\": null,\n \"status\": \"succeeded\"\ + ,\n \"usage\": \"off_session\"\n },\n \"shipping_address_collection\":\ + \ null,\n \"shipping_cost\": null,\n \"shipping_details\": null,\n \"shipping_options\"\ + : [],\n \"status\": \"complete\",\n \"submit_type\": null,\n \"subscription\"\ + : null,\n \"success_url\": \"http://testserver/subscription/1/payment-method/change/{CHECKOUT_SESSION_ID}/\"\ + ,\n \"total_details\": null,\n \"ui_mode\": \"hosted\",\n \"url\": null\n\ + }" + content_type: text/plain; charset=utf-8 + method: GET + status: 200 + url: https://api.stripe.com/v1/checkout/sessions/cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9?expand%5B0%5D=setup_intent&expand%5B1%5D=setup_intent.payment_method diff --git a/subscriptions/tests/_responses/stripe_get_cs_usd.yaml b/subscriptions/tests/_responses/stripe_get_cs_usd.yaml new file mode 100644 index 00000000..e2ed2e9b --- /dev/null +++ b/subscriptions/tests/_responses/stripe_get_cs_usd.yaml @@ -0,0 +1,132 @@ +responses: +- response: + auto_calculate_content_length: false + body: "{\n \"id\": \"cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w\"\ + ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\ + allow_promotion_codes\": null,\n \"amount_subtotal\": 1110,\n \"amount_total\"\ + : 1110,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\"\ + : null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\ + \ \"cancel_url\": \"http://testserver/subscription/1/manage/\",\n \"client_reference_id\"\ + : null,\n \"client_secret\": null,\n \"consent\": null,\n \"consent_collection\"\ + : null,\n \"created\": 1718033250,\n \"currency\": \"usd\",\n \"currency_conversion\"\ + : null,\n \"custom_fields\": [],\n \"custom_text\": {\n \"after_submit\"\ + : null,\n \"shipping_address\": null,\n \"submit\": null,\n \"terms_of_service_acceptance\"\ + : null\n },\n \"customer\": \"cus_QGhXPj2pOTBdSo\",\n \"customer_creation\"\ + : null,\n \"customer_details\": {\n \"address\": {\n \"city\": null,\n\ + \ \"country\": null,\n \"line1\": null,\n \"line2\": null,\n\ + \ \"postal_code\": null,\n \"state\": null\n },\n \"email\"\ + : \"billing@example.com\",\n \"name\": \"\u0410\u043B\u0435\u043A\u0441\u0435\ + \u0439 \u041D.\",\n \"phone\": null,\n \"tax_exempt\": \"none\",\n \ + \ \"tax_ids\": []\n },\n \"customer_email\": null,\n \"expires_at\": 1718119650,\n\ + \ \"invoice\": null,\n \"invoice_creation\": {\n \"enabled\": false,\n\ + \ \"invoice_data\": {\n \"account_tax_ids\": null,\n \"custom_fields\"\ + : null,\n \"description\": null,\n \"footer\": null,\n \"issuer\"\ + : null,\n \"metadata\": {},\n \"rendering_options\": null\n }\n\ + \ },\n \"livemode\": false,\n \"locale\": null,\n \"metadata\": {},\n \"\ + mode\": \"payment\",\n \"payment_intent\": {\n \"id\": \"pi_3PQAVnE4KAUB5djs1ciLiZeV\"\ + ,\n \"object\": \"payment_intent\",\n \"amount\": 1110,\n \"amount_capturable\"\ + : 0,\n \"amount_details\": {\n \"tip\": {}\n },\n \"amount_received\"\ + : 1110,\n \"application\": null,\n \"application_fee_amount\": null,\n\ + \ \"automatic_payment_methods\": null,\n \"canceled_at\": null,\n \"\ + cancellation_reason\": null,\n \"capture_method\": \"automatic\",\n \"\ + client_secret\": \"pi_3PQAVnE4KAUB5djs1ciLiZeV_secret_foobar\"\ + ,\n \"confirmation_method\": \"automatic\",\n \"created\": 1718034719,\n\ + \ \"currency\": \"usd\",\n \"customer\": {\n \"id\": \"cus_QGhXPj2pOTBdSo\"\ + ,\n \"object\": \"customer\",\n \"address\": null,\n \"balance\"\ + : 0,\n \"created\": 1718033249,\n \"currency\": null,\n \"default_source\"\ + : null,\n \"delinquent\": false,\n \"description\": null,\n \"\ + discount\": null,\n \"email\": \"billing@example.com\",\n \"invoice_prefix\"\ + : \"B062701B\",\n \"invoice_settings\": {\n \"custom_fields\": null,\n\ + \ \"default_payment_method\": null,\n \"footer\": null,\n \ + \ \"rendering_options\": null\n },\n \"livemode\": false,\n \ + \ \"metadata\": {},\n \"name\": \"\u0410\u043B\u0435\u043A\u0441\u0435\ + \u0439 \u041D.\",\n \"phone\": null,\n \"preferred_locales\": [],\n\ + \ \"shipping\": null,\n \"tax_exempt\": \"none\",\n \"test_clock\"\ + : null\n },\n \"description\": null,\n \"invoice\": null,\n \"last_payment_error\"\ + : null,\n \"latest_charge\": {\n \"id\": \"py_3PQAVnE4KAUB5djs1yXEDdPh\"\ + ,\n \"object\": \"charge\",\n \"amount\": 1110,\n \"amount_captured\"\ + : 1110,\n \"amount_refunded\": 0,\n \"application\": null,\n \ + \ \"application_fee\": null,\n \"application_fee_amount\": null,\n \ + \ \"balance_transaction\": \"txn_3PQAVnE4KAUB5djs1bl5WHpi\",\n \"billing_details\"\ + : {\n \"address\": {\n \"city\": null,\n \"country\"\ + : null,\n \"line1\": null,\n \"line2\": null,\n \"\ + postal_code\": null,\n \"state\": null\n },\n \"email\"\ + : \"billing@example.com\",\n \"name\": \"Josh Dane\",\n \"phone\"\ + : null\n },\n \"calculated_statement_descriptor\": null,\n \"\ + captured\": true,\n \"created\": 1718034735,\n \"currency\": \"usd\"\ + ,\n \"customer\": \"cus_QGhXPj2pOTBdSo\",\n \"description\": null,\n\ + \ \"destination\": null,\n \"dispute\": null,\n \"disputed\"\ + : false,\n \"failure_balance_transaction\": null,\n \"failure_code\"\ + : null,\n \"failure_message\": null,\n \"fraud_details\": {},\n \ + \ \"invoice\": null,\n \"livemode\": false,\n \"metadata\": {\n\ + \ \"order_id\": \"1\"\n },\n \"on_behalf_of\": null,\n \ + \ \"order\": null,\n \"outcome\": {\n \"network_status\": \"approved_by_network\"\ + ,\n \"reason\": null,\n \"risk_level\": \"not_assessed\",\n \ + \ \"seller_message\": \"Payment complete.\",\n \"type\": \"authorized\"\ + \n },\n \"paid\": true,\n \"payment_intent\": \"pi_3PQAVnE4KAUB5djs1ciLiZeV\"\ + ,\n \"payment_method\": \"pm_1PQAVnE4KAUB5djsss37I9F6\",\n \"payment_method_details\"\ + : {\n \"paypal\": {\n \"country\": \"FR\",\n \"payer_email\"\ + : \"billing@example.com\",\n \"payer_id\": \"2RDOSMFNFJCL0\",\n \ + \ \"payer_name\": \"Name Surname\",\n \"seller_protection\":\ + \ {\n \"dispute_categories\": [\n \"product_not_received\"\ + ,\n \"fraudulent\"\n ],\n \"status\": \"\ + eligible\"\n },\n \"transaction_id\": \"a3c6e965-49d6-4133-9d8e-0220b0dd8ec1\"\ + \n },\n \"type\": \"paypal\"\n },\n \"radar_options\"\ + : {},\n \"receipt_email\": null,\n \"receipt_number\": null,\n \ + \ \"receipt_url\": \"https://pay.stripe.com/receipts/payment/foobar\"\ + ,\n \"refunded\": false,\n \"review\": null,\n \"shipping\":\ + \ null,\n \"source\": null,\n \"source_transfer\": null,\n \"\ + statement_descriptor\": null,\n \"statement_descriptor_suffix\": null,\n\ + \ \"status\": \"succeeded\",\n \"transfer_data\": null,\n \"\ + transfer_group\": null\n },\n \"livemode\": false,\n \"metadata\":\ + \ {\n \"order_id\": \"1\"\n },\n \"next_action\": null,\n \"on_behalf_of\"\ + : null,\n \"payment_method\": {\n \"id\": \"pm_1PQAVnE4KAUB5djsss37I9F6\"\ + ,\n \"object\": \"payment_method\",\n \"allow_redisplay\": \"limited\"\ + ,\n \"billing_details\": {\n \"address\": {\n \"city\"\ + : null,\n \"country\": null,\n \"line1\": null,\n \ + \ \"line2\": null,\n \"postal_code\": null,\n \"state\":\ + \ null\n },\n \"email\": \"billing@example.com\",\n \"\ + name\": \"Josh Dane\",\n \"phone\": null\n },\n \"created\"\ + : 1718034719,\n \"customer\": \"cus_QGhXPj2pOTBdSo\",\n \"livemode\"\ + : false,\n \"metadata\": {},\n \"paypal\": {\n \"country\"\ + : \"FR\",\n \"payer_email\": \"billing@example.com\",\n \"payer_id\"\ + : \"2RDOSMFNFJCL0\"\n },\n \"type\": \"paypal\"\n },\n \"payment_method_configuration_details\"\ + : null,\n \"payment_method_options\": {\n \"paypal\": {\n \"\ + preferred_locale\": null,\n \"reference\": null\n }\n },\n \ + \ \"payment_method_types\": [\n \"paypal\"\n ],\n \"processing\"\ + : null,\n \"receipt_email\": null,\n \"review\": null,\n \"setup_future_usage\"\ + : \"off_session\",\n \"shipping\": null,\n \"source\": null,\n \"statement_descriptor\"\ + : null,\n \"statement_descriptor_suffix\": null,\n \"status\": \"succeeded\"\ + ,\n \"transfer_data\": null,\n \"transfer_group\": null\n },\n \"payment_link\"\ + : null,\n \"payment_method_collection\": \"if_required\",\n \"payment_method_configuration_details\"\ + : null,\n \"payment_method_options\": {},\n \"payment_method_types\": [\n\ + \ \"card\",\n \"link\",\n \"paypal\"\n ],\n \"payment_status\": \"\ + paid\",\n \"phone_number_collection\": {\n \"enabled\": false\n },\n \"\ + recovered_from\": null,\n \"saved_payment_method_options\": {\n \"allow_redisplay_filters\"\ + : [\n \"always\"\n ],\n \"payment_method_remove\": null,\n \"\ + payment_method_save\": null\n },\n \"setup_intent\": null,\n \"shipping_address_collection\"\ + : null,\n \"shipping_cost\": null,\n \"shipping_details\": null,\n \"shipping_options\"\ + : [],\n \"status\": \"complete\",\n \"submit_type\": \"pay\",\n \"subscription\"\ + : null,\n \"success_url\": \"http://testserver/looper/stripe_success/1/{CHECKOUT_SESSION_ID}\"\ + ,\n \"total_details\": {\n \"amount_discount\": 0,\n \"amount_shipping\"\ + : 0,\n \"amount_tax\": 0\n },\n \"ui_mode\": \"hosted\",\n \"url\": null\n\ + }" + content_type: text/plain; charset=utf-8 + method: GET + status: 200 + url: https://api.stripe.com/v1/checkout/sessions/cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w?expand%5B0%5D=payment_intent&expand%5B1%5D=payment_intent.customer&expand%5B2%5D=payment_intent.latest_charge&expand%5B3%5D=payment_intent.latest_charge.payment_method_details&expand%5B4%5D=payment_intent.payment_method +- response: + auto_calculate_content_length: false + body: "{\n \"id\": \"pm_1PQAVnE4KAUB5djsss37I9F6\",\n \"object\": \"payment_method\"\ + ,\n \"allow_redisplay\": \"limited\",\n \"billing_details\": {\n \"address\"\ + : {\n \"city\": null,\n \"country\": null,\n \"line1\": null,\n\ + \ \"line2\": null,\n \"postal_code\": null,\n \"state\": null\n\ + \ },\n \"email\": \"billing@example.com\",\n \"name\": \"Josh Dane\"\ + ,\n \"phone\": null\n },\n \"created\": 1718034719,\n \"customer\": \"\ + cus_QGhXPj2pOTBdSo\",\n \"livemode\": false,\n \"metadata\": {},\n \"paypal\"\ + : {\n \"country\": \"FR\",\n \"payer_email\": \"billing@example.com\"\ + ,\n \"payer_id\": \"2RDOSMFNFJCL0\"\n },\n \"type\": \"paypal\"\n}" + content_type: text/plain + method: GET + status: 200 + url: https://api.stripe.com/v1/payment_methods/pm_1PQAVnE4KAUB5djsss37I9F6 diff --git a/subscriptions/tests/_responses/stripe_new_cs_eur.yaml b/subscriptions/tests/_responses/stripe_new_cs_eur.yaml new file mode 100644 index 00000000..ff5b857e --- /dev/null +++ b/subscriptions/tests/_responses/stripe_new_cs_eur.yaml @@ -0,0 +1,59 @@ +responses: +- response: + auto_calculate_content_length: false + body: "{\n \"id\": \"cus_QI5uhaeNfeXwYP\",\n \"object\": \"customer\",\n \"\ + address\": null,\n \"balance\": 0,\n \"created\": 1718354548,\n \"currency\"\ + : null,\n \"default_source\": null,\n \"delinquent\": false,\n \"description\"\ + : null,\n \"discount\": null,\n \"email\": \"my.billing.email@example.com\"\ + ,\n \"invoice_prefix\": \"8D38744D\",\n \"invoice_settings\": {\n \"custom_fields\"\ + : null,\n \"default_payment_method\": null,\n \"footer\": null,\n \"\ + rendering_options\": null\n },\n \"livemode\": false,\n \"metadata\": {},\n\ + \ \"name\": \"New Full Name\",\n \"phone\": null,\n \"preferred_locales\"\ + : [],\n \"shipping\": null,\n \"tax_exempt\": \"none\",\n \"test_clock\"\ + : null\n}" + content_type: text/plain + method: POST + status: 200 + url: https://api.stripe.com/v1/customers +- response: + auto_calculate_content_length: false + body: "{\n \"id\": \"cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW\"\ + ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\ + allow_promotion_codes\": null,\n \"amount_subtotal\": 1252,\n \"amount_total\"\ + : 1252,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\"\ + : null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\ + \ \"cancel_url\": \"http://testserver/join/plan-variation/10/billing/\",\n\ + \ \"client_reference_id\": null,\n \"client_secret\": null,\n \"consent\"\ + : null,\n \"consent_collection\": null,\n \"created\": 1718354548,\n \"currency\"\ + : \"eur\",\n \"currency_conversion\": null,\n \"custom_fields\": [],\n \"\ + custom_text\": {\n \"after_submit\": null,\n \"shipping_address\": null,\n\ + \ \"submit\": null,\n \"terms_of_service_acceptance\": null\n },\n \"\ + customer\": \"cus_QI5uhaeNfeXwYP\",\n \"customer_creation\": null,\n \"customer_details\"\ + : {\n \"address\": null,\n \"email\": \"my.billing.email@example.com\"\ + ,\n \"name\": null,\n \"phone\": null,\n \"tax_exempt\": \"none\",\n\ + \ \"tax_ids\": null\n },\n \"customer_email\": null,\n \"expires_at\"\ + : 1718440948,\n \"invoice\": null,\n \"invoice_creation\": {\n \"enabled\"\ + : false,\n \"invoice_data\": {\n \"account_tax_ids\": null,\n \"\ + custom_fields\": null,\n \"description\": null,\n \"footer\": null,\n\ + \ \"issuer\": null,\n \"metadata\": {},\n \"rendering_options\"\ + : null\n }\n },\n \"livemode\": false,\n \"locale\": null,\n \"metadata\"\ + : {},\n \"mode\": \"payment\",\n \"payment_intent\": null,\n \"payment_link\"\ + : null,\n \"payment_method_collection\": \"if_required\",\n \"payment_method_configuration_details\"\ + : null,\n \"payment_method_options\": {\n \"card\": {\n \"request_three_d_secure\"\ + : \"automatic\"\n }\n },\n \"payment_method_types\": [\n \"card\",\n\ + \ \"link\",\n \"paypal\"\n ],\n \"payment_status\": \"unpaid\",\n \"\ + phone_number_collection\": {\n \"enabled\": false\n },\n \"recovered_from\"\ + : null,\n \"saved_payment_method_options\": {\n \"allow_redisplay_filters\"\ + : [\n \"always\"\n ],\n \"payment_method_remove\": null,\n \"\ + payment_method_save\": null\n },\n \"setup_intent\": null,\n \"shipping_address_collection\"\ + : null,\n \"shipping_cost\": null,\n \"shipping_details\": null,\n \"shipping_options\"\ + : [],\n \"status\": \"open\",\n \"submit_type\": \"pay\",\n \"subscription\"\ + : null,\n \"success_url\": \"http://testserver/looper/stripe_success/1/{CHECKOUT_SESSION_ID}\"\ + ,\n \"total_details\": {\n \"amount_discount\": 0,\n \"amount_shipping\"\ + : 0,\n \"amount_tax\": 0\n },\n \"ui_mode\": \"hosted\",\n \"url\": \"\ + https://checkout.stripe.com/c/pay/cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl\"\ + \n}" + content_type: text/plain + method: POST + status: 200 + url: https://api.stripe.com/v1/checkout/sessions diff --git a/subscriptions/tests/_responses/stripe_new_cs_setup.yaml b/subscriptions/tests/_responses/stripe_new_cs_setup.yaml new file mode 100644 index 00000000..478ec93d --- /dev/null +++ b/subscriptions/tests/_responses/stripe_new_cs_setup.yaml @@ -0,0 +1,51 @@ +responses: +- response: + auto_calculate_content_length: false + body: "{\n \"id\": \"cus_QGeKULCHd4p9o2\",\n \"object\": \"customer\",\n \"\ + address\": null,\n \"balance\": 0,\n \"created\": 1718021356,\n \"currency\"\ + : null,\n \"default_source\": null,\n \"delinquent\": false,\n \"description\"\ + : null,\n \"discount\": null,\n \"email\": \"billing@example.com\",\n \"\ + invoice_prefix\": \"0ABA8C57\",\n \"invoice_settings\": {\n \"custom_fields\"\ + : null,\n \"default_payment_method\": null,\n \"footer\": null,\n \"\ + rendering_options\": null\n },\n \"livemode\": false,\n \"metadata\": {},\n\ + \ \"name\": \"\u0410\u043B\u0435\u043A\u0441\u0435\u0439 \u041D.\",\n \"phone\"\ + : null,\n \"preferred_locales\": [],\n \"shipping\": null,\n \"tax_exempt\"\ + : \"none\",\n \"test_clock\": null\n}" + content_type: text/plain; charset=utf-8 + method: POST + status: 200 + url: https://api.stripe.com/v1/customers +- response: + auto_calculate_content_length: false + body: "{\n \"id\": \"cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9\"\ + ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\ + allow_promotion_codes\": null,\n \"amount_subtotal\": null,\n \"amount_total\"\ + : null,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\"\ + : null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\ + \ \"cancel_url\": \"http://testserver/settings/billing/\",\n \"client_reference_id\"\ + : null,\n \"client_secret\": null,\n \"consent\": null,\n \"consent_collection\"\ + : null,\n \"created\": 1718021357,\n \"currency\": null,\n \"currency_conversion\"\ + : null,\n \"custom_fields\": [],\n \"custom_text\": {\n \"after_submit\"\ + : null,\n \"shipping_address\": null,\n \"submit\": null,\n \"terms_of_service_acceptance\"\ + : null\n },\n \"customer\": \"cus_QGeKULCHd4p9o2\",\n \"customer_creation\"\ + : null,\n \"customer_details\": {\n \"address\": null,\n \"email\": \"\ + billing@example.com\",\n \"name\": null,\n \"phone\": null,\n \"tax_exempt\"\ + : null,\n \"tax_ids\": null\n },\n \"customer_email\": null,\n \"expires_at\"\ + : 1718107757,\n \"invoice\": null,\n \"invoice_creation\": null,\n \"livemode\"\ + : false,\n \"locale\": null,\n \"metadata\": {},\n \"mode\": \"setup\",\n\ + \ \"payment_intent\": null,\n \"payment_link\": null,\n \"payment_method_collection\"\ + : \"always\",\n \"payment_method_configuration_details\": null,\n \"payment_method_options\"\ + : {\n \"card\": {\n \"request_three_d_secure\": \"automatic\"\n }\n\ + \ },\n \"payment_method_types\": [\n \"card\",\n \"link\",\n \"paypal\"\ + \n ],\n \"payment_status\": \"no_payment_required\",\n \"phone_number_collection\"\ + : {\n \"enabled\": false\n },\n \"recovered_from\": null,\n \"saved_payment_method_options\"\ + : null,\n \"setup_intent\": \"seti_1PQ72HE4KAUB5djsPthAKRdZ\",\n \"shipping_address_collection\"\ + : null,\n \"shipping_cost\": null,\n \"shipping_details\": null,\n \"shipping_options\"\ + : [],\n \"status\": \"open\",\n \"submit_type\": null,\n \"subscription\"\ + : null,\n \"success_url\": \"http://testserver/subscription/1/payment-method/change/{CHECKOUT_SESSION_ID}/\"\ + ,\n \"total_details\": null,\n \"ui_mode\": \"hosted\",\n \"url\": \"https://checkout.stripe.com/c/pay/cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBaZmppcGhrJyknYGtkZ2lgVWlkZmBtamlhYHd2Jz9xd3BgeCUl\"\ + \n}" + content_type: text/plain + method: POST + status: 200 + url: https://api.stripe.com/v1/checkout/sessions diff --git a/subscriptions/tests/_responses/stripe_new_cs_usd.yaml b/subscriptions/tests/_responses/stripe_new_cs_usd.yaml new file mode 100644 index 00000000..1c9602c0 --- /dev/null +++ b/subscriptions/tests/_responses/stripe_new_cs_usd.yaml @@ -0,0 +1,58 @@ +responses: +- response: + auto_calculate_content_length: false + body: "{\n \"id\": \"cus_QGhXPj2pOTBdSo\",\n \"object\": \"customer\",\n \"\ + address\": null,\n \"balance\": 0,\n \"created\": 1718033249,\n \"currency\"\ + : null,\n \"default_source\": null,\n \"delinquent\": false,\n \"description\"\ + : null,\n \"discount\": null,\n \"email\": \"billing@example.com\",\n \"\ + invoice_prefix\": \"B062701B\",\n \"invoice_settings\": {\n \"custom_fields\"\ + : null,\n \"default_payment_method\": null,\n \"footer\": null,\n \"\ + rendering_options\": null\n },\n \"livemode\": false,\n \"metadata\": {},\n\ + \ \"name\": \"\u0410\u043B\u0435\u043A\u0441\u0435\u0439 \u041D.\",\n \"phone\"\ + : null,\n \"preferred_locales\": [],\n \"shipping\": null,\n \"tax_exempt\"\ + : \"none\",\n \"test_clock\": null\n}" + content_type: text/plain; charset=utf-8 + method: POST + status: 200 + url: https://api.stripe.com/v1/customers +- response: + auto_calculate_content_length: false + body: "{\n \"id\": \"cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w\"\ + ,\n \"object\": \"checkout.session\",\n \"after_expiration\": null,\n \"\ + allow_promotion_codes\": null,\n \"amount_subtotal\": 1110,\n \"amount_total\"\ + : 1110,\n \"automatic_tax\": {\n \"enabled\": false,\n \"liability\"\ + : null,\n \"status\": null\n },\n \"billing_address_collection\": null,\n\ + \ \"cancel_url\": \"http://testserver/subscription/1/manage/\",\n \"client_reference_id\"\ + : null,\n \"client_secret\": null,\n \"consent\": null,\n \"consent_collection\"\ + : null,\n \"created\": 1718033250,\n \"currency\": \"usd\",\n \"currency_conversion\"\ + : null,\n \"custom_fields\": [],\n \"custom_text\": {\n \"after_submit\"\ + : null,\n \"shipping_address\": null,\n \"submit\": null,\n \"terms_of_service_acceptance\"\ + : null\n },\n \"customer\": \"cus_QGhXPj2pOTBdSo\",\n \"customer_creation\"\ + : null,\n \"customer_details\": {\n \"address\": null,\n \"email\": \"\ + billing@example.com\",\n \"name\": null,\n \"phone\": null,\n \"tax_exempt\"\ + : \"none\",\n \"tax_ids\": null\n },\n \"customer_email\": null,\n \"\ + expires_at\": 1718119650,\n \"invoice\": null,\n \"invoice_creation\": {\n\ + \ \"enabled\": false,\n \"invoice_data\": {\n \"account_tax_ids\"\ + : null,\n \"custom_fields\": null,\n \"description\": null,\n \ + \ \"footer\": null,\n \"issuer\": null,\n \"metadata\": {},\n \ + \ \"rendering_options\": null\n }\n },\n \"livemode\": false,\n \"locale\"\ + : null,\n \"metadata\": {},\n \"mode\": \"payment\",\n \"payment_intent\"\ + : null,\n \"payment_link\": null,\n \"payment_method_collection\": \"if_required\"\ + ,\n \"payment_method_configuration_details\": null,\n \"payment_method_options\"\ + : {\n \"card\": {\n \"request_three_d_secure\": \"automatic\"\n }\n\ + \ },\n \"payment_method_types\": [\n \"card\",\n \"link\",\n \"paypal\"\ + \n ],\n \"payment_status\": \"unpaid\",\n \"phone_number_collection\": {\n\ + \ \"enabled\": false\n },\n \"recovered_from\": null,\n \"saved_payment_method_options\"\ + : {\n \"allow_redisplay_filters\": [\n \"always\"\n ],\n \"payment_method_remove\"\ + : null,\n \"payment_method_save\": null\n },\n \"setup_intent\": null,\n\ + \ \"shipping_address_collection\": null,\n \"shipping_cost\": null,\n \"\ + shipping_details\": null,\n \"shipping_options\": [],\n \"status\": \"open\"\ + ,\n \"submit_type\": \"pay\",\n \"subscription\": null,\n \"success_url\"\ + : \"http://testserver/looper/stripe_success/1/{CHECKOUT_SESSION_ID}\",\n \"\ + total_details\": {\n \"amount_discount\": 0,\n \"amount_shipping\": 0,\n\ + \ \"amount_tax\": 0\n },\n \"ui_mode\": \"hosted\",\n \"url\": \"https://checkout.stripe.com/c/pay/cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl\"\ + \n}" + content_type: text/plain + method: POST + status: 200 + url: https://api.stripe.com/v1/checkout/sessions diff --git a/subscriptions/tests/_responses/vies_valid.yaml b/subscriptions/tests/_responses/vies_valid.yaml new file mode 100644 index 00000000..8bf5ac12 --- /dev/null +++ b/subscriptions/tests/_responses/vies_valid.yaml @@ -0,0 +1,9 @@ +responses: +- response: + auto_calculate_content_length: false + body: 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..1f79b9d9 --- /dev/null +++ b/subscriptions/tests/_responses/vies_wsdl.yaml @@ -0,0 +1,106 @@ +responses: +- response: + auto_calculate_content_length: false + body: "\n\n \n\ + \ blah blah blah time period, try again later. \n\t\n\ + \ \n \n \n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\ + \t\t\t\t\t\n\t\t\t\t\t\ + \t\n\t\t\t\t\t\n\ + \t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\ + \t\t\t\t\t\n\t\t\t\t\t\ + \t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\ + \n\t\t\t\n\t\t\t\t\n\ + \t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\ + \n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\ + \t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\ + \t\t\t\n\t\t\t\n\t\ + \t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\ + \t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\ + \n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\ + \t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\ + \t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tVALID\n\ + \t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n \n \ + \ INVALID\n \ + \ \n \n \ + \ \n \n\ + \ NOT_PROCESSED\n\ + \ \n \n\ + \t\t\t\t\n\t\t\t\n\t\t\n \n\ + \ \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \ + \ \n \ + \ \n \n \n \n \n \n \n \n \n \n\ + \ \n \n \n \n \n \n \n \n \n \n \n \n\ + \ \n \n \n \n \n \n \n \n \n\ + \ \n \n \n \n \n \n\n" + content_type: text/plain + method: GET + status: 200 + url: https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl diff --git a/subscriptions/tests/base.py b/subscriptions/tests/base.py index 6227ec92..ad54574b 100644 --- a/subscriptions/tests/base.py +++ b/subscriptions/tests/base.py @@ -1,6 +1,6 @@ +from typing import Tuple import os -from django.contrib.auth import get_user_model from django.core import mail from django.db.models import signals from django.test import TestCase @@ -8,10 +8,12 @@ from django.urls import reverse import factory import responses -from common.tests.factories.subscriptions import create_customer_with_billing_address +from looper.tests.factories import create_customer_with_billing_address +import looper.models + import users.tests.util as util -User = get_user_model() +responses_dir = 'subscriptions/tests/_responses/' def _write_mail(mail, index=0): @@ -24,16 +26,41 @@ def _write_mail(mail, index=0): f.write(str(content)) +def responses_from_file(file_name: str, rsps=responses, order_id=None): + """Add a response mock from file, override `order_id` metadata with a given one.""" + rsps._add_from_file(f'{responses_dir}{file_name}') + # Replace metadata's "order_id" hardcoded in the response YAML with current order ID, + # because it differs depending on whether this test is run alone or with all the tests. + for _ in rsps.registered(): + if '%5D=payment_intent' in _.url: + assert '\"order_id\": \"1' in _.body + _.body = _.body.replace('\"order_id\": \"1', f'\"order_id\": \"{order_id}') + + class BaseSubscriptionTestCase(TestCase): + fixtures = ['gateways'] + + def _get_url_for(self, **filter_params) -> Tuple[str, looper.models.PlanVariation]: + plan_variation = looper.models.PlanVariation.objects.active().get(**filter_params) + return ( + reverse( + 'subscriptions:join-billing-details', + kwargs={'plan_variation_id': plan_variation.pk}, + ), + plan_variation, + ) + @factory.django.mute_signals(signals.pre_save, signals.post_save) def setUp(self): + super().setUp() + # Allow requests to Braintree Sandbox responses.add_passthru('https://api.sandbox.braintreegateway.com:443/') # Create the admin user used for logging self.admin_user = util.create_admin_log_user() - self.user = create_customer_with_billing_address( + self.customer = create_customer_with_billing_address( full_name='Алексей Н.', company='Testcompany B.V.', street_address='Billing street 1', @@ -43,11 +70,18 @@ class BaseSubscriptionTestCase(TestCase): region='North Holland', country='NL', vat_number='NL-KVK-41202535', - billing_email='billing@example.com', + email='billing@example.com', ) - self.customer = self.user.customer + self.user = self.customer.user self.billing_address = self.customer.billing_address + responses._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml') + + def tearDown(self): + super().tearDown() + responses.stop() + responses.reset() + def _mock_vies_response(self, is_valid=True, is_broken=False): path = os.path.abspath(__file__) dir_path = os.path.join(os.path.dirname(path), 'vies') @@ -86,12 +120,13 @@ class BaseSubscriptionTestCase(TestCase): self._assert_continue_to_payment_displayed(response) self.assertContains(response, 'id_street_address') self.assertContains(response, 'id_full_name') + self.assertContains(response, 'name="gateway" value="stripe"') - def _assert_payment_form_displayed(self, response): - self.assertNotContains(response, 'Pricing has been updated') - self.assertNotContains(response, 'Continue to Payment') - self.assertContains(response, 'payment method') - self.assertContains(response, 'Confirm and Pay') + def _assert_pay_via_bank_not_displayed(self, response): + self.assertNotContains(response, 'name="gateway" value="bank"') + + def _assert_pay_via_bank_displayed(self, response): + self.assertContains(response, 'name="gateway" value="bank"') def _assert_pricing_has_been_updated(self, response): self.assertContains(response, 'Pricing has been updated') @@ -319,7 +354,7 @@ class BaseSubscriptionTestCase(TestCase): self.assertContains(response, '

Bank details:

', html=True) self.assertContains(response, 'on hold') self.assertContains(response, 'NL07 INGB 0008 4489 82') - subscription = response.wsgi_request.user.subscription_set.first() + subscription = response.wsgi_request.user.customer.subscription_set.first() self.assertContains( response, f'Blender Studio order-{subscription.latest_order().display_number}' ) @@ -341,11 +376,11 @@ class BaseSubscriptionTestCase(TestCase): self.assertEqual(len(mail.outbox), 0) def _assert_bank_transfer_email_is_sent(self, subscription): - user = subscription.user + customer = subscription.customer self.assertEqual(len(mail.outbox), 1) _write_mail(mail) email = mail.outbox[0] - self.assertEqual(email.to, [user.customer.billing_email]) + self.assertEqual(email.to, [customer.billing_address.email]) # TODO(anna): set the correct reply_to self.assertEqual(email.reply_to, []) # TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL @@ -384,11 +419,11 @@ class BaseSubscriptionTestCase(TestCase): self.assertIn('Recurring total: €\xa026.45', email_body.replace(' ', ' ')) def _assert_subscription_activated_email_is_sent(self, subscription): - user = subscription.user + customer = subscription.customer self.assertEqual(len(mail.outbox), 1) _write_mail(mail) email = mail.outbox[0] - self.assertEqual(email.to, [user.customer.billing_email]) + self.assertEqual(email.to, [customer.billing_address.email]) # TODO(anna): set the correct reply_to self.assertEqual(email.reply_to, []) # TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL @@ -397,17 +432,17 @@ class BaseSubscriptionTestCase(TestCase): self.assertEqual(email.alternatives[0][1], 'text/html') for email_body in (email.body, email.alternatives[0][0]): self.assertIn('activated', email_body) - self.assertIn(f'Dear {user.customer.full_name},', email_body) + self.assertIn(f'Dear {customer.billing_address.full_name},', email_body) self.assertIn(reverse('user-settings-billing'), email_body) self.assertIn('Automatic renewal subscription', email_body) self.assertIn('Blender Studio Team', email_body) def _assert_team_subscription_activated_email_is_sent(self, subscription): - user = subscription.user + customer = subscription.customer self.assertEqual(len(mail.outbox), 1) _write_mail(mail) email = mail.outbox[0] - self.assertEqual(email.to, [user.customer.billing_email]) + self.assertEqual(email.to, [customer.billing_address.email]) # TODO(anna): set the correct reply_to self.assertEqual(email.reply_to, []) # TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL @@ -416,17 +451,17 @@ class BaseSubscriptionTestCase(TestCase): self.assertEqual(email.alternatives[0][1], 'text/html') for email_body in (email.body, email.alternatives[0][0]): self.assertIn('activated', email_body) - self.assertIn(f'Dear {user.customer.full_name},', email_body) + self.assertIn(f'Dear {customer.billing_address.full_name},', email_body) self.assertIn(reverse('user-settings-billing'), email_body) self.assertIn('Automatic renewal, 15 seats subscription', email_body) self.assertIn('Blender Studio Team', email_body) def _assert_subscription_deactivated_email_is_sent(self, subscription): - user = subscription.user + customer = subscription.customer self.assertEqual(len(mail.outbox), 1) _write_mail(mail) email = mail.outbox[0] - self.assertEqual(email.to, [user.customer.billing_email]) + self.assertEqual(email.to, [customer.billing_address.email]) # TODO(anna): set the correct reply_to self.assertEqual(email.reply_to, []) # TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL @@ -440,11 +475,12 @@ class BaseSubscriptionTestCase(TestCase): self.assertIn('Blender Studio Team', email_body) def _assert_payment_soft_failed_email_is_sent(self, subscription): - user = subscription.user + customer = subscription.customer + user = customer.user self.assertEqual(len(mail.outbox), 1) _write_mail(mail) email = mail.outbox[0] - self.assertEqual(email.to, [user.customer.billing_email]) + self.assertEqual(email.to, [user.customer.billing_address.email]) # TODO(anna): set the correct reply_to self.assertEqual(email.reply_to, []) # TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL @@ -454,7 +490,7 @@ class BaseSubscriptionTestCase(TestCase): ) self.assertEqual(email.alternatives[0][1], 'text/html') for email_body in (email.body, email.alternatives[0][0]): - self.assertIn(f'Dear {user.customer.full_name},', email_body) + self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body) self.assertIn('Automatic payment', email_body) self.assertIn('failed', email_body) self.assertIn('try again', email_body) @@ -470,11 +506,12 @@ class BaseSubscriptionTestCase(TestCase): self.assertIn('Blender Studio Team', email_body) def _assert_payment_failed_email_is_sent(self, subscription): - user = subscription.user + customer = subscription.customer + user = customer.user self.assertEqual(len(mail.outbox), 1) _write_mail(mail) email = mail.outbox[0] - self.assertEqual(email.to, [user.customer.billing_email]) + self.assertEqual(email.to, [user.customer.billing_address.email]) # TODO(anna): set the correct reply_to self.assertEqual(email.reply_to, []) # TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL @@ -482,7 +519,7 @@ class BaseSubscriptionTestCase(TestCase): self.assertEqual(email.subject, 'Blender Studio Subscription: payment failed') self.assertEqual(email.alternatives[0][1], 'text/html') for email_body in (email.body, email.alternatives[0][0]): - self.assertIn(f'Dear {user.customer.full_name},', email_body) + self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body) self.assertIn('Automatic payment', email_body) self.assertIn('failed', email_body) self.assertIn('3 times', email_body) @@ -497,11 +534,12 @@ class BaseSubscriptionTestCase(TestCase): self.assertIn('Blender Studio Team', email_body) def _assert_payment_paid_email_is_sent(self, subscription): - user = subscription.user + customer = subscription.customer + user = customer.user self.assertEqual(len(mail.outbox), 1) _write_mail(mail) email = mail.outbox[0] - self.assertEqual(email.to, [user.customer.billing_email]) + self.assertEqual(email.to, [user.customer.billing_address.email]) # TODO(anna): set the correct reply_to self.assertEqual(email.reply_to, []) # TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL @@ -509,7 +547,7 @@ class BaseSubscriptionTestCase(TestCase): self.assertEqual(email.subject, 'Blender Studio Subscription: payment received') self.assertEqual(email.alternatives[0][1], 'text/html') for email_body in (email.body, email.alternatives[0][0]): - self.assertIn(f'Dear {user.customer.full_name},', email_body) + self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body) self.assertIn('Automatic monthly payment', email_body) self.assertIn('successful', email_body) self.assertIn('$\xa011.10', email_body) @@ -523,7 +561,8 @@ class BaseSubscriptionTestCase(TestCase): self.assertIn('Blender Studio Team', email_body) def _assert_managed_subscription_notification_email_is_sent(self, subscription): - user = subscription.user + customer = subscription.customer + user = customer.user self.assertEqual(len(mail.outbox), 1) _write_mail(mail) email = mail.outbox[0] @@ -533,7 +572,7 @@ class BaseSubscriptionTestCase(TestCase): self.assertEqual(email.subject, 'Blender Studio managed subscription needs attention') self.assertEqual(email.alternatives[0][1], 'text/html') for email_body in (email.body, email.alternatives[0][0]): - self.assertIn(f'{user.customer.full_name} has', email_body) + self.assertIn(f'{user.customer.billing_address.full_name} has', email_body) self.assertIn('its next payment date', email_body) self.assertIn('$\xa011.10', email_body) self.assertIn( @@ -542,16 +581,17 @@ class BaseSubscriptionTestCase(TestCase): ) def _assert_subscription_expired_email_is_sent(self, subscription): - user = subscription.user + customer = subscription.customer + user = customer.user self.assertEqual(len(mail.outbox), 1) _write_mail(mail) email = mail.outbox[0] - self.assertEqual(email.to, [subscription.user.email]) + self.assertEqual(email.to, [user.email]) self.assertEqual(email.from_email, 'webmaster@localhost') self.assertEqual(email.subject, 'We miss you at Blender Studio') self.assertEqual(email.alternatives[0][1], 'text/html') for email_body in (email.body, email.alternatives[0][0]): - self.assertIn(f'Dear {user.customer.full_name}', email_body) + self.assertIn(f'Dear {user.customer.billing_address.full_name}', email_body) self.assertIn(f'#{subscription.pk}', email_body) self.assertIn('has expired', email_body) self.assertIn( diff --git a/subscriptions/tests/test_clock.py b/subscriptions/tests/test_clock.py index 54ad8b76..d60fcac5 100644 --- a/subscriptions/tests/test_clock.py +++ b/subscriptions/tests/test_clock.py @@ -11,27 +11,26 @@ from looper import admin_log from looper.clock import Clock from looper.models import Gateway, Subscription from looper.money import Money +from looper.tests.factories import SubscriptionFactory, create_customer_with_billing_address -from common.tests.factories.subscriptions import ( - SubscriptionFactory, - create_customer_with_billing_address, -) from subscriptions.tests.base import BaseSubscriptionTestCase import subscriptions.tasks import users.tasks import users.tests.util as util +from common.tests.factories.users import OAuthUserInfoFactory -class TestClock(BaseSubscriptionTestCase): +class TestClockBraintree(BaseSubscriptionTestCase): def _create_subscription_due_now(self) -> Subscription: - user = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + OAuthUserInfoFactory(user=customer.user, oauth_user_id=554433) now = timezone.now() with mock.patch('django.utils.timezone.now') as mock_now: mock_now.return_value = now + relativedelta(months=-1) # print('fake now:', mock_now.return_value) subscription = SubscriptionFactory( - user=user, - payment_method__user_id=user.pk, + customer=customer, + payment_method__customer_id=customer.pk, payment_method__recognisable_name='Test payment method', payment_method__gateway=Gateway.objects.get(name='braintree'), currency='USD', @@ -52,6 +51,9 @@ class TestClock(BaseSubscriptionTestCase): def setUp(self): super().setUp() + # Allow requests to Braintree Sandbox + responses.add_passthru('https://api.sandbox.braintreegateway.com:443/') + self.subscription = self._create_subscription_due_now() @patch( @@ -114,7 +116,7 @@ class TestClock(BaseSubscriptionTestCase): # Tick the clock and check that order and transaction were created util.mock_blender_id_badger_badger_response( - 'revoke', 'cloud_subscriber', self.subscription.user.oauth_info.oauth_user_id + 'revoke', 'cloud_subscriber', self.subscription.customer.user.oauth_info.oauth_user_id ) Clock().tick() @@ -167,7 +169,7 @@ class TestClock(BaseSubscriptionTestCase): # Create another active subscription for the same user SubscriptionFactory( - user=self.subscription.user, + customer=self.subscription.customer, payment_method=self.subscription.payment_method, currency='USD', price=Money('USD', 1110), @@ -190,10 +192,12 @@ class TestClock(BaseSubscriptionTestCase): ) def test_automated_payment_paid_email_is_sent(self): now = timezone.now() + self.assertEqual(self.subscription.collection_method, 'automatic') # Tick the clock and check that subscription renews, order and transaction were created with patch( - 'looper.gateways.BraintreeGateway.transact_sale', return_value='mock-transaction-id' + 'looper.gateways.BraintreeGateway.transact_sale', + return_value={'transaction_id': 'mock-transaction-id'}, ): Clock().tick() @@ -254,9 +258,9 @@ class TestClock(BaseSubscriptionTestCase): class TestClockExpiredSubscription(BaseSubscriptionTestCase): def test_subscription_on_hold_not_long_enough(self): now = timezone.now() - user = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe') self.subscription = SubscriptionFactory( - user=user, + customer=customer, status='on-hold', # payment date has passed, but not long enough ago next_payment=now - timedelta(weeks=4), @@ -283,15 +287,16 @@ class TestClockExpiredSubscription(BaseSubscriptionTestCase): @responses.activate def test_subscription_on_hold_too_long_status_changed_to_expired_email_sent(self): now = timezone.now() - user = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + OAuthUserInfoFactory(user=customer.user, oauth_user_id=223344) self.subscription = SubscriptionFactory( - user=user, + customer=customer, status='on-hold', # payment date has passed a long long time ago next_payment=now - timedelta(weeks=4 * 10), ) util.mock_blender_id_badger_badger_response( - 'revoke', 'cloud_subscriber', user.oauth_info.oauth_user_id + 'revoke', 'cloud_subscriber', customer.user.oauth_info.oauth_user_id ) Clock().tick() diff --git a/subscriptions/tests/test_forms.py b/subscriptions/tests/test_forms.py index 9f3caccb..7aa57de0 100644 --- a/subscriptions/tests/test_forms.py +++ b/subscriptions/tests/test_forms.py @@ -21,7 +21,6 @@ class TestBillingAddressForm(BaseSubscriptionTestCase): def test_instance_loads_both_address_and_customer_data(self): form = BillingAddressForm(instance=self.billing_address) - # N.B.: email is loaded from Customer.billing_email self.assertEqual(form['email'].value(), 'billing@example.com') self.assertEqual(form['company'].value(), 'Testcompany B.V.') self.assertEqual(form['country'].value(), 'NL') @@ -215,15 +214,12 @@ class TestBillingAddressForm(BaseSubscriptionTestCase): class TestPaymentForm(BaseSubscriptionTestCase): required_payment_form_data = { 'gateway': 'bank', - 'payment_method_nonce': 'fake-nonce', 'plan_variation_id': 1, - 'price': '9.90', } def test_instance_loads_both_address_and_customer_data(self): form = PaymentForm(instance=self.billing_address) - # N.B.: email is loaded from Customer.billing_email self.assertEqual(form['email'].value(), 'billing@example.com') self.assertEqual(form['company'].value(), 'Testcompany B.V.') self.assertEqual(form['country'].value(), 'NL') @@ -246,8 +242,6 @@ class TestPaymentForm(BaseSubscriptionTestCase): 'email': ['This field is required.'], 'full_name': ['This field is required.'], 'gateway': ['This field is required.'], - 'payment_method_nonce': ['This field is required.'], - 'price': ['This field is required.'], }, ) diff --git a/subscriptions/tests/test_queries.py b/subscriptions/tests/test_queries.py index 21219c7d..12328fb8 100644 --- a/subscriptions/tests/test_queries.py +++ b/subscriptions/tests/test_queries.py @@ -1,7 +1,9 @@ from django.test import TestCase +from looper.tests.factories import SubscriptionFactory + from common.tests.factories.users import UserFactory -from common.tests.factories.subscriptions import SubscriptionFactory, TeamFactory +from common.tests.factories.subscriptions import TeamFactory import looper.models @@ -25,12 +27,12 @@ class TestHasActiveSubscription(TestCase): status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) - self.assertTrue(has_active_subscription(subscription.user)) + self.assertTrue(has_active_subscription(subscription.customer.user)) def test_false_when_subscription_inactive(self): subscription = SubscriptionFactory(plan_id=1) - self.assertFalse(has_active_subscription(subscription.user)) + self.assertFalse(has_active_subscription(subscription.customer.user)) def test_false_when_team_subscription_inactive(self): team = TeamFactory(subscription__plan_id=1) @@ -60,17 +62,17 @@ class TestHasNotYetCancelledSubscription(TestCase): status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) - self.assertTrue(has_not_yet_cancelled_subscription(subscription.user)) + self.assertTrue(has_not_yet_cancelled_subscription(subscription.customer.user)) def test_false_when_subscription_cancelled(self): subscription = SubscriptionFactory(plan_id=1, status='cancelled') - self.assertFalse(has_not_yet_cancelled_subscription(subscription.user)) + self.assertFalse(has_not_yet_cancelled_subscription(subscription.customer.user)) def test_true_when_subscription_inactive(self): subscription = SubscriptionFactory(plan_id=1) - self.assertTrue(has_not_yet_cancelled_subscription(subscription.user)) + self.assertTrue(has_not_yet_cancelled_subscription(subscription.customer.user)) def test_false_when_team_subscription_inactive(self): team = TeamFactory(subscription__plan_id=1) @@ -103,7 +105,7 @@ class TestHasNotYetCancelledSubscription(TestCase): ) team.team_users.create(user=UserFactory()) SubscriptionFactory( - user=team.team_users.first().user, + customer=team.team_users.first().user.customer, plan_id=1, status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) @@ -117,7 +119,7 @@ class TestHasNotYetCancelledSubscription(TestCase): ) team.team_users.create(user=UserFactory()) SubscriptionFactory( - user=team.team_users.first().user, + customer=team.team_users.first().user.customer, plan_id=1, status='cancelled', ) @@ -137,12 +139,12 @@ class TestHasSubscription(TestCase): status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) - self.assertTrue(has_subscription(subscription.user)) + self.assertTrue(has_subscription(subscription.customer.user)) def test_true_when_subscription_inactive(self): subscription = SubscriptionFactory(plan_id=1) - self.assertTrue(has_subscription(subscription.user)) + self.assertTrue(has_subscription(subscription.customer.user)) def test_true_when_team_subscription_inactive(self): team = TeamFactory(subscription__plan_id=1) @@ -166,12 +168,12 @@ class TestHasSubscription(TestCase): is_legacy=True, ) - self.assertTrue(has_subscription(subscription.user)) + self.assertTrue(has_subscription(subscription.customer.user)) def test_true_when_subscription_inactive_and_is_legacy(self): subscription = SubscriptionFactory(plan_id=1, is_legacy=True) - self.assertTrue(has_subscription(subscription.user)) + self.assertTrue(has_subscription(subscription.customer.user)) def test_true_when_team_subscription_inactive_and_is_legacy(self): team = TeamFactory(subscription__plan_id=1, subscription__is_legacy=True) @@ -192,12 +194,12 @@ class TestHasNonLegacySubscription(TestCase): status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], ) - self.assertTrue(has_non_legacy_subscription(subscription.user)) + self.assertTrue(has_non_legacy_subscription(subscription.customer.user)) def test_true_when_subscription_inactive_and_not_is_legacy(self): subscription = SubscriptionFactory(plan_id=1) - self.assertTrue(has_non_legacy_subscription(subscription.user)) + self.assertTrue(has_non_legacy_subscription(subscription.customer.user)) def test_true_when_team_subscription_inactive_and_not_is_legacy(self): team = TeamFactory(subscription__plan_id=1) @@ -208,7 +210,7 @@ class TestHasNonLegacySubscription(TestCase): def test_false_when_subscription_inactive_and_is_legacy(self): subscription = SubscriptionFactory(plan_id=1, is_legacy=True) - self.assertFalse(has_non_legacy_subscription(subscription.user)) + self.assertFalse(has_non_legacy_subscription(subscription.customer.user)) def test_false_when_subscription_active_and_is_legacy(self): subscription = SubscriptionFactory( @@ -217,7 +219,7 @@ class TestHasNonLegacySubscription(TestCase): is_legacy=True, ) - self.assertFalse(has_non_legacy_subscription(subscription.user)) + self.assertFalse(has_non_legacy_subscription(subscription.customer.user)) def test_false_when_team_subscription_inactive_and_is_legacy(self): team = TeamFactory(subscription__plan_id=1, subscription__is_legacy=True) diff --git a/subscriptions/urls.py b/subscriptions/urls.py index c1911980..34ee0202 100644 --- a/subscriptions/urls.py +++ b/subscriptions/urls.py @@ -2,7 +2,7 @@ from django.urls import path, re_path from looper.views import settings as looper_settings -from subscriptions.views.join import BillingDetailsView, ConfirmAndPayView +from subscriptions.views.join import JoinView from subscriptions.views.select_plan_variation import ( SelectPlanVariationView, SelectTeamPlanVariationView, @@ -26,14 +26,9 @@ urlpatterns = [ ), path( 'join/plan-variation//billing/', - BillingDetailsView.as_view(), + JoinView.as_view(), name='join-billing-details', ), - path( - 'join/plan-variation//confirm/', - ConfirmAndPayView.as_view(), - name='join-confirm-and-pay', - ), path( 'subscription//manage/', settings.ManageSubscriptionView.as_view(), @@ -49,15 +44,18 @@ urlpatterns = [ settings.PaymentMethodChangeView.as_view(), name='payment-method-change', ), + path( + 'subscription//payment-method/change//', + settings.PaymentMethodChangeDoneView.as_view(), + name='payment-method-change-done', + ), path( 'subscription/order//pay/', settings.PayExistingOrderView.as_view(), name='pay-existing-order', ), path( - 'settings/billing-address/', - settings.BillingAddressView.as_view(), - name='billing-address', + 'settings/billing-address/', settings.BillingAddressView.as_view(), name='billing-address' ), path('settings/receipts/', looper_settings.settings_receipts, name='receipts'), path( diff --git a/subscriptions/views/join.py b/subscriptions/views/join.py index 8ebcd61b..0aedc2e2 100644 --- a/subscriptions/views/join.py +++ b/subscriptions/views/join.py @@ -1,23 +1,21 @@ """Views handling subscription management.""" -from decimal import Decimal import logging -from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.db import transaction from django.shortcuts import redirect, get_object_or_404 +from django.urls import reverse from django.views.generic import FormView -from looper.middleware import COUNTRY_CODE_SESSION_KEY -from looper.views.checkout import AbstractPaymentView, CheckoutView import looper.gateways import looper.middleware import looper.models import looper.money import looper.taxes +from looper.views.checkout_stripe import CheckoutStripeView -from subscriptions.forms import BillingAddressForm, PaymentForm, AutomaticPaymentForm +from subscriptions.forms import PaymentForm from subscriptions.middleware import preferred_currency_for_country_code from subscriptions.queries import should_redirect_to_billing from subscriptions.signals import subscription_created_needs_payment @@ -27,111 +25,145 @@ logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) -class _JoinMixin: - customer: looper.models.Customer +class JoinView(LoginRequiredMixin, FormView): + """Fill in billing details and initiate Stripe checkout session.""" - # FIXME(anna): this view uses some functionality of AbstractPaymentView, - # but cannot directly inherit from them, since JoinView supports creating only one subscription. - get_currency = AbstractPaymentView.get_currency - get_client_token = AbstractPaymentView.get_client_token - client_token_session_key = AbstractPaymentView.client_token_session_key - erase_client_token = AbstractPaymentView.erase_client_token + # FIXME(anna): this view uses some functionality of CheckoutStripeView, + # but cannot directly inherit it, since JoinView supports creating only one subscription. + _fetch_or_create_order = CheckoutStripeView._fetch_or_create_order - @property - def session_key_prefix(self) -> str: - """Separate client tokens by currency code.""" - currency = self.get_currency() - return f'PAYMENT_GATEWAY_CLIENT_TOKEN_{currency}' + template_name = 'subscriptions/join/billing_address.html' + form_class = PaymentForm def _get_existing_subscription(self): # Exclude cancelled subscriptions because they cannot transition to active - existing_subscriptions = self.request.user.subscription_set.exclude( + existing_subscriptions = self.request.user.customer.subscription_set.exclude( status__in=looper.models.Subscription._CANCELLED_STATUSES ) return existing_subscriptions.first() + def _set_preferred_currency_and_redirect(self): + # If no country is set in the existing address, use GeoIP's + geoip_country = self.request.session.get(looper.middleware.COUNTRY_CODE_SESSION_KEY) + if geoip_country and (not self.customer or not self.customer.billing_address.country): + country = geoip_country + else: + country = self.customer.billing_address.country + currency = preferred_currency_for_country_code(country) + if self.plan_variation.currency != currency: + # If variation's currency doesn't match, redirect to another plan variation + plan_variation = self.plan_variation.in_other_currency(currency) + self.request.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY] = currency + self.request.session.modified = True + return redirect( + 'subscriptions:join-billing-details', plan_variation_id=plan_variation.pk + ) + return None # nothing to do, no need to redirect + def dispatch(self, request, *args, **kwargs): - """Set customer for authenticated user, same as AbstractPaymentView does.""" + """Redirect to login or to billing, or prepare plan variation.""" + if not request.user.is_authenticated: + return self.handle_no_permission() + + if should_redirect_to_billing(request.user): + return redirect('user-settings-billing') + plan_variation_id = kwargs['plan_variation_id'] self.plan_variation = get_object_or_404( looper.models.PlanVariation, pk=plan_variation_id, is_active=True, - currency=self.get_currency(), ) - if not getattr(self, 'gateway', None): - self.gateway = looper.models.Gateway.default() - self.user = self.request.user - self.customer = None - self.subscription = None - if self.user.is_authenticated: - self.customer = self.user.customer - self.subscription = self._get_existing_subscription() + + self.user = request.user + self.customer = self.user.customer + self.subscription = self._get_existing_subscription() + + response_redirect = self._set_preferred_currency_and_redirect() + if response_redirect: + return response_redirect return super().dispatch(request, *args, **kwargs) - def get_form_kwargs(self) -> dict: - """Pass extra parameters to the form.""" - form_kwargs = super().get_form_kwargs() - if self.user.is_authenticated: - return { - **form_kwargs, + def get_form_kwargs(self, *args, **kwargs): + """Pass request to the form.""" + form_kwargs = super().get_form_kwargs(*args, **kwargs) + form_kwargs.update( + { + 'request': self.request, + 'plan_variation': self.plan_variation, 'instance': self.customer.billing_address, } + ) return form_kwargs - def get(self, request, *args, **kwargs): - """Redirect to the Store if subscriptions are not enabled.""" - if should_redirect_to_billing(request.user): - return redirect('user-settings-billing') - - return super().get(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - """Redirect anonymous users to login.""" - if request.user.is_anonymous: - return redirect('{}?next={}'.format(settings.LOGIN_URL, request.path)) - - if request.user.is_authenticated: - if self.subscription and self.subscription.status in self.subscription._ACTIVE_STATUSES: - return redirect('user-settings-billing') - - return super().post(request, *args, **kwargs) - - -class BillingDetailsView(_JoinMixin, LoginRequiredMixin, FormView): - """Display billing details form and save them as billing Address and Customer.""" - - template_name = 'subscriptions/join/billing_address.html' - form_class = BillingAddressForm - - customer: looper.models.Customer - - def get_initial(self) -> dict: - """Prefill default payment gateway, country and selected plan options.""" - initial = super().get_initial() - # Only preset country when it's not already selected by the customer - geoip_country = self.request.session.get(COUNTRY_CODE_SESSION_KEY) - if geoip_country and (not self.customer or not self.customer.billing_address.country): - initial['country'] = geoip_country - - # Only set initial values if they aren't already saved to the billing address. - # Initial values always override form data, which leads to confusing issues with views. - if not (self.customer and self.customer.billing_address.full_name): - # Fall back to user's full name, if no full name set already in the billing address: - if self.request.user.full_name: - initial['full_name'] = self.request.user.full_name - return initial - def get_context_data(self, **kwargs) -> dict: - """Add an extra form and gateway's client token.""" + """Add existing subscription to the view and the context.""" return { **super().get_context_data(**kwargs), 'current_plan_variation': self.plan_variation, 'subscription': self.subscription, } + def _get_or_create_subscription( + self, gateway: looper.models.Gateway + ) -> looper.models.Subscription: + subscription = self.subscription + is_new = False + if not subscription: + subscription = looper.models.Subscription(customer=self.customer) + is_new = True + logger_args = [self.customer.pk, gateway] + logger.debug('Creating a new subscription for customer pk=%s, %s', *logger_args) + collection_method = self.plan_variation.collection_method + if collection_method not in gateway.provider.supported_collection_methods: + # FIXME(anna): this breaks plan selector because collection method + # might not match the one selected by the customer. + collection_method = next(iter(gateway.provider.supported_collection_methods)) + + with transaction.atomic(): + subscription.plan = self.plan_variation.plan + subscription.customer = self.customer + # Currency must be set before the price, in case it was changed + subscription.currency = self.plan_variation.currency + subscription.price = self.plan_variation.price + subscription.interval_unit = self.plan_variation.interval_unit + subscription.interval_length = self.plan_variation.interval_length + subscription.collection_method = collection_method + subscription.save() + + if gateway.name == 'bank': + payment_method = self.customer.payment_method_add(None, gateway) + if subscription.payment_method_id != payment_method.pk: + logger.info( + 'Switching subscription pk=%d from payment method pk=%d to pk=%d', + *[subscription.pk, subscription.payment_method_id, payment_method.pk], + ) + subscription.switch_payment_method(payment_method) + + # Configure the team if this is a team plan + if hasattr(subscription.plan, 'team_properties'): + team_properties = subscription.plan.team_properties + team, team_is_new = subscriptions.models.Team.objects.get_or_create( + subscription=subscription, + seats=team_properties.seats, + ) + logger.info( + '%s a team for subscription pk=%r, seats: %s', + team_is_new and 'Created' or 'Updated', + subscription.pk, + team.seats and team.seats or 'unlimited', + ) + + logger.debug('%s subscription pk=%r', is_new and 'Created' or 'Updated', subscription.pk) + return subscription + + def form_invalid(self, form, *args, **kwargs): + """Temporarily log all validation errors.""" + logger.exception('Validation error in JoinView: %s, %s', form.errors, form.data) + return super().form_invalid(form, *args, **kwargs) + def form_valid(self, form): - """Save the billing details and pass the data to the payment form.""" + """Save the billing details and redirect to Stripe's checkout.""" product_type = self.plan_variation.plan.product.type # Get the tax the same way the template does, # to detect if it was affected by changes to the billing details @@ -144,17 +176,10 @@ class BillingDetailsView(_JoinMixin, LoginRequiredMixin, FormView): form.save() msg = 'Pricing has been updated to reflect changes to your billing details' - new_country = self.customer.billing_address.country - new_currency = preferred_currency_for_country_code(new_country) - # Compare currency before and after the billing address is updated - if self.plan_variation.currency != new_currency: - # If currency has changed, find a matching plan variation for this new currency - plan_variation = self.plan_variation.in_other_currency(new_currency) - self.request.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY] = new_currency + response_redirect = self._set_preferred_currency_and_redirect() + if response_redirect: messages.add_message(self.request, messages.INFO, msg) - return redirect( - 'subscriptions:join-billing-details', plan_variation_id=plan_variation.pk - ) + return response_redirect # Compare tax before and after the billing address is updated new_tax = self.customer.get_tax(product_type=product_type) @@ -164,149 +189,55 @@ class BillingDetailsView(_JoinMixin, LoginRequiredMixin, FormView): messages.add_message(self.request, messages.INFO, msg) return self.form_invalid(form) - return redirect( - 'subscriptions:join-confirm-and-pay', plan_variation_id=self.plan_variation.pk - ) - - -class ConfirmAndPayView(_JoinMixin, LoginRequiredMixin, FormView): - """Display the payment form and handle the payment flow.""" - - raise_exception = True - template_name = 'subscriptions/join/payment_method.html' - form_class = PaymentForm - - log = logger - gateway: looper.models.Gateway - - # FIXME(anna): this view uses some functionality of AbstractPaymentView/CheckoutView, - # but cannot directly inherit from them. - gateway_from_form = AbstractPaymentView.gateway_from_form - - _check_customer_ip_address = AbstractPaymentView._check_customer_ip_address - _check_payment_method_nonce = CheckoutView._check_payment_method_nonce - _check_recaptcha = CheckoutView._check_recaptcha - - _charge_if_supported = CheckoutView._charge_if_supported - _fetch_or_create_order = CheckoutView._fetch_or_create_order - - def get_form_class(self): - """Override the payment form based on the selected plan variation, before validation.""" - if self.plan_variation.collection_method == 'automatic': - return AutomaticPaymentForm - return PaymentForm - - def get_initial(self) -> dict: - """Prefill default payment gateway, country and selected plan options.""" - product_type = self.plan_variation.plan.product.type - customer_tax = self.customer.get_tax(product_type=product_type) - taxable = looper.taxes.Taxable(self.plan_variation.price, *customer_tax) - return { - **super().get_initial(), - 'price': taxable.price.decimals_string, - 'gateway': self.gateway.name, - } - - def get_context_data(self, **kwargs) -> dict: - """Add an extra form and gateway's client token.""" - currency = self.get_currency() - ctx = { - **super().get_context_data(**kwargs), - 'current_plan_variation': self.plan_variation, - 'client_token': self.get_client_token(currency) if self.customer else None, - 'subscription': self.subscription, - } - return ctx - - def _get_or_create_subscription( - self, gateway: looper.models.Gateway, payment_method: looper.models.PaymentMethod - ) -> looper.models.Subscription: - subscription = self._get_existing_subscription() - is_new = False - if not subscription: - subscription = looper.models.Subscription() - is_new = True - self.log.debug('Creating an new subscription for %s, %s', gateway, payment_method) - collection_method = self.plan_variation.collection_method - supported = set(gateway.provider.supported_collection_methods) - if collection_method not in supported: - # FIXME(anna): this breaks plan selector because collection method - # might not match the one selected by the customer. - collection_method = supported.pop() - - with transaction.atomic(): - subscription.plan = self.plan_variation.plan - subscription.user = self.user - subscription.payment_method = payment_method - # Currency must be set before the price, in case it was changed - subscription.currency = self.plan_variation.currency - subscription.price = self.plan_variation.price - subscription.interval_unit = self.plan_variation.interval_unit - subscription.interval_length = self.plan_variation.interval_length - subscription.collection_method = collection_method - subscription.save() - - # Configure the team if this is a team plan - if hasattr(subscription.plan, 'team_properties'): - team_properties = subscription.plan.team_properties - team, team_is_new = subscriptions.models.Team.objects.get_or_create( - subscription=subscription, - seats=team_properties.seats, - ) - self.log.info( - '%s a team for subscription pk=%r, seats: %s', - team_is_new and 'Created' or 'Updated', - subscription.pk, - team.seats and team.seats or 'unlimited', - ) - - self.log.debug('%s subscription pk=%r', is_new and 'Created' or 'Updated', subscription.pk) - return subscription - - def form_invalid(self, form): - """Temporarily log all validation errors.""" - logger.exception('Validation error in ConfirmAndPayView: %s', form.errors) - return super().form_invalid(form) - - def form_valid(self, form): - """Handle valid form data. - - Confirm and Pay view doesn't update the billing address, - only displays it for use by payment flow and validates it on submit. - The billing address is assumed to be saved at the previous step. - """ - assert self.request.method == 'POST' - - response = self._check_recaptcha(form) - if response: - return response - - response = self._check_customer_ip_address(form) - if response: - return response - - gateway = self.gateway_from_form(form) - payment_method = self._check_payment_method_nonce(form, gateway) - if payment_method is None: - return self.form_invalid(form) - - price_cents = int(Decimal(form.cleaned_data['price']) * 100) - subscription = self._get_or_create_subscription(gateway, payment_method) + gateway = form.cleaned_data['gateway'] + price_cents = new_taxable.price.cents + subscription = self._get_or_create_subscription(gateway) # Update the tax info stored on the subscription subscription.update_tax() order = self._fetch_or_create_order(form, subscription) # Update the order to take into account latest changes + if order.payment_method_id != subscription.payment_method_id: + order.switch_payment_method(subscription.payment_method) order.update() # Make sure we are charging what we've displayed price = looper.money.Money(order.price.currency, price_cents) if order.price != price: - form.add_error('', 'Payment failed: please reload the page and try again') + logger.error("Order price %s doesn't match form price %s", order.price, price) + msg = 'Please reload the page and try again' + messages.warning(self.request, msg) return self.form_invalid(form) if not gateway.provider.supports_transactions: + logger.info( + 'Not creating transaction for order pk=%r because gateway %r does ' + 'not support it', + order.pk, + gateway.name, + ) + url = reverse( + 'looper:transactionless_checkout_done', + kwargs={'pk': order.pk, 'gateway_name': gateway.name}, + ) # Trigger an email with instructions about manual payment: subscription_created_needs_payment.send(sender=subscription) + return redirect(url) - response = self._charge_if_supported(form, gateway, order) - return response + success_url = self.request.build_absolute_uri( + reverse( + 'looper:stripe_success', + kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'}, + ) + ) + # we have to do it to avoid uri-encoding of curly braces, + # otherwise stripe doesn't do the template substitution + success_url = success_url.replace('CHECKOUT_SESSION_ID', '{CHECKOUT_SESSION_ID}', 1) + cancel_url = self.request.build_absolute_uri(self.request.get_full_path()) + + session = looper.stripe_utils.create_stripe_checkout_session_for_order( + order, + success_url, + cancel_url, + payment_intent_metadata={'order_id': order.pk}, + ) + return redirect(session.url) diff --git a/subscriptions/views/mixins.py b/subscriptions/views/mixins.py index c90d8dcc..7085c09d 100644 --- a/subscriptions/views/mixins.py +++ b/subscriptions/views/mixins.py @@ -4,7 +4,6 @@ import logging from django.contrib.auth.mixins import LoginRequiredMixin from django.forms.utils import ErrorList -from django.http import HttpResponse from django.shortcuts import get_object_or_404 from looper.models import Subscription @@ -35,7 +34,9 @@ class SingleSubscriptionMixin(LoginRequiredMixin): def get_subscription(self) -> Subscription: """Retrieve Subscription object.""" - return get_object_or_404(self.request.user.subscription_set, pk=self.subscription_id) + return get_object_or_404( + self.request.user.customer.subscription_set, pk=self.subscription_id + ) def get_context_data(self, **kwargs) -> dict: """Add Subscription to the template context.""" @@ -45,29 +46,6 @@ class SingleSubscriptionMixin(LoginRequiredMixin): 'subscription': subscription, } - def dispatch(self, request, *args, **kwargs): - """Allow the view to do things that rely on auth state before dispatch. - - The AnonymousUser instance doesn't have a 'subscriptions' property, - but login checking only happens in the super().dispatch() call. - """ - if not hasattr(request.user, 'subscription_set'): - return self.handle_no_permission() - response = self.pre_dispatch(request, *args, **kwargs) - if response: - return response - return super().dispatch(request, *args, **kwargs) - - def pre_dispatch(self, request, *args, **kwargs) -> Optional[HttpResponse]: - """Called between a permission check and calling super().dispatch(). - - This allows you to get the current subscription, or otherwise do things - that require the user to be logged in. - - :return: None to continue handling this request, or a - HttpResponse to stop processing early. - """ - class BootstrapErrorListMixin: """Override get_form method changing error_class of the form.""" diff --git a/subscriptions/views/select_plan_variation.py b/subscriptions/views/select_plan_variation.py index 61207a8e..6bf8175f 100644 --- a/subscriptions/views/select_plan_variation.py +++ b/subscriptions/views/select_plan_variation.py @@ -5,7 +5,7 @@ import logging from django.shortcuts import redirect from django.views.generic import FormView -from looper.views.checkout import AbstractPaymentView +from looper.views.checkout_stripe import CheckoutStripeView import looper.gateways import looper.middleware import looper.models @@ -20,7 +20,7 @@ logger.setLevel(logging.DEBUG) class _PlanSelectorMixin: - get_currency = AbstractPaymentView.get_currency + get_currency = CheckoutStripeView.get_currency select_team_plans = False plan_variation = None plan = None diff --git a/subscriptions/views/settings.py b/subscriptions/views/settings.py index c8793255..a344cb94 100644 --- a/subscriptions/views/settings.py +++ b/subscriptions/views/settings.py @@ -1,11 +1,7 @@ """Views handling subscription management.""" -from typing import Optional import logging -from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.messages.views import SuccessMessageMixin -from django.http import HttpResponseForbidden -from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy, reverse from django.views.generic import UpdateView, FormView @@ -15,8 +11,6 @@ import looper.views.settings from subscriptions.forms import ( BillingAddressForm, CancelSubscriptionForm, - ChangePaymentMethodForm, - PayExistingOrderForm, TeamForm, ) from subscriptions.views.mixins import SingleSubscriptionMixin, BootstrapErrorListMixin @@ -26,24 +20,13 @@ logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) -class BillingAddressView(LoginRequiredMixin, UpdateView): - """Combine looper's Customer and Address into a billing address.""" +class BillingAddressView(looper.views.settings.BillingAddressView): + """Override form class and success URL of looper's view.""" template_name = 'settings/billing_address.html' - model = looper.models.Address form_class = BillingAddressForm success_url = reverse_lazy('subscriptions:billing-address') - def _get_customer_object(self) -> Optional[looper.models.Customer]: - if self.request.user.is_anonymous: - return None - return self.request.user.customer - - def get_object(self, queryset=None) -> Optional[looper.models.Address]: - """Get billing address.""" - customer = self._get_customer_object() - return customer.billing_address if customer else None - class CancelSubscriptionView(SingleSubscriptionMixin, FormView): """Confirm and cancel a subscription.""" @@ -68,78 +51,40 @@ class CancelSubscriptionView(SingleSubscriptionMixin, FormView): class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView): - """Use the Braintree drop-in UI to switch a subscription's payment method.""" + """Override cancel and success URLs.""" - template_name = 'subscriptions/payment_method_change.html' - form_class = ChangePaymentMethodForm - success_url = reverse_lazy('user-settings-billing') + success_url = 'subscriptions:payment-method-change-done' - subscription: looper.models.Subscription - - def get_initial(self) -> dict: - """Modify initial form data.""" - initial = super().get_initial() - initial['next_url_after_done'] = self.success_url - - # Looper's view uses customer full_name, we don't - initial.pop('full_name', None) - - # Only set initial values if they aren't already saved to the billing address. - # Initial values always override form data, which leads to confusing issues with views. - if not (self.customer and self.customer.billing_address.full_name): - # Fall back to user's full name, if no full name set already in the billing address: - if self.request.user.full_name: - initial['full_name'] = self.request.user.full_name - return initial - - def form_invalid(self, form): - """Temporarily log all validation errors.""" - logger.exception('Validation error in ChangePaymentMethodForm: %s', form.errors) - return super().form_invalid(form) + def get_cancel_url(self): + """Return to this subscription's manage page.""" + return reverse( + 'subscriptions:manage', + kwargs={'subscription_id': self.kwargs['subscription_id']}, + ) -class PayExistingOrderView(looper.views.checkout.CheckoutExistingOrderView): +class PaymentMethodChangeDoneView(looper.views.settings.PaymentMethodChangeDoneView): + """Change payment method in response to a successful payment setup.""" + + @property + def success_url(self): + """Return to this subscription's manage page.""" + return reverse( + 'subscriptions:manage', + kwargs={'subscription_id': self.kwargs['subscription_id']}, + ) + + +class PayExistingOrderView(looper.views.checkout_stripe.CheckoutExistingOrderView): """Override looper's view with our forms.""" # Redirect to LOGIN_URL instead of raising an exception raise_exception = False - template_name = 'subscriptions/pay_existing_order.html' - form_class = PayExistingOrderForm - success_url = reverse_lazy('user-settings-billing') - def get_initial(self) -> dict: - """Prefill the payment amount and missing form data, if any.""" - initial = { - 'price': self.order.price.decimals_string, - 'email': self.customer.billing_email, - } - - # Only set initial values if they aren't already saved to the billing address. - # Initial values always override form data, which leads to confusing issues with views. - if not (self.customer and self.customer.billing_address.full_name): - # Fall back to user's full name, if no full name set already in the billing address: - if self.request.user.full_name: - initial['full_name'] = self.request.user.full_name - return initial - - def form_invalid(self, form): - """Temporarily log all validation errors.""" - logger.exception('Validation error in PayExistingOrderView: %s', form.errors) - return super().form_invalid(form) - - def dispatch(self, request, *args, **kwargs): - """Return 403 unless current session and the order belong to the same user. - - Looper renders a template instead, we just want to display the standard 403 page - or redirect to login, like LoginRequiredMixin does with raise_exception=False. - """ - self.order = get_object_or_404(looper.models.Order, pk=kwargs['order_id']) - if request.user.is_authenticated and self.order.user_id != request.user.id: - return HttpResponseForbidden() - self.plan = self.order.subscription.plan - return super(looper.views.checkout.CheckoutExistingOrderView, self).dispatch( - request, *args, **kwargs - ) + def get_cancel_url(self): + """Return to this subscription's manage page.""" + order = self.get_object() + return reverse('subscriptions:manage', kwargs={'subscription_id': order.subscription_id}) class ManageSubscriptionView( diff --git a/subscriptions/views/teams.py b/subscriptions/views/teams.py index 66dc0c7b..b1957a11 100644 --- a/subscriptions/views/teams.py +++ b/subscriptions/views/teams.py @@ -2,7 +2,7 @@ from collections import defaultdict from django.views.generic.base import TemplateView -from looper.views.checkout import AbstractPaymentView +from looper.views.checkout_stripe import CheckoutStripeView import looper.models import subscriptions.models @@ -11,7 +11,7 @@ import subscriptions.models class TeamsLanding(TemplateView): """Display a selection of team plans and existing sponsors.""" - get_currency = AbstractPaymentView.get_currency + get_currency = CheckoutStripeView.get_currency template_name = 'subscriptions/teams_landing.html' @staticmethod diff --git a/subscriptions/views/tests/test_join.py b/subscriptions/views/tests/test_join.py index a102d5ea..5a722c3f 100644 --- a/subscriptions/views/tests/test_join.py +++ b/subscriptions/views/tests/test_join.py @@ -1,24 +1,27 @@ -from typing import Tuple from unittest.mock import patch import os import unittest from django.conf import settings +from django.test import TestCase from django.urls import reverse from freezegun import freeze_time import responses +# from responses import _recorder + from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4, SINGAPORE_IPV4 from looper.money import Money import looper.models -from common.tests.factories.subscriptions import create_customer_with_billing_address -from common.tests.factories.users import UserFactory -from subscriptions.tests.base import BaseSubscriptionTestCase +from looper.tests.factories import create_customer_with_billing_address +from common.tests.factories.users import UserFactory, OAuthUserInfoFactory +from subscriptions.tests.base import BaseSubscriptionTestCase, responses_from_file import subscriptions.tasks import users.tasks import users.tests.util as util +responses_dir = 'subscriptions/tests/_responses/' required_address_data = { 'country': 'NL', 'email': 'my.billing.email@example.com', @@ -36,15 +39,27 @@ full_billing_address_data = { # **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database. -def _get_default_variation(currency='USD'): - return looper.models.Plan.objects.first().variation_for_currency(currency) - - @freeze_time('2023-05-19 11:41:11') -class TestGETBillingDetailsView(BaseSubscriptionTestCase): +class TestGETJoinView(BaseSubscriptionTestCase): url_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1}) url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2}) + def test_pay_via_bank_transfer_button_is_shown_for_manual_plan_variation(self): + customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + user = customer.user + self.client.force_login(user) + + url, selected_variation = self._get_url_for( + currency='EUR', + interval_length=1, + interval_unit='month', + plan__name='Manual renewal', + ) + response = self.client.get(url, REMOTE_ADDR=EURO_IPV4) + + self.assertEqual(response.status_code, 200) + self._assert_pay_via_bank_displayed(response) + def test_get_prefills_full_name_and_billing_email_from_user(self): user = UserFactory(full_name="Jane До", email='jane.doe@example.com') self.client.force_login(user) @@ -53,6 +68,7 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): self.assertEqual(response.status_code, 200) self._assert_total_default_variation_selected_tax_21_eur(response) + self._assert_pay_via_bank_not_displayed(response) self.assertContains( response, '', @@ -65,7 +81,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): ) def test_get_displays_total_and_billing_details_to_logged_in_nl(self): - user = create_customer_with_billing_address(vat_number='', country='NL') + customer = create_customer_with_billing_address(vat_number='', country='NL') + user = customer.user self.client.force_login(user) response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4) @@ -76,7 +93,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): self._assert_total_default_variation_selected_tax_21_eur(response) def test_get_displays_total_and_billing_details_to_logged_in_de(self): - user = create_customer_with_billing_address(vat_number='', country='DE') + customer = create_customer_with_billing_address(vat_number='', country='DE') + user = customer.user self.client.force_login(user) response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4) @@ -86,9 +104,10 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): self._assert_total_default_variation_selected_tax_19_eur(response) def test_get_displays_total_and_billing_details_to_logged_in_us(self): - user = create_customer_with_billing_address( + customer = create_customer_with_billing_address( vat_number='', country='US', region='NY', postal_code='12001' ) + user = customer.user self.client.force_login(user) response = self.client.get(self.url_usd) @@ -99,16 +118,23 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): self._assert_total_default_variation_selected_usd(response) @unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required') - def test_get_detects_country_us_sets_preferred_currency_usd_invalid_variation(self): - user = create_customer_with_billing_address() + def test_get_detects_country_us_sets_preferred_currency_usd_and_redirects(self): + customer = create_customer_with_billing_address() + user = customer.user self.client.force_login(user) + self.assertTrue(looper.middleware.PREFERRED_CURRENCY_SESSION_KEY not in self.client.session) response = self.client.get(self.url, REMOTE_ADDR=USA_IPV4) - self.assertEqual(response.status_code, 404) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], self.url_usd) + self.assertEqual( + self.client.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY], 'USD' + ) @unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required') def test_get_detects_country_us_sets_preferred_currency_usd(self): - user = create_customer_with_billing_address() + customer = create_customer_with_billing_address() + user = customer.user self.client.force_login(user) response = self.client.get(self.url_usd, REMOTE_ADDR=USA_IPV4) @@ -125,7 +151,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): @unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required') def test_get_detects_country_sg_sets_preferred_currency_eur(self): - user = create_customer_with_billing_address() + customer = create_customer_with_billing_address() + user = customer.user self.client.force_login(user) response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4) @@ -142,7 +169,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): @unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required') def test_get_detects_country_nl_sets_preferred_currency_eur_displays_correct_vat(self): - user = create_customer_with_billing_address() + customer = create_customer_with_billing_address() + user = customer.user self.client.force_login(user) response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4) @@ -156,14 +184,42 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase): ) self._assert_total_default_variation_selected_tax_21_eur(response) + def test_plan_variation_matches_detected_currency_eur_non_eea_ip(self): + customer = create_customer_with_billing_address() + self.client.force_login(customer.user) + + response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4) + + self.assertEqual(response.status_code, 200) + # Check that prices are in EUR and there is no tax + self._assert_total_default_variation_selected_no_tax_eur(response) + + def test_billing_address_country_takes_precedence_over_geo_ip(self): + customer = create_customer_with_billing_address(country='NL') + self.client.force_login(customer.user) + + response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4) + + self.assertEqual(response.status_code, 200) + self._assert_total_default_variation_selected_tax_21_eur(response) + @freeze_time('2023-05-19 11:41:11') -class TestPOSTBillingDetailsView(BaseSubscriptionTestCase): +class TestPOSTJoinView(BaseSubscriptionTestCase): url_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1}) url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2}) + cs_url = 'https://checkout.stripe.com/c/pay/cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl' + cs_id = 'cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW' + + def setUp(self): + super().setUp() + responses._add_from_file(f'{responses_dir}vies_wsdl.yaml') + responses._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml') + def test_post_updates_billing_address_and_customer_renders_next_form_de(self): - user = create_customer_with_billing_address(vat_number='', country='DE') + customer = create_customer_with_billing_address(vat_number='', country='DE') + user = customer.user self.client.force_login(user) selected_variation = ( @@ -174,7 +230,7 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase): ) .first() ) - data = full_billing_address_data + data = {**full_billing_address_data, 'gateway': 'stripe'} url = reverse( 'subscriptions:join-billing-details', kwargs={'plan_variation_id': selected_variation.pk}, @@ -197,51 +253,36 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase): self.assertContains(response, 'Manual ') self.assertContains(response, '/ 1 year', html=True) - def test_post_has_correct_price_field_value(self): + @responses.activate + def test_post_redirects_to_stripe_hosted_checkout(self): self.client.force_login(self.user) - default_variation = _get_default_variation('EUR') - data = required_address_data + data = {**required_address_data, 'gateway': 'stripe'} response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4) self.assertEqual(response.status_code, 302) - self.assertEqual( - response['Location'], - reverse( - 'subscriptions:join-confirm-and-pay', - kwargs={'plan_variation_id': default_variation.pk}, - ), - ) - - # Follow the redirect to avoid "Couldn't retrieve content: Response code was 302 (expected 200)" - response = self.client.get(response['Location']) - # Check that we are no longer on the billing details page - self._assert_payment_form_displayed(response) - - self._assert_total_default_variation_selected_tax_21_eur(response) - # The hidden price field must also be set to a matching amount - self.assertContains( - response, - '', - html=True, - ) + self.assertEqual(response['Location'], self.cs_url) + @responses.activate def test_post_updates_billing_address_and_customer_applies_reverse_charged_tax(self): + responses._add_from_file(f'{responses_dir}vies_valid.yaml') self.client.force_login(self.user) data = { **required_address_data, - 'vat_number': 'DE 260543043', + 'gateway': 'stripe', 'country': 'DE', 'postal_code': '11111', + 'vat_number': 'DE 260543043', } response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4) self.assertEqual(response.status_code, 200) self.user.refresh_from_db() - self.assertEqual(self.user.customer.vat_number, 'DE260543043') address = self.user.customer.billing_address + address.refresh_from_db() + self.assertEqual(address.vat_number, 'DE260543043') self.assertEqual(address.full_name, 'New Full Name') self.assertEqual(address.postal_code, '11111') self.assertEqual(address.country, 'DE') @@ -255,23 +296,14 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase): # Post the same form again response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) - # Follow the redirect to avoid unexpected assertion errors - response = self.client.get(response['Location']) - # Check that we are no longer on the billing details page - self._assert_payment_form_displayed(response) - - # The hidden price field must also be set to a matching amount - self.assertContains( - response, - '', - html=True, - ) + self.assertEqual(response['Location'], self.cs_url, response['Location']) def test_post_changing_address_from_with_region_to_without_region_clears_region(self): - user = create_customer_with_billing_address( + customer = create_customer_with_billing_address( vat_number='', country='US', region='NY', postal_code='12001' ) + user = customer.user self.client.force_login(user) response = self.client.get(self.url_usd) @@ -285,6 +317,7 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase): # Post an new address that doesn't require a region data = { **required_address_data, + 'gateway': 'stripe', 'country': 'DE', 'postal_code': '11111', } @@ -304,94 +337,51 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase): self.assertEqual(user.customer.billing_address.country, 'DE') self.assertEqual(user.customer.billing_address.postal_code, '11111') - -@freeze_time('2023-05-19 11:41:11') -class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): - def _get_url_for(self, **filter_params) -> Tuple[str, looper.models.PlanVariation]: - plan_variation = looper.models.PlanVariation.objects.active().get(**filter_params) - return ( - reverse( - 'subscriptions:join-confirm-and-pay', - kwargs={'plan_variation_id': plan_variation.pk}, - ), - plan_variation, - ) - def test_plan_variation_does_not_match_detected_currency_usd_euro_ip(self): url, _ = self._get_url_for(currency='USD', price=11900) - user = create_customer_with_billing_address(country='NL') + customer = create_customer_with_billing_address(country='NL') + user = customer.user self.client.force_login(user) - data = required_address_data + data = {**required_address_data, 'gateway': 'stripe'} response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4) - self.assertEqual(response.status_code, 404) - - def test_plan_variation_matches_detected_currency_eur_non_eea_ip(self): - url, _ = self._get_url_for(currency='EUR', price=990) - user = create_customer_with_billing_address() - self.client.force_login(user) - - data = required_address_data - response = self.client.post(url, data, REMOTE_ADDR=SINGAPORE_IPV4) - - self.assertEqual(response.status_code, 200) - # Check that prices are in EUR and there is no tax - self._assert_total_default_variation_selected_no_tax_eur(response) + self.assertEqual(response.status_code, 302) + expected_url, _ = self._get_url_for(currency='EUR', price=10900) + self.assertEqual(response['Location'], expected_url) def test_billing_address_country_takes_precedence_over_geo_ip(self): - url, _ = self._get_url_for(currency='EUR', price=990) - user = create_customer_with_billing_address(country='NL') - self.client.force_login(user) + customer = create_customer_with_billing_address(country='GE') + self.client.force_login(customer.user) - data = required_address_data - response = self.client.post(url, data, REMOTE_ADDR=SINGAPORE_IPV4) + data = {**required_address_data, 'gateway': 'stripe'} + response = self.client.post(self.url, data, REMOTE_ADDR=SINGAPORE_IPV4) self.assertEqual(response.status_code, 200) self._assert_total_default_variation_selected_tax_21_eur(response) def test_invalid_missing_required_fields(self): - url, _ = self._get_url_for(currency='EUR', price=990) - user = create_customer_with_billing_address(country='NL') - self.client.force_login(user) + customer = create_customer_with_billing_address(country='NL') + self.client.force_login(customer.user) - data = required_address_data - response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4) + response = self.client.post(self.url, {}, REMOTE_ADDR=EURO_IPV4) self.assertEqual(response.status_code, 200) self._assert_total_default_variation_selected_tax_21_eur(response) self.assertEqual( response.context['form'].errors, { + 'country': ['This field is required.'], + 'email': ['This field is required.'], + 'full_name': ['This field is required.'], 'gateway': ['This field is required.'], - 'payment_method_nonce': ['This field is required.'], - 'price': ['This field is required.'], }, ) - def test_invalid_price_does_not_match_selected_plan_variation(self): - url, selected_variation = self._get_url_for(currency='EUR', price=990) - user = create_customer_with_billing_address(country='NL') - self.client.force_login(user) - - data = { - **required_address_data, - 'gateway': 'braintree', - 'payment_method_nonce': 'fake-valid-nonce', - 'price': '999.09', - } - response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4) - - self.assertEqual(response.status_code, 200) - self._assert_total_default_variation_selected_tax_21_eur(response) - self.assertEqual( - response.context['form'].errors, - {'__all__': ['Payment failed: please reload the page and try again']}, - ) - def test_invalid_bank_transfer_cannot_be_selected_for_automatic_payments(self): url, selected_variation = self._get_url_for(currency='EUR', price=990) - user = create_customer_with_billing_address(country='NL') + customer = create_customer_with_billing_address(country='NL') + user = customer.user self.client.force_login(user) data = { @@ -423,7 +413,9 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): ) @responses.activate def test_pay_with_bank_transfer_creates_order_subscription_on_hold(self): - user = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + customer = create_customer_with_billing_address(country='NL') + user = customer.user + OAuthUserInfoFactory(user=user, oauth_user_id=554433) self.client.force_login(user) util.mock_blender_id_badger_badger_response( 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id @@ -435,17 +427,12 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): interval_unit='month', plan__name='Manual renewal', ) - data = { - **required_address_data, - 'gateway': 'bank', - 'payment_method_nonce': 'unused', - 'price': '14.90', - } + data = {**required_address_data, 'full_name': 'Jane Doe', 'gateway': 'bank'} response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4) self._assert_transactionless_done_page_displayed(response) - subscription = user.subscription_set.first() + subscription = user.customer.subscription_set.first() self.assertEqual(subscription.status, 'on-hold') self.assertEqual(subscription.price, Money('EUR', 1490)) self.assertEqual(subscription.tax, Money('EUR', 259)) @@ -500,9 +487,17 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): def test_pay_with_bank_transfer_creates_order_subscription_on_hold_shows_reverse_charged_price( self, ): - user = create_customer_with_billing_address( - country='ES', full_name='Jane Doe', vat_number='DE260543043' - ) + responses._add_from_file(f'{responses_dir}vies_valid.yaml') + address = { + **required_address_data, + 'country': 'ES', + 'full_name': 'Jane Doe', + 'postal_code': '11111', + 'vat_number': 'ES A78374725', + } + customer = create_customer_with_billing_address(**address) + user = customer.user + OAuthUserInfoFactory(user=user, oauth_user_id=554433) self.client.force_login(user) util.mock_blender_id_badger_badger_response( 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id @@ -514,17 +509,12 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): interval_length=3, interval_unit='month', ) - data = { - **required_address_data, - 'gateway': 'bank', - 'payment_method_nonce': 'unused', - 'price': '26.45', - } + data = {'gateway': 'bank', **address} response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4) self._assert_transactionless_done_page_displayed(response) - subscription = user.subscription_set.first() + subscription = user.customer.subscription_set.first() self.assertEqual(subscription.status, 'on-hold') self.assertEqual(subscription.price, Money('EUR', 3200)) self.assertEqual(subscription.tax, Money('EUR', 0)) @@ -533,6 +523,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): self.assertEqual(subscription.collection_method, selected_variation.collection_method) self.assertEqual(subscription.collection_method, 'manual') self.assertEqual(subscription.plan, selected_variation.plan) + self.assertEqual(str(subscription.payment_method), 'Bank Transfer') + self.assertIsNone(subscription.payment_method.token) order = subscription.latest_order() self.assertEqual(order.status, 'created') @@ -545,6 +537,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): self.assertIsNotNone(order.display_number) self.assertIsNotNone(order.vat_number) self.assertNotEqual(order.display_number, str(order.pk)) + self.assertEqual(str(order.payment_method), 'Bank Transfer') + self.assertIsNone(order.payment_method.token) self._assert_bank_transfer_email_is_sent(subscription) self._assert_bank_transfer_email_is_sent_tax_21_eur_reverse_charged(subscription) @@ -553,6 +547,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): response = self.client.get( reverse('subscriptions:manage', kwargs={'subscription_id': subscription.pk}) ) + self.assertNotIn('32.00', response) self.assertNotIn('21%', response) self.assertNotIn('Inc.', response) @@ -581,34 +576,44 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): 'users.signals.tasks.grant_blender_id_role', new=users.tasks.grant_blender_id_role.task_function, ) - @responses.activate def test_pay_with_credit_card_creates_order_subscription_active(self): url, selected_variation = self._get_url_for(currency='EUR', price=990) - user = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + user = customer.user + OAuthUserInfoFactory(user=user, oauth_user_id=554433) self.client.force_login(user) - util.mock_blender_id_badger_badger_response( - 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id - ) - util.mock_blender_id_badger_badger_response( - 'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id - ) - data = { - **required_address_data, - 'gateway': 'braintree', - # fake-three-d-secure-visa-full-authentication-nonce - # causes the following error: - # Merchant account must match the 3D Secure authorization merchant account: code 91584 - # TODO(anna): figure out if this is due to our settings or a quirk of the sandbox - 'payment_method_nonce': 'fake-valid-nonce', - 'price': '9.90', - } - response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4) + data = {**required_address_data, 'gateway': 'stripe'} + with responses.RequestsMock() as rsps: + rsps._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml') + response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], self.cs_url, response['Location']) + + # **N.B**: this flow happens in 2 different views separated by a Stripe payment page. + # Pretend that checkout session was completed and we've returned to the success page with its ID: + subscription = user.customer.subscription_set.first() + order = subscription.latest_order() + url = reverse( + 'looper:stripe_success', + kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'}, + ) + url = url.replace('CHECKOUT_SESSION_ID', self.cs_id) + with responses.RequestsMock() as rsps: + responses_from_file('stripe_get_cs_eur.yaml', order_id=order.pk, rsps=rsps) + util.mock_blender_id_badger_badger_response( + 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id, rsps + ) + util.mock_blender_id_badger_badger_response( + 'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id, rsps + ) + response = self.client.get(url) self._assert_done_page_displayed(response) - subscription = user.subscription_set.first() - order = subscription.latest_order() + subscription.refresh_from_db() + order.refresh_from_db() self.assertEqual(subscription.status, 'active') self.assertEqual(subscription.price, Money('EUR', 990)) self.assertEqual(subscription.collection_method, selected_variation.collection_method) @@ -629,33 +634,47 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): 'users.signals.tasks.grant_blender_id_role', new=users.tasks.grant_blender_id_role.task_function, ) - @responses.activate def test_pay_with_credit_card_creates_order_subscription_active_team(self): url, selected_variation = self._get_url_for( currency='EUR', price=9000, plan__name='Automatic renewal, 15 seats', ) - user = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe') + user = customer.user + OAuthUserInfoFactory(user=user, oauth_user_id=554433) self.client.force_login(user) - util.mock_blender_id_badger_badger_response( - 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id - ) - util.mock_blender_id_badger_badger_response( - 'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id - ) - data = { - **required_address_data, - 'gateway': 'braintree', - 'payment_method_nonce': 'fake-valid-nonce', - 'price': '90.00', - } - response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4) + data = {**required_address_data, 'gateway': 'stripe'} + with responses.RequestsMock() as rsps: + rsps._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml') + response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], self.cs_url, response['Location']) + + # **N.B**: this flow happens in 2 different views separated by a Stripe payment page. + # Pretend that checkout session was completed and we've returned to the success page with its ID: + subscription = user.customer.subscription_set.first() + order = subscription.latest_order() + url = reverse( + 'looper:stripe_success', + kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'}, + ) + url = url.replace('CHECKOUT_SESSION_ID', self.cs_id) + with responses.RequestsMock() as rsps: + responses_from_file('stripe_get_cs_eur.yaml', order_id=order.pk, rsps=rsps) + util.mock_blender_id_badger_badger_response( + 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id, rsps + ) + util.mock_blender_id_badger_badger_response( + 'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id, rsps + ) + response = self.client.get(url) self._assert_done_page_displayed(response) - subscription = user.subscription_set.first() + subscription = customer.subscription_set.first() order = subscription.latest_order() self.assertEqual(subscription.status, 'active') self.assertEqual(subscription.price, Money('EUR', 9000)) @@ -670,7 +689,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): self._assert_team_subscription_activated_email_is_sent(subscription) def test_pay_with_credit_card_creates_order_subscription_active_business_de(self): - user = create_customer_with_billing_address(country='DE', vat_number='DE260543043') + customer = create_customer_with_billing_address(country='DE', vat_number='DE260543043') + user = customer.user self.client.force_login(user) url, selected_variation = self._get_url_for( @@ -681,19 +701,46 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): ) data = { **required_address_data, - 'vat_number': 'DE 260543043', + 'gateway': 'stripe', 'country': 'DE', 'postal_code': '11111', - 'gateway': 'braintree', - 'payment_method_nonce': 'fake-valid-nonce', - # VAT is subtracted from the plan variation price: - 'price': '12.52', + 'vat_number': 'DE 260543043', } - response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4) + # @_recorder.record(file_path=f'{responses_dir}stripe_new_cs_eur.yaml') + def _continue_to_payment(): # noqa: E306 + return self.client.post(url, data, REMOTE_ADDR=EURO_IPV4) + + with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: + # request to wsdl doesn't happen on subsequent calls to the validator, + # hence assert_all_requests_are_fired = False. + rsps._add_from_file(f'{responses_dir}vies_wsdl.yaml') + rsps._add_from_file(f'{responses_dir}vies_valid.yaml') + rsps._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml') + response = _continue_to_payment() + + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], self.cs_url, response['Location']) + + # **N.B**: this flow happens in 2 different views separated by a Stripe payment page. + # Pretend that checkout session was completed and we've returned to the success page with its ID: + subscription = user.customer.subscription_set.first() + order = subscription.latest_order() + url = reverse( + 'looper:stripe_success', + kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'}, + ) + url = url.replace('CHECKOUT_SESSION_ID', self.cs_id) + # @_recorder.record(file_path=f'{responses_dir}stripe_get_cs_eur.yaml') + def _back_to_success_url(): # noqa: E306 + return self.client.get(url) + + with responses.RequestsMock() as rsps: + responses_from_file('stripe_get_cs_eur.yaml', order_id=order.pk, rsps=rsps) + response = _back_to_success_url() self._assert_done_page_displayed(response) - subscription = user.subscription_set.first() + subscription.refresh_from_db() self.assertEqual(subscription.status, 'active') self.assertEqual(subscription.price, Money('EUR', 1490)) self.assertEqual(subscription.tax, Money('EUR', 0)) @@ -716,15 +763,17 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase): self.assertIsNotNone(order.number) -class TestJoinConfirmAndPayLoggedInUserOnlyView(BaseSubscriptionTestCase): - url = reverse('subscriptions:join-confirm-and-pay', kwargs={'plan_variation_id': 8}) +class TestJoinViewLoggedInUserOnly(TestCase): + url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2}) - def test_get_anonymous_403(self): + def test_get_anonymous_redirects_to_login_with_next(self): response = self.client.get(self.url) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/oauth/login?next=/join/plan-variation/2/billing/') - def test_join_post_anonymous_403(self): + def test_post_anonymous_redirects_to_login_with_next(self): response = self.client.post(self.url) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/oauth/login?next=/join/plan-variation/2/billing/') diff --git a/subscriptions/views/tests/test_receipt_pdf.py b/subscriptions/views/tests/test_receipt_pdf.py index 409ebfc7..8d48c930 100644 --- a/subscriptions/views/tests/test_receipt_pdf.py +++ b/subscriptions/views/tests/test_receipt_pdf.py @@ -10,11 +10,9 @@ from freezegun import freeze_time from looper.tests.factories import PaymentMethodFactory, OrderFactory import looper.taxes -from common.tests.factories.subscriptions import ( - TeamFactory, - create_customer_with_billing_address, -) +from common.tests.factories.subscriptions import TeamFactory from common.tests.factories.users import UserFactory +from looper.tests.factories import create_customer_with_billing_address expected_text_tmpl = '''Invoice Blender Studio B.V. @@ -63,16 +61,16 @@ class TestReceiptPDFView(TestCase): def setUpClass(cls): super().setUpClass() - user = create_customer_with_billing_address(email='mail1@example.com') - cls.payment_method = PaymentMethodFactory(user=user) + customer = create_customer_with_billing_address(email='mail1@example.com') + cls.payment_method = PaymentMethodFactory(customer=customer) cls.paid_order = OrderFactory( - user=user, + customer=customer, price=990, status='paid', tax_country='NL', payment_method=cls.payment_method, + subscription__customer=customer, subscription__payment_method=cls.payment_method, - subscription__user=user, subscription__plan__product__name='Blender Studio Subscription', ) @@ -84,15 +82,15 @@ class TestReceiptPDFView(TestCase): def test_get_pdf_unpaid_order_not_found(self): unpaid_order = OrderFactory( - user=self.payment_method.user, + customer=self.payment_method.customer, price=990, tax_country='NL', payment_method=self.payment_method, + subscription__customer=self.payment_method.customer, subscription__payment_method=self.payment_method, - subscription__user=self.payment_method.user, subscription__plan_id=1, ) - self.client.force_login(unpaid_order.user) + self.client.force_login(unpaid_order.customer.user) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': unpaid_order.pk}) response = self.client.get(url) @@ -115,7 +113,7 @@ class TestReceiptPDFView(TestCase): self.assertEqual(404, response.status_code) def test_get_pdf_has_logo(self): - self.client.force_login(self.paid_order.user) + self.client.force_login(self.paid_order.customer.user) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': self.paid_order.pk}) response = self.client.get(url) @@ -136,7 +134,9 @@ class TestReceiptPDFView(TestCase): tax_type=looper.taxes.TaxType.VAT_CHARGE, tax_rate=Decimal(19), ) + user = UserFactory() order = OrderFactory( + customer=user.customer, price=taxable.price, status='paid', tax=taxable.tax, @@ -144,9 +144,10 @@ class TestReceiptPDFView(TestCase): tax_type=taxable.tax_type.value, tax_rate=taxable.tax_rate, email='billing@example.com', + subscription__customer=user.customer, subscription__plan_id=1, ) - self.client.force_login(order.user) + self.client.force_login(order.customer.user) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk}) response = self.client.get(url) @@ -178,7 +179,9 @@ class TestReceiptPDFView(TestCase): tax_type=looper.taxes.TaxType.VAT_REVERSE_CHARGE, tax_rate=Decimal(19), ) + user = UserFactory() order = OrderFactory( + customer=user.customer, price=taxable.price, status='paid', tax=taxable.tax, @@ -187,9 +190,10 @@ class TestReceiptPDFView(TestCase): tax_rate=taxable.tax_rate, vat_number='DE123456789', email='billing@example.com', + subscription__customer=user.customer, subscription__plan_id=1, ) - self.client.force_login(order.user) + self.client.force_login(order.customer.user) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk}) response = self.client.get(url) @@ -225,7 +229,9 @@ class TestReceiptPDFView(TestCase): tax_type=looper.taxes.TaxType.VAT_CHARGE, tax_rate=Decimal(21), ) + user = UserFactory() order = OrderFactory( + customer=user.customer, price=taxable.price, status='paid', tax=taxable.tax, @@ -234,9 +240,10 @@ class TestReceiptPDFView(TestCase): tax_rate=taxable.tax_rate, vat_number='NL123456789', email='billing@example.com', + subscription__customer=user.customer, subscription__plan_id=1, ) - self.client.force_login(order.user) + self.client.force_login(order.customer.user) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk}) response = self.client.get(url) @@ -263,15 +270,18 @@ class TestReceiptPDFView(TestCase): @freeze_time('2023-02-08T11:12:20+01:00') def test_get_pdf_total_no_vat(self): + user = UserFactory() order = OrderFactory( + customer=user.customer, price=1000, currency='USD', status='paid', tax_country='US', email='billing@example.com', + subscription__customer=user.customer, subscription__plan_id=1, ) - self.client.force_login(order.user) + self.client.force_login(order.customer.user) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk}) response = self.client.get(url) @@ -305,6 +315,7 @@ class TestReceiptPDFView(TestCase): subscription__plan_id=1, ) order = OrderFactory( + customer=team.subscription.customer, price=20000, currency='USD', status='paid', @@ -312,7 +323,7 @@ class TestReceiptPDFView(TestCase): email='billing@example.com', subscription=team.subscription, ) - self.client.force_login(order.user) + self.client.force_login(order.customer.user) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk}) response = self.client.get(url) @@ -347,6 +358,7 @@ class TestReceiptPDFView(TestCase): invoice_reference='PO #9876', ) order = OrderFactory( + customer=team.subscription.customer, price=20000, currency='USD', status='paid', @@ -354,7 +366,7 @@ class TestReceiptPDFView(TestCase): email='billing@example.com', subscription=team.subscription, ) - self.client.force_login(order.user) + self.client.force_login(order.customer.user) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk}) response = self.client.get(url) diff --git a/subscriptions/views/tests/test_select_plan_variation.py b/subscriptions/views/tests/test_select_plan_variation.py index 7e12b773..0a7fbfdd 100644 --- a/subscriptions/views/tests/test_select_plan_variation.py +++ b/subscriptions/views/tests/test_select_plan_variation.py @@ -7,7 +7,7 @@ from freezegun import freeze_time from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4 # , SINGAPORE_IPV4 -from common.tests.factories.subscriptions import create_customer_with_billing_address +from looper.tests.factories import create_customer_with_billing_address from subscriptions.tests.base import BaseSubscriptionTestCase # **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database. @@ -49,8 +49,8 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase): self._assert_default_variation_selected_no_tax_usd(response) def test_get_displays_plan_selection_to_logged_in_nl(self): - user = create_customer_with_billing_address(vat_number='', country='NL') - self.client.force_login(user) + customer = create_customer_with_billing_address(vat_number='', country='NL') + self.client.force_login(customer.user) response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4) @@ -60,8 +60,8 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase): self._assert_default_variation_selected_tax_21_eur(response) def test_get_displays_plan_selection_to_logged_in_de(self): - user = create_customer_with_billing_address(vat_number='', country='DE') - self.client.force_login(user) + customer = create_customer_with_billing_address(vat_number='', country='DE') + self.client.force_login(customer.user) response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4) @@ -71,10 +71,10 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase): self._assert_default_variation_selected_tax_19_eur(response) def test_get_displays_plan_selection_to_logged_in_us(self): - user = create_customer_with_billing_address( + customer = create_customer_with_billing_address( vat_number='', country='US', region='NY', postal_code='12001' ) - self.client.force_login(user) + self.client.force_login(customer.user) response = self.client.get(self.url) @@ -84,10 +84,10 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase): self._assert_plan_selector_no_tax(response) def test_get_team_displays_plan_selection_to_logged_in_us(self): - user = create_customer_with_billing_address( + customer = create_customer_with_billing_address( vat_number='', country='US', region='NY', postal_code='12001' ) - self.client.force_login(user) + self.client.force_login(customer.user) response = self.client.get(self.url_team) diff --git a/subscriptions/views/tests/test_settings.py b/subscriptions/views/tests/test_settings.py index f9a7d266..5fd34e11 100644 --- a/subscriptions/views/tests/test_settings.py +++ b/subscriptions/views/tests/test_settings.py @@ -4,12 +4,16 @@ from django.urls import reverse from looper.models import PaymentMethod, PaymentMethodAuthentication, Gateway from looper.money import Money +from looper.tests.factories import SubscriptionFactory +import responses + +# from responses import _recorder from common.tests.factories.users import UserFactory -from common.tests.factories.subscriptions import SubscriptionFactory -from subscriptions.tests.base import BaseSubscriptionTestCase +from subscriptions.tests.base import BaseSubscriptionTestCase, responses_from_file import subscriptions.tasks +responses_dir = 'subscriptions/tests/_responses/' required_address_data = { 'country': 'NL', 'email': 'my.billing.email@example.com', @@ -27,16 +31,17 @@ full_billing_address_data = { class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase): - def test_saves_both_address_and_customer(self): + url = reverse('subscriptions:billing-address') + + def test_saves_full_billing_address(self): user = UserFactory() self.client.force_login(user) - url = reverse('subscriptions:billing-address') - response = self.client.post(url, full_billing_address_data) + response = self.client.post(self.url, full_billing_address_data) # Check that the redirect on success happened self.assertEqual(response.status_code, 302, response.content) - self.assertEqual(response['Location'], url) + self.assertEqual(response['Location'], self.url) # Check that all address fields were updated customer = user.customer @@ -51,15 +56,14 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase): self.assertEqual(address.company, 'Test LLC') # Check that customer fields were updated as well - self.assertEqual(customer.vat_number, 'NL818152011B01') - # N.B.: email is saved as Customer.billing_email - self.assertEqual(customer.billing_email, 'my.billing.email@example.com') + self.assertEqual(customer.billing_address.vat_number, 'NL818152011B01') + self.assertEqual(customer.billing_address.email, 'my.billing.email@example.com') def test_invalid_missing_required_fields(self): user = UserFactory() self.client.force_login(user) - response = self.client.post(reverse('subscriptions:billing-address'), {}) + response = self.client.post(self.url, {}) self.assertEqual(response.status_code, 200) self.assertContains(response, 'errorlist') @@ -72,7 +76,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase): data = { 'email': 'new@example.com', } - response = self.client.post(reverse('subscriptions:billing-address'), data) + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 200) self.assertContains(response, 'errorlist') @@ -85,7 +89,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase): data = { 'full_name': 'New Full Name', } - response = self.client.post(reverse('subscriptions:billing-address'), data) + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 200) self.assertContains(response, 'errorlist') @@ -100,11 +104,13 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase): url_name = 'subscriptions:payment-method-change' success_url_name = 'user-settings-billing' + # @_recorder.record(file_path=f'{responses_dir}stripe_new_cs_setup.yaml') + # @_recorder.record(file_path=f'{responses_dir}stripe_get_cs_setup.yaml') def test_can_change_payment_method_from_bank_to_credit_card_with_sca(self): bank = Gateway.objects.get(name='bank') subscription = SubscriptionFactory( - user=self.user, - payment_method__user_id=self.user.pk, + customer=self.user.customer, + payment_method__customer_id=self.user.customer.pk, payment_method__gateway=bank, ) self.assertEqual(PaymentMethod.objects.count(), 1) @@ -113,15 +119,24 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase): self.client.force_login(self.user) url = reverse(self.url_name, kwargs={'subscription_id': subscription.pk}) - data = { - **self.shared_payment_form_data, - 'gateway': 'braintree', - 'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce', - } - response = self.client.post(url, data=data) + with responses.RequestsMock() as rsps: + rsps._add_from_file(f'{responses_dir}stripe_new_cs_setup.yaml') + response = self.client.post(url) self.assertEqual(response.status_code, 302) - self.assertEqual(response['Location'], reverse(self.success_url_name)) + expected_redirect_url = 'https://checkout.stripe.com/c/pay/cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBaZmppcGhrJyknYGtkZ2lgVWlkZmBtamlhYHd2Jz9xd3BgeCUl' + self.assertEqual(response['Location'], expected_redirect_url, response['Location']) + + # **N.B**: this flow happens in 2 different views separated by a Stripe payment page. + # Pretend that checkout session was completed and we've returned to the success page with its ID: + checkout_session_id = 'cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9' + success_url = url + f'{checkout_session_id}/' + with responses.RequestsMock() as rsps: + rsps._add_from_file(f'{responses_dir}stripe_get_cs_setup.yaml') + response = self.client.get(success_url) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], f'/subscription/{subscription.pk}/manage/') + # New payment method was created self.assertEqual(PaymentMethod.objects.count(), 2) @@ -131,40 +146,9 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase): self.assertNotEqual(subscription.payment_method_id, payment_method.pk) self.assertEqual( str(subscription.payment_method), - 'Visa credit card ending in 0002', + 'visa credit card ending in 4242', ) - # SCA was stored - self.assertIsNotNone(PaymentMethodAuthentication.objects.first()) - - def test_can_change_payment_method_from_credit_card_to_bank(self): - braintree = Gateway.objects.get(name='braintree') - subscription = SubscriptionFactory( - user=self.user, - payment_method__user_id=self.user.pk, - payment_method__gateway=braintree, - ) - self.assertEqual(PaymentMethod.objects.count(), 1) - payment_method = subscription.payment_method - self.client.force_login(self.user) - - url = reverse(self.url_name, kwargs={'subscription_id': subscription.pk}) - data = { - **self.shared_payment_form_data, - 'gateway': 'bank', - 'payment_method_nonce': 'unused', - } - response = self.client.post(url, data=data) - - self.assertEqual(response.status_code, 302) - self.assertEqual(response['Location'], reverse(self.success_url_name)) - # New payment method was created - self.assertEqual(PaymentMethod.objects.count(), 2) - - subscription.refresh_from_db() - subscription.payment_method.refresh_from_db() - # Subscription's payment method was changed to bank transfer - self.assertNotEqual(subscription.payment_method_id, payment_method.pk) - self.assertEqual(str(subscription.payment_method), 'Bank Transfer') + # SCA isn't stored for Stripe payment methods self.assertIsNone(PaymentMethodAuthentication.objects.first()) @@ -173,8 +157,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase): def test_can_cancel_when_on_hold(self): subscription = SubscriptionFactory( - user=self.user, - payment_method__user_id=self.user.pk, + customer=self.user.customer, + payment_method__customer_id=self.user.customer.pk, payment_method__gateway=Gateway.objects.get(name='bank'), status='on-hold', ) @@ -198,8 +182,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase): def test_can_cancel_when_active(self): subscription = SubscriptionFactory( - user=self.user, - payment_method__user_id=self.user.pk, + customer=self.user.customer, + payment_method__customer_id=self.user.customer.pk, payment_method__gateway=Gateway.objects.get(name='bank'), status='active', ) @@ -218,8 +202,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase): def test_email_sent_when_pending_cancellation_changes_to_cancelled(self): subscription = SubscriptionFactory( - user=self.user, - payment_method__user_id=self.user.pk, + customer=self.user.customer, + payment_method__customer_id=self.user.customer.pk, payment_method__gateway=Gateway.objects.get(name='bank'), status='pending-cancellation', ) @@ -240,12 +224,11 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase): class TestPayExistingOrder(BaseSubscriptionTestCase): url_name = 'subscriptions:pay-existing-order' - success_url_name = 'user-settings-billing' def test_redirect_to_login_when_anonymous(self): subscription = SubscriptionFactory( - user=self.user, - payment_method__user_id=self.user.pk, + customer=self.user.customer, + payment_method__customer_id=self.user.customer.pk, payment_method__gateway=Gateway.objects.get(name='bank'), status='on-hold', ) @@ -253,19 +236,15 @@ class TestPayExistingOrder(BaseSubscriptionTestCase): self.client.logout() url = reverse(self.url_name, kwargs={'order_id': order.pk}) - data = { - 'gateway': 'braintree', - 'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce', - } - response = self.client.post(url, data=data) + response = self.client.get(url) self.assertEqual(response.status_code, 302) self.assertEqual(response['Location'], f'/oauth/login?next={url}') def test_cannot_pay_someone_elses_order(self): subscription = SubscriptionFactory( - user=self.user, - payment_method__user_id=self.user.pk, + customer=self.user.customer, + payment_method__customer_id=self.user.customer.pk, payment_method__gateway=Gateway.objects.get(name='bank'), status='on-hold', ) @@ -274,40 +253,12 @@ class TestPayExistingOrder(BaseSubscriptionTestCase): self.client.force_login(user) url = reverse(self.url_name, kwargs={'order_id': order.pk}) - data = { - 'gateway': 'braintree', - 'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce', - } - response = self.client.post(url, data=data) + response = self.client.get(url) - self.assertEqual(response.status_code, 403) - - def test_invalid_missing_required_form_data(self): - subscription = SubscriptionFactory( - user=self.user, - payment_method__user_id=self.user.pk, - payment_method__gateway=Gateway.objects.get(name='bank'), - status='on-hold', - ) - order = subscription.generate_order() - self.client.force_login(self.user) - - url = reverse(self.url_name, kwargs={'order_id': order.pk}) - response = self.client.post(url, data={}) - - self.assertEqual(response.status_code, 200) - self.assertEqual( - response.context['form'].errors, - { - # 'full_name': ['This field is required.'], - # 'country': ['This field is required.'], - # 'email': ['This field is required.'], - 'payment_method_nonce': ['This field is required.'], - 'gateway': ['This field is required.'], - 'price': ['This field is required.'], - }, - ) + self.assertEqual(response.status_code, 404) + # @_recorder.record(file_path=f'{responses_dir}stripe_new_cs_usd.yaml') + # @_recorder.record(file_path=f'{responses_dir}stripe_get_cs_usd.yaml') @patch( # Make sure background task is executed as a normal function 'subscriptions.signals.tasks.send_mail_subscription_status_changed', @@ -315,26 +266,39 @@ class TestPayExistingOrder(BaseSubscriptionTestCase): ) def test_can_pay_for_manual_subscription_with_an_order(self): subscription = SubscriptionFactory( - user=self.user, - payment_method__user_id=self.user.pk, + plan__name='Automatic renewal subscription', + customer=self.user.customer, + payment_method__customer_id=self.user.customer.pk, payment_method__gateway=Gateway.objects.get(name='bank'), currency='USD', price=Money('USD', 1110), status='on-hold', ) + self.assertEqual(subscription.collection_method, 'automatic') order = subscription.generate_order() self.client.force_login(self.user) url = reverse(self.url_name, kwargs={'order_id': order.pk}) - data = { - **required_address_data, - 'price': order.price, - 'gateway': 'braintree', - 'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce', - } - response = self.client.post(url, data=data) + with responses.RequestsMock() as rsps: + rsps._add_from_file(f'{responses_dir}stripe_new_cs_usd.yaml') + response = self.client.get(url) self.assertEqual(response.status_code, 302) + expected_redirect_url = 'https://checkout.stripe.com/c/pay/cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl' + self.assertEqual(response['Location'], expected_redirect_url) + + # **N.B**: this flow happens in 2 different views separated by a Stripe payment page. + # Pretend that checkout session was completed and we've returned to the success page with its ID: + checkout_session_id = 'cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w' + url = reverse( + 'looper:stripe_success', + kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'}, + ) + url = url.replace('CHECKOUT_SESSION_ID', checkout_session_id) + with responses.RequestsMock() as rsps: + responses_from_file('stripe_get_cs_usd.yaml', order_id=order.pk, rsps=rsps) + response = self.client.get(url) + self.assertEqual(order.transaction_set.count(), 1) transaction = order.latest_transaction() self.assertEqual( @@ -350,7 +314,7 @@ class TestPayExistingOrder(BaseSubscriptionTestCase): self.assertNotEqual(subscription.payment_method, 'bank') self.assertEqual( str(subscription.payment_method), - 'Visa credit card ending in 0002', + 'PayPal account billing@example.com', ) self.assertEqual(subscription.status, 'active') diff --git a/users/admin.py b/users/admin.py index cf287739..9d0a3f6c 100644 --- a/users/admin.py +++ b/users/admin.py @@ -6,6 +6,9 @@ from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from rest_framework.authtoken.admin import TokenAdmin +import nested_admin + +from looper.admin import REL_CUSTOMER_SEARCH_FIELDS import looper.admin import looper.models @@ -26,7 +29,7 @@ user_section_progress_link.short_description = 'Training sections progress' def user_subscriptions_link(obj, title='View subscriptions of this user'): admin_view = looper.admin._get_admin_url_name(looper.models.Subscription, 'changelist') - link = reverse(admin_view) + f'?user_id={obj.pk}' + link = reverse(admin_view) + f'?customer_id={obj.customer.pk}' return format_html('{}', link, title) @@ -80,7 +83,7 @@ class NumberOfBraintreeCustomerIDsFilter(admin.SimpleListFilter): @admin.register(get_user_model()) -class UserAdmin(auth_admin.UserAdmin): +class UserAdmin(auth_admin.UserAdmin, nested_admin.NestedModelAdmin): change_form_template = 'loginas/change_form.html' def has_add_permission(self, request): @@ -91,9 +94,9 @@ class UserAdmin(auth_admin.UserAdmin): """Count user subscriptions for subscription debugging purposes.""" queryset = super().get_queryset(*args, **kwargs) queryset = queryset.annotate( - subscriptions_count=Count('subscription', distinct=True), + subscriptions_count=Count('customer__subscription', distinct=True), braintreecustomerids_count=Count( - 'gatewaycustomerid', Q(gateway__name='braintree'), distinct=True + 'customer__gatewaycustomerid', Q(customer__gateway__name='braintree'), distinct=True ), ) return queryset @@ -147,13 +150,12 @@ class UserAdmin(auth_admin.UserAdmin): user_subscriptions_link, subscriptions, ) - inlines = [ - looper.admin.AddressInline, + inlines = ( + looper.admin.LinkCustomerTokenInline, looper.admin.CustomerInline, - looper.admin.GatewayCustomerIdInline, - ] + ) ordering = ['-date_joined'] - search_fields = ['email', 'full_name', 'username'] + search_fields = ['email', 'full_name', 'username', *REL_CUSTOMER_SEARCH_FIELDS] def deletion_requested(self, obj): """Display yes/no icon status of deletion request.""" diff --git a/users/models.py b/users/models.py index 3988bc81..5c76a93a 100644 --- a/users/models.py +++ b/users/models.py @@ -174,7 +174,7 @@ class User(AbstractUser): self.delete_oauth() # If there are no orders, the user account can be deleted - if self.order_set.count() == 0: + if self.customer.order_set.count() == 0: logger.warning( 'User pk=%s requested deletion and has no orders: deleting the account', self.pk, @@ -199,7 +199,7 @@ class User(AbstractUser): logger.warning('Anonymized user pk=%s', self.pk) logger.warning('Soft-deleting payment methods records of user pk=%s', self.pk) - for payment_method in self.paymentmethod_set.all(): + for payment_method in self.customer.paymentmethod_set.all(): payment_method.recognisable_name = '' logger.warning( 'Deleting payment method %s of user pk=%s at the payment gateway', @@ -208,17 +208,14 @@ class User(AbstractUser): ) payment_method.delete() - logger.warning('Deleting address records of user pk=%s', self.pk) - looper.models.Address.objects.filter(user_id=self.pk).delete() + customer_id = self.customer.pk + logger.warning('Deleting address records of customer pk=%s', customer_id) + looper.models.Address.objects.filter(customer_id=customer_id).delete() - logger.warning('Anonymizing Customer record of user pk=%s', self.pk) - looper.models.Customer.objects.exclude(user_id=None).filter(user_id=self.pk).update( - billing_email=f'{username}@example.com', - full_name='', - ) - - looper.models.GatewayCustomerId.objects.filter(user_id=self.pk).delete() + logger.warning('Deleting gateway customer ID records of customer pk=%s', customer_id) + looper.models.GatewayCustomerId.objects.filter(customer_id=customer_id).delete() + logger.warning('Deleting user pk=%s from teams', self.pk) subscriptions.models.TeamUsers.objects.filter(user_id=self.pk).delete() logger.warning('Anonymizing comments of user pk=%s', self.pk) diff --git a/users/tasks.py b/users/tasks.py index d1948dd4..5446d029 100644 --- a/users/tasks.py +++ b/users/tasks.py @@ -107,7 +107,7 @@ def handle_tracking_event_unsubscribe(event_type: str, message_id: str, event: D def unsubscribe_from_newsletters(pk: int): """Remove emails of user with given pk from newsletter lists.""" user = User.objects.get(pk=pk) - emails = (user.email, user.customer.billing_email) + emails = (user.email, user.customer.billing_address.email) for email in emails: if not email: continue @@ -135,7 +135,7 @@ def handle_deletion_request(pk: int) -> bool: try: unsubscribe_from_newsletters(pk=pk) except Exception: - logger.warning('Error while trying to unsubscribe user pk=%s from newsletters') + logger.warning('Error while trying to unsubscribe user pk=%s from newsletters', pk) user.anonymize_or_delete() return True diff --git a/users/templates/users/settings/base.html b/users/templates/users/settings/base.html index a0e1bd46..d5fec9ba 100644 --- a/users/templates/users/settings/base.html +++ b/users/templates/users/settings/base.html @@ -1,4 +1,5 @@ {% extends 'common/base.html' %} +{% load pipeline %} {% block nav_drawer_inner %} {% include 'users/settings/tabs.html' %} @@ -21,9 +22,13 @@
- {% block settings%} + {% block settings %} {% endblock settings %}
{% endblock content %} + +{% block scripts %} + {% javascript "subscriptions" %} +{% endblock scripts %} diff --git a/users/templates/users/settings/billing.html b/users/templates/users/settings/billing.html index 3f355947..98c8d9dd 100644 --- a/users/templates/users/settings/billing.html +++ b/users/templates/users/settings/billing.html @@ -4,7 +4,7 @@ {% block settings %}

Settings

-

Subscription{% if user.subscription_set.count > 1 %}s{% endif %}

+

Subscription{% if user.customer.subscription_set.count > 1 %}s{% endif %}

If you have any problems with billing, contact the team directly on {{ ADMIN_MAIL }}.

{% if user|has_group:"demo" %} @@ -18,7 +18,8 @@

- {% else %}{# for everyone except demo #} + {% endif %} + {% if not user|has_group:"demo" or user.customer.subscription_set.count %}
{% include "subscriptions/components/list.html" %} diff --git a/users/tests/test_tasks.py b/users/tests/test_tasks.py index 01bc6491..909e688a 100644 --- a/users/tests/test_tasks.py +++ b/users/tests/test_tasks.py @@ -5,15 +5,17 @@ from django.contrib.auth import get_user_model from django.test import TestCase, override_settings from django.utils import timezone -from comments.queries import set_comment_like -from common.tests.factories.comments import CommentFactory -from common.tests.factories.subscriptions import ( - TeamFactory, +from looper.tests.factories import ( PaymentMethodFactory, TransactionFactory, create_customer_with_billing_address, ) -from common.tests.factories.users import UserFactory +import looper.models + +from comments.queries import set_comment_like +from common.tests.factories.comments import CommentFactory +from common.tests.factories.subscriptions import TeamFactory +from common.tests.factories.users import UserFactory, OAuthUserInfoFactory, OAuthUserTokenFactory import users.tasks as tasks import users.tests.util as util @@ -49,9 +51,13 @@ class TestTasks(TestCase): def test_handle_deletion_request(self): now = timezone.now() - user = create_customer_with_billing_address( + customer = create_customer_with_billing_address( email='mail1@example.com', date_deletion_requested=now - timedelta(days=30) ) + user = customer.user + OAuthUserInfoFactory(user=user, oauth_user_id=223344) + OAuthUserTokenFactory(user=user) + OAuthUserTokenFactory(user=user) # this user made some comments user_comments = [CommentFactory(user=user) for _ in range(2)] # this user liked some comments as well @@ -102,22 +108,27 @@ class TestTasks(TestCase): def test_handle_deletion_request_user_has_orders(self): now = timezone.now() - user = create_customer_with_billing_address( + customer = create_customer_with_billing_address( email='mail1@example.com', date_deletion_requested=now - timedelta(days=30) ) + user = customer.user + OAuthUserInfoFactory(user=user, oauth_user_id=223344) + OAuthUserTokenFactory(user=user) + OAuthUserTokenFactory(user=user) # this user has a subscription with an order and a transaction - payment_method = PaymentMethodFactory(user=user) + payment_method = PaymentMethodFactory(customer=customer, token='fake-token') transaction = TransactionFactory( - user=user, - order__price=990, - order__tax_country='NL', + customer=customer, + order__customer=customer, order__payment_method=payment_method, + order__price=990, + order__subscription__customer=customer, order__subscription__payment_method=payment_method, - order__subscription__user=user, order__subscription__status='cancelled', - order__user=user, + order__tax_country='NL', payment_method=payment_method, ) + billing_address = customer.billing_address # this user made some comments user_comments = [CommentFactory(user=user) for _ in range(2)] # this user liked some comments as well @@ -142,8 +153,9 @@ class TestTasks(TestCase): f'Anonymized user pk={user.pk}', f'Soft-deleting payment methods records of user pk={user.pk}', rf'Deleting payment method \d+ of user pk={user.pk} at the payment gateway', - f'Deleting address records of user pk={user.pk}', - f'Anonymizing Customer record of user pk={user.pk}', + f'Deleting address records of customer pk={customer.pk}', + f'Deleting gateway customer ID records of customer pk={customer.pk}', + f'Deleting user pk={user.pk} from teams', f'Anonymizing comments of user pk={user.pk}', f'Anonymizing likes of user pk={user.pk}', f'Deleting actions of user pk={user.pk}', @@ -165,12 +177,11 @@ class TestTasks(TestCase): self.assertEqual(user.full_name, '') self.assertTrue(user.email.startswith('del')) self.assertTrue(user.email.endswith('@example.com')) - user.customer.refresh_from_db() - self.assertTrue(user.customer.billing_email.startswith('del'), user.customer.billing_email) - self.assertTrue(user.customer.billing_email.endswith('@example.com'), user.customer.billing_email) - self.assertEqual(user.customer.full_name, '' , user.customer.full_name) - self.assertEqual(user.address_set.count(), 0) - self.assertEqual(user.paymentmethod_set.first().recognisable_name, '') + # billing address was deleted + with self.assertRaises(looper.models.Address.DoesNotExist): + billing_address.refresh_from_db() + customer.refresh_from_db() + self.assertEqual(customer.paymentmethod_set.first().recognisable_name, '') # user actions got deleted for action in user_actions: @@ -192,22 +203,23 @@ class TestTasks(TestCase): def test_handle_deletion_request_user_has_not_yet_cancelled_subscription(self): now = timezone.now() - user = create_customer_with_billing_address( + customer = create_customer_with_billing_address( full_name='Joe Dane', email='mail1@example.com', date_deletion_requested=now - timedelta(days=30), ) + user = customer.user # this user has a subscription with an order and a transaction - payment_method = PaymentMethodFactory(user=user) + payment_method = PaymentMethodFactory(customer=customer) transaction = TransactionFactory( - user=user, - order__price=990, - order__tax_country='NL', + customer=customer, + order__customer=customer, order__payment_method=payment_method, + order__price=990, + order__subscription__customer=customer, order__subscription__payment_method=payment_method, - order__subscription__user=user, order__subscription__status='on-hold', - order__user=user, + order__tax_country='NL', payment_method=payment_method, ) @@ -235,7 +247,7 @@ class TestTasks(TestCase): def test_handle_deletion_request_user_has_orders_and_is_on_a_team(self): now = timezone.now() team = TeamFactory( - subscription__user=create_customer_with_billing_address( + subscription__customer=create_customer_with_billing_address( full_name='Joe Manager Dane', email='mail1@example.com', ) @@ -246,25 +258,27 @@ class TestTasks(TestCase): team.users.add(user_to_be_deleted) self.assertEqual(3, team.users.count()) # this user also has a subscription with an order and a transaction - payment_method = PaymentMethodFactory(user=user_to_be_deleted) + payment_method = PaymentMethodFactory( + customer=user_to_be_deleted.customer, token='fake-token' + ) TransactionFactory( - user=user_to_be_deleted, + customer=user_to_be_deleted.customer, order__price=990, order__tax_country='NL', + order__customer=user_to_be_deleted.customer, order__payment_method=payment_method, order__subscription__payment_method=payment_method, - order__subscription__user=user_to_be_deleted, + order__subscription__customer=user_to_be_deleted.customer, order__subscription__status='cancelled', - order__user=user_to_be_deleted, payment_method=payment_method, ) tasks.handle_deletion_request.task_function(pk=user_to_be_deleted.pk) # sanity check: nothing happened to the user owning the team subscription - team.subscription.user.refresh_from_db() - self.assertEqual('Joe Manager Dane', team.subscription.user.full_name) - self.assertTrue(team.subscription.user.is_active) + team.subscription.customer.user.refresh_from_db() + self.assertEqual('Joe Manager Dane', team.subscription.customer.user.full_name) + self.assertTrue(team.subscription.customer.user.is_active) # user wasn't deleted but anonymised user_to_be_deleted.refresh_from_db() @@ -280,7 +294,7 @@ class TestTasks(TestCase): def test_handle_deletion_request_user_and_is_on_a_team(self): now = timezone.now() team = TeamFactory( - subscription__user=create_customer_with_billing_address( + subscription__customer=create_customer_with_billing_address( full_name='Joe Manager Dane', email='mail1@example.com', ) @@ -294,9 +308,9 @@ class TestTasks(TestCase): tasks.handle_deletion_request.task_function(pk=user_to_be_deleted.pk) # sanity check: nothing happened to the user owning the team subscription - team.subscription.user.refresh_from_db() - self.assertEqual('Joe Manager Dane', team.subscription.user.full_name) - self.assertTrue(team.subscription.user.is_active) + team.subscription.customer.user.refresh_from_db() + self.assertEqual('Joe Manager Dane', team.subscription.customer.user.full_name) + self.assertTrue(team.subscription.customer.user.is_active) # user was deleted with self.assertRaises(User.DoesNotExist): diff --git a/users/tests/util.py b/users/tests/util.py index 21f02b35..dce3092a 100644 --- a/users/tests/util.py +++ b/users/tests/util.py @@ -5,8 +5,6 @@ from django.conf import settings from django.contrib.auth import get_user_model import responses -from common.tests.factories.users import UserFactory - User = get_user_model() @@ -145,7 +143,7 @@ def mock_mailgun_responses() -> None: def create_admin_log_user() -> User: """Create the admin user used for logging.""" - admin_user = UserFactory(id=1, email='admin@blender.studio', is_staff=True, is_superuser=True) - # Reset ID sequence to avoid clashing with an already used ID 1 - UserFactory.reset_sequence(100, force=True) + admin_user, _ = User.objects.update_or_create( + id=1, defaults={'email': 'admin@blender.studio', 'is_staff': True, 'is_superuser': True} + ) return admin_user
{% if subscription.team %}Team {% endif %}Subscription #{{ subscription.pk }} {% include "subscriptions/components/pretty_status.html" %}