Stripe checkout #104411

Merged
Anna Sirota merged 61 commits from stripe into main 2024-06-17 18:08:41 +02:00
65 changed files with 1612 additions and 1415 deletions

View File

@ -49,6 +49,8 @@ MAILGUN_API_KEY=
MAILGUN_WEBHOOK_SIGNING_KEY= MAILGUN_WEBHOOK_SIGNING_KEY=
MAILGUN_WEBHOOK_SECRET= MAILGUN_WEBHOOK_SECRET=
GOOGLE_RECAPTCHA_SITE_KEY=
GOOGLE_RECAPTCHA_SECRET_KEY=
GOOGLE_ANALYTICS_TRACKING_ID= GOOGLE_ANALYTICS_TRACKING_ID=
STRIPE_API_PUBLISHABLE_KEY=
STRIPE_API_SECRET_KEY=
STRIPE_ENDPOINT_SECRET=

View File

@ -174,7 +174,7 @@ class TestCharacterVersionDownload(TestCase):
def test_can_download_non_free_when_subscribed(self): def test_can_download_non_free_when_subscribed(self):
user = UserFactory() user = UserFactory()
SubscriptionFactory(user=user, status='active') SubscriptionFactory(customer=user.customer, status='active')
character_version = CharacterVersionFactory(is_free=False) character_version = CharacterVersionFactory(is_free=False)
self.client.force_login(user) self.client.force_login(user)
@ -222,7 +222,7 @@ class TestCharacterShowcaseDownload(TestCase):
def test_can_download_non_free_when_subscribed(self): def test_can_download_non_free_when_subscribed(self):
user = UserFactory() user = UserFactory()
SubscriptionFactory(user=user, status='active') SubscriptionFactory(customer=user.customer, status='active')
character_showcase = CharacterShowcaseFactory(is_free=False) character_showcase = CharacterShowcaseFactory(is_free=False)
self.client.force_login(user) self.client.force_login(user)

View File

@ -28,5 +28,4 @@ def extra_context(request: HttpRequest) -> Dict[str, str]:
}, },
'canonical_url': request.build_absolute_uri(request.path), 'canonical_url': request.build_absolute_uri(request.path),
'ADMIN_MAIL': settings.ADMIN_MAIL, 'ADMIN_MAIL': settings.ADMIN_MAIL,
'GOOGLE_RECAPTCHA_SITE_KEY': settings.GOOGLE_RECAPTCHA_SITE_KEY,
} }

View File

@ -714,3 +714,19 @@ button,
&.rounded-lg &.rounded-lg
.plyr .plyr
border-radius: var(--border-radius-lg) border-radius: var(--border-radius-lg)
/* Simple CSS-only tooltips. */
[data-tooltip]
&:hover
&:before, &:after
display: block
position: absolute
color: var(--color-text-primary)
&:before
border-radius: var(--spacer-1)
content: attr(title)
background-color: var(--color-bg-primary-subtle)
margin-top: var(--spacer)
padding: var(--spacer)
font-size: var(--fs-sm)

View File

@ -1,53 +1,10 @@
from django.contrib.auth import get_user_model
from django.db.models import signals
from factory.django import DjangoModelFactory from factory.django import DjangoModelFactory
import factory import factory
import looper.models from looper.tests.factories import SubscriptionFactory
from common.tests.factories.users import UserFactory
from subscriptions.models import Team 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 TeamFactory(DjangoModelFactory):
class Meta: class Meta:
@ -55,38 +12,3 @@ class TeamFactory(DjangoModelFactory):
name = factory.Faker('text', max_nb_chars=15) name = factory.Faker('text', max_nb_chars=15)
subscription = factory.SubFactory(SubscriptionFactory) 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

View File

@ -249,8 +249,6 @@ Symbol | Description
│   ├── 🔴 bank_transfer_details.txt │   ├── 🔴 bank_transfer_details.txt
│   ├── 🔴 bank_transfer_reference.txt │   ├── 🔴 bank_transfer_reference.txt
│   ├── 🟢 billing_address_form.html │   ├── 🟢 billing_address_form.html
│   ├── 🟢 billing_address_form_readonly.html
│   ├── 🟢 current_plan_variation.html
│   ├── 🟢 footer.html │   ├── 🟢 footer.html
│   ├── ❌ header_jumbotron.html │   ├── ❌ header_jumbotron.html
│   ├── 🟢 info.html │   ├── 🟢 info.html
@ -265,11 +263,8 @@ Symbol | Description
│   └── 🟢 total.html │   └── 🟢 total.html
├── join ├── join
│   ├── 🟢 billing_address.html │   ├── 🟢 billing_address.html
│   ├── 🟢 payment_method.html
│   └── ⚪ select_plan_variation.html │   └── ⚪ select_plan_variation.html
├── 🟢 manage.html ├── 🟢 manage.html
├── 🟢 pay_existing_order.html
├── 🟢 payment_method_change.html
└── widgets └── widgets
└── ⚪ region_select.html └── ⚪ region_select.html
``` ```

View File

@ -93,12 +93,13 @@ class SubscriptionEmailPreviewAdmin(looper.admin.mixins.NoAddDeleteMixin, EmailA
def get_object(self, request, object_id, from_field=None): def get_object(self, request, object_id, from_field=None):
"""Construct the Email on th fly from known subscription email templates.""" """Construct the Email on th fly from known subscription email templates."""
user = User() user = User(full_name='Jane Doe')
user.customer = looper.models.Customer(full_name='Jane Doe') customer = looper.models.Customer(user=user)
user.customer = customer
now = timezone.now() now = timezone.now()
subscription = looper.models.Subscription( subscription = looper.models.Subscription(
id=1234567890, id=1234567890,
user=user, customer=user.customer,
payment_method=looper.models.PaymentMethod( payment_method=looper.models.PaymentMethod(
method_type='cc', method_type='cc',
gateway_id=1, gateway_id=1,
@ -112,7 +113,7 @@ class SubscriptionEmailPreviewAdmin(looper.admin.mixins.NoAddDeleteMixin, EmailA
) )
order = looper.models.Order(subscription=subscription) order = looper.models.Order(subscription=subscription)
context = { context = {
'user': subscription.user, 'user': user,
'subscription': subscription, 'subscription': subscription,
'order': order, 'order': order,
# Also add context for the expired email # Also add context for the expired email

View File

@ -54,6 +54,8 @@ MAILGUN_API_KEY=
MAILGUN_WEBHOOK_SIGNING_KEY= MAILGUN_WEBHOOK_SIGNING_KEY=
MAILGUN_WEBHOOK_SECRET= MAILGUN_WEBHOOK_SECRET=
GOOGLE_RECAPTCHA_SITE_KEY=
GOOGLE_RECAPTCHA_SECRET_KEY=
GOOGLE_ANALYTICS_TRACKING_ID= GOOGLE_ANALYTICS_TRACKING_ID=
STRIPE_API_PUBLISHABLE_KEY=
STRIPE_API_SECRET_KEY=
STRIPE_ENDPOINT_SECRET=

36
poetry.lock generated
View File

@ -1300,7 +1300,7 @@ six = ">=1.11.0"
[[package]] [[package]]
name = "looper" name = "looper"
version = "2.1.3" version = "3.2.9"
description = "" description = ""
category = "main" category = "main"
optional = false optional = false
@ -1316,19 +1316,21 @@ braintree = "4.17.1"
colorhash = "^1.0.3" colorhash = "^1.0.3"
django = "^2.2.0 || 3.0 || 3.0.* || 3.2.*" django = "^2.2.0 || 3.0 || 3.0.* || 3.2.*"
django-countries = "^7.2.1" django-countries = "^7.2.1"
django-nested-admin = "^4.0.2"
django-pipeline = "^2.0.6" django-pipeline = "^2.0.6"
geoip2 = "^3.0" geoip2 = "^3.0"
python-dateutil = "^2.7" python-dateutil = "^2.7"
python-stdnum = "^1.16" python-stdnum = "^1.16"
requests = "^2.22" requests = "^2.22"
stripe = "7.1.0"
xhtml2pdf = "^0.2" xhtml2pdf = "^0.2"
zeep = "4.0.0" zeep = "4.0.0"
[package.source] [package.source]
type = "git" type = "git"
url = "https://projects.blender.org/infrastructure/looper.git" url = "https://projects.blender.org/infrastructure/looper.git"
reference = "10bca5a012" reference = "8cd4da9"
resolved_reference = "10bca5a012f3deeede160b3520db630da6b9dfa5" resolved_reference = "8cd4da950b21b85e0a5ecfa8e8e3d62774215a67"
[[package]] [[package]]
name = "lxml" name = "lxml"
@ -2687,6 +2689,22 @@ files = [
{file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"}, {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]] [[package]]
name = "tblib" name = "tblib"
version = "3.0.0" version = "3.0.0"
@ -2783,14 +2801,14 @@ files = [
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.4.0" version = "4.12.1"
description = "Backported and Experimental Type Hints for Python 3.7+" description = "Backported and Experimental Type Hints for Python 3.8+"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
files = [ files = [
{file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, {file = "typing_extensions-4.12.1-py3-none-any.whl", hash = "sha256:6024b58b69089e5a89c347397254e35f1bf02a907728ec7fee9bf0fe837d203a"},
{file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, {file = "typing_extensions-4.12.1.tar.gz", hash = "sha256:915f5e35ff76f56588223f15fdd5938f9a1cf9195c0de25130c627e4d597f6d1"},
] ]
[[package]] [[package]]
@ -2939,4 +2957,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.8" python-versions = "^3.8"
content-hash = "1039e10638790d46a95584572ec99691f740e74be5166fe4c86ebe52b4e953c5" content-hash = "69a6d187007cb4d99d90cf4472b9295a03c6c18518efcea4b6c50dc0d3dfef9f"

View File

@ -14,7 +14,7 @@ libsasscompiler = "^0.1.5"
jsmin = "3.0.0" jsmin = "3.0.0"
sorl-thumbnail = "^12.10.0" sorl-thumbnail = "^12.10.0"
mistune = "2.0.0a4" 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" Pillow = "^8.0"
django-storages = {extras = ["google"], version = "1.11.1"} django-storages = {extras = ["google"], version = "1.11.1"}
pymongo = "^3.10.1" pymongo = "^3.10.1"

View File

@ -504,6 +504,12 @@ GATEWAYS = {
'supported_collection_methods': {'automatic', 'manual'}, 'supported_collection_methods': {'automatic', 'manual'},
}, },
'bank': {'supported_collection_methods': {'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 # Optional Sentry configuration
@ -630,8 +636,6 @@ if MAILGUN_SENDER_DOMAIN:
GEOIP2_DB = _get('GEOIP2_DB') GEOIP2_DB = _get('GEOIP2_DB')
GOOGLE_ANALYTICS_TRACKING_ID = _get('GOOGLE_ANALYTICS_TRACKING_ID') 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 = { S3DIRECT_DESTINATIONS = {
'default': { '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 # Maximum number of attempts for failing background tasks
MAX_ATTEMPTS = 3 MAX_ATTEMPTS = 3

View File

@ -5,13 +5,12 @@ import logging
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.forms.fields import Field from django.forms.fields import Field
from django.forms.models import model_to_dict
from localflavor.administrative_areas import ADMINISTRATIVE_AREAS from localflavor.administrative_areas import ADMINISTRATIVE_AREAS
from localflavor.generic.validators import validate_country_postcode from localflavor.generic.validators import validate_country_postcode
from looper.middleware import COUNTRY_CODE_SESSION_KEY
from stdnum.eu import vat from stdnum.eu import vat
import localflavor.exceptions import localflavor.exceptions
import looper.form_fields import looper.form_fields
import looper.forms import looper.forms
import looper.models import looper.models
@ -48,17 +47,11 @@ REQUIRED_FIELDS = {
class BillingAddressForm(forms.ModelForm): class BillingAddressForm(forms.ModelForm):
"""Unify Customer and Address in a single form.""" """Fill in billing address and prepare for intitiating Stripe checkout session."""
# Customer.billing_email is exposed as email in the Form
# because Looper scripts and forms already use "email" everywhere.
__customer_fields = {'billing_email': 'email', 'vat_number': 'vat_number'}
# Colliding "full_name" and "company" values are taken from and saved to the Address.
# FIXME(anna): do we need to use company and full_name on the Customer or only Address?
class Meta: class Meta:
model = looper.models.Address 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 # What kind of choices are allowed depends on the selected country
# and is not yet known when the form is rendered. # and is not yet known when the form is rendered.
@ -81,20 +74,6 @@ class BillingAddressForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Load additional model data from Customer and set form placeholders.""" """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) super().__init__(*args, **kwargs)
# Set placeholder values on all form fields # Set placeholder values on all form fields
@ -160,43 +139,62 @@ class BillingAddressForm(forms.ModelForm):
) )
def save(self, commit=True): 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__, # 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. # however Django won't set the updated blank region value if was omitted from the form.
if self.cleaned_data['region'] == '': if self.cleaned_data['region'] == '':
self.instance.region = '' self.instance.region = ''
instance = super().save(commit=commit) 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 return instance
class BillingAddressReadonlyForm(forms.ModelForm): class PaymentForm(BillingAddressForm):
"""Display the billing details in a payment form but neither validate nor update them. """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: gateway = looper.form_fields.GatewayChoiceField(
model = looper.models.Address queryset=looper.models.Gateway.objects.filter(name__in={'stripe', 'bank'}).order_by(
fields = looper.models.Address.PUBLIC_FIELDS '-is_default'
)
)
def __init__(self, *args, **kwargs): 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) 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): 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): class CancelSubscriptionForm(forms.Form):
"""Confirm cancellation of a subscription.""" """Confirm cancellation of a subscription."""

View File

@ -1,29 +1,10 @@
from django.conf import settings from django.conf import settings
from django.core.management import call_command
from django.db import migrations 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): def add_gateways(apps, schema_editor):
Gateway = apps.get_model('looper', 'Gateway') call_command('loaddata', 'gateways.json', app_label='looper')
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),
)
def remove_gateways(apps, schema_editor): def remove_gateways(apps, schema_editor):

View File

@ -90,7 +90,7 @@ class Team(mixins.CreatedUpdatedMixin, models.Model):
"""Add given user to the team.""" """Add given user to the team."""
seats_taken = self.users.count() seats_taken = self.users.count()
# Not adding the team manager to the team # Not adding the team manager to the team
if user.pk == self.subscription.user_id: if user.pk == self.subscription.customer.user_id:
return return
if self.seats is not None and seats_taken >= self.seats: if self.seats is not None and seats_taken >= self.seats:
logger.warning( logger.warning(

View File

@ -19,7 +19,7 @@ def has_active_subscription(user: User) -> bool:
active_subscriptions: 'QuerySet[Subscription]' = Subscription.objects.active() active_subscriptions: 'QuerySet[Subscription]' = Subscription.objects.active()
return active_subscriptions.filter( 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() ).exists()
@ -33,7 +33,9 @@ def has_non_legacy_subscription(user: User) -> bool:
subscriptions: 'QuerySet[Subscription]' = Subscription.objects.filter(is_legacy=False) 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: def has_subscription(user: User) -> bool:
@ -42,7 +44,7 @@ def has_subscription(user: User) -> bool:
return False return False
return Subscription.objects.filter( 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() ).exists()
@ -51,7 +53,8 @@ def should_redirect_to_billing(user: User) -> bool:
if not user.is_authenticated: if not user.is_authenticated:
return False 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 # Only cancelled subscriptions, no need to redirect to billing
return False 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 # so this seems to be the only currently available way to tell
# when to stop showing the checkout to the customer. # when to stop showing the checkout to the customer.
subscription.latest_order() and subscription.payment_method 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 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()

View File

@ -3,6 +3,7 @@ from typing import Set
import logging import logging
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q from django.db.models import Q
from django.dispatch import receiver from django.dispatch import receiver
import alphabetic_timestamp as ats import alphabetic_timestamp as ats
@ -30,17 +31,39 @@ def timebased_order_number():
@receiver(django_signals.post_save, sender=User) @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.""" """Create Customer on User creation."""
if raw:
return
if not created: if not created:
return 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 try:
Customer.objects.create( customer = instance.customer
user_id=instance.pk, except Customer.DoesNotExist:
billing_email=instance.email, pass
full_name=instance.full_name, 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) @receiver(django_signals.pre_save, sender=Order)
@ -54,7 +77,8 @@ def _set_order_number(sender, instance: Order, **kwargs):
@receiver(subscription_created_needs_payment) @receiver(subscription_created_needs_payment)
def _on_subscription_created_needs_payment(sender: looper.models.Subscription, **kwargs): def _on_subscription_created_needs_payment(sender: looper.models.Subscription, **kwargs):
tasks.send_mail_bank_transfer_required(subscription_id=sender.pk) 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) @receiver(looper.signals.subscription_activated)
@ -65,8 +89,9 @@ def _on_subscription_status_changed(sender: looper.models.Subscription, **kwargs
@receiver(looper.signals.subscription_activated) @receiver(looper.signals.subscription_activated)
def _on_subscription_status_activated(sender: looper.models.Subscription, **kwargs): def _on_subscription_status_activated(sender: looper.models.Subscription, **kwargs):
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=sender.user_id, role='cloud_subscriber') 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'): if not hasattr(sender, 'team'):
return return
@ -79,8 +104,9 @@ def _on_subscription_status_activated(sender: looper.models.Subscription, **kwar
@receiver(looper.signals.subscription_expired) @receiver(looper.signals.subscription_expired)
def _on_subscription_status_deactivated(sender: looper.models.Subscription, **kwargs): def _on_subscription_status_deactivated(sender: looper.models.Subscription, **kwargs):
# No other active subscription exists, subscriber badge can be revoked # No other active subscription exists, subscriber badge can be revoked
if not queries.has_active_subscription(sender.user): user = sender.customer.user
users.tasks.revoke_blender_id_role(pk=sender.user_id, role='cloud_subscriber') if not queries.has_active_subscription(user):
users.tasks.revoke_blender_id_role(pk=user.pk, role='cloud_subscriber')
if not hasattr(sender, 'team'): if not hasattr(sender, 'team'):
return 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})"' 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 # 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) tasks.send_mail_subscription_expired(subscription_id=sender.pk)

View File

@ -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): def send_mail_bank_transfer_required(subscription_id: int):
"""Send out an email notifying about the required bank transfer payment.""" """Send out an email notifying about the required bank transfer payment."""
subscription = looper.models.Subscription.objects.get(pk=subscription_id) subscription = looper.models.Subscription.objects.get(pk=subscription_id)
user = subscription.user customer = subscription.customer
email = user.customer.billing_email or user.email user = customer.user
email = customer.billing_address.email or user.email
assert ( assert (
email email
), f'Cannot send notification about bank payment for subscription {subscription.pk}: no 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" assert order, "Can't send a notificaton about bank transfer without an existing order"
context = { context = {
'user': subscription.user, 'user': user,
'subscription': subscription, 'subscription': subscription,
'order': order, 'order': order,
**get_template_context(), **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): def send_mail_subscription_status_changed(subscription_id: int):
"""Send out an email notifying about the activated subscription.""" """Send out an email notifying about the activated subscription."""
subscription = looper.models.Subscription.objects.get(pk=subscription_id) subscription = looper.models.Subscription.objects.get(pk=subscription_id)
user = subscription.user customer = subscription.customer
email = user.customer.billing_email or user.email user = customer.user
email = customer.billing_address.email or user.email
assert email, f'Cannot send notification about subscription {subscription.pk} status: no email' assert email, f'Cannot send notification about subscription {subscription.pk} status: no email'
if is_noreply(email): if is_noreply(email):
raise raise
@ -109,7 +111,7 @@ def send_mail_subscription_status_changed(subscription_id: int):
verb = 'deactivated' verb = 'deactivated'
context = { context = {
'user': subscription.user, 'user': user,
'subscription': subscription, 'subscription': subscription,
'verb': verb, 'verb': verb,
**get_template_context(), **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.""" """Send out an email notifying about the soft-failed payment."""
order = looper.models.Order.objects.get(pk=order_id) order = looper.models.Order.objects.get(pk=order_id)
transaction = looper.models.Transaction.objects.get(pk=transaction_id) transaction = looper.models.Transaction.objects.get(pk=transaction_id)
user = order.user customer = order.customer
customer = user.customer user = customer.user
email = customer.billing_email or user.email email = customer.billing_address.email or user.email
logger.debug('Sending %r notification to %s', order.status, email) logger.debug('Sending %r notification to %s', order.status, email)
# An Unsubscribe record will prevent this message from being delivered by Mailgun. # 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}) receipt_url = absolute_url('subscriptions:receipt', kwargs={'order_id': order.pk})
context = { context = {
'user': subscription.user, 'user': user,
'email': email, 'email': email,
'order': order, 'order': order,
'subscription': subscription, 'subscription': subscription,
@ -185,7 +187,8 @@ def send_mail_managed_subscription_notification(subscription_id: int):
subscription.pk, subscription.pk,
) )
user = subscription.user customer = subscription.customer
user = customer.user
admin_url = absolute_url( admin_url = absolute_url(
'admin:looper_subscription_change', 'admin:looper_subscription_change',
kwargs={'object_id': subscription.id}, 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): def send_mail_subscription_expired(subscription_id: int):
"""Send out an email notifying about an expired subscription.""" """Send out an email notifying about an expired subscription."""
subscription = looper.models.Subscription.objects.get(pk=subscription_id) subscription = looper.models.Subscription.objects.get(pk=subscription_id)
user = subscription.user customer = subscription.customer
user = customer.user
assert ( assert (
subscription.status == 'expired' subscription.status == 'expired'
@ -229,7 +233,7 @@ def send_mail_subscription_expired(subscription_id: int):
if queries.has_active_subscription(user): if queries.has_active_subscription(user):
logger.error( logger.error(
'Not sending subscription-expired notification: pk=%s has other active subscriptions', 'Not sending subscription-expired notification: pk=%s has other active subscriptions',
subscription.user_id, user.pk,
) )
return return
@ -248,7 +252,7 @@ def send_mail_subscription_expired(subscription_id: int):
logger.debug('Sending subscription-expired notification to %s', email) logger.debug('Sending subscription-expired notification to %s', email)
context = { context = {
'user': subscription.user, 'user': user,
'subscription': subscription, 'subscription': subscription,
'latest_trainings': get_latest_trainings_and_production_lessons(), 'latest_trainings': get_latest_trainings_and_production_lessons(),
'latest_posts': Post.objects.filter(is_published=True)[:5], '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' ), 'send_mail_no_payment_method expects automatic subscription'
assert 'fail' in order.status, f'Unexpected order pk={order_id} status: {order.status}' assert 'fail' in order.status, f'Unexpected order pk={order_id} status: {order.status}'
user = order.user customer = order.customer
customer = user.customer user = customer.user
email = customer.billing_email or user.email email = customer.billing_address.email or user.email
logger.debug('Sending %r notification to %s', order.status, email) logger.debug('Sending %r notification to %s', order.status, email)
# An Unsubscribe record will prevent this message from being delivered by Mailgun. # 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}) receipt_url = absolute_url('subscriptions:receipt', kwargs={'order_id': order.pk})
context = { context = {
'user': subscription.user, 'user': user,
'email': email, 'email': email,
'order': order, 'order': order,
'subscription': subscription, 'subscription': subscription,

View File

@ -14,7 +14,7 @@
<div class="container flat-page-content"> <div class="container flat-page-content">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="jumbotron-body"> <div class="jumbotron-body text-center mt-3">
<h1 class="thanks">Thanks for subscribing to Blender Studio!</h1> <h1 class="thanks">Thanks for subscribing to Blender Studio!</h1>
<p>Make sure to follow the instructions below to activate your account.</p> <p>Make sure to follow the instructions below to activate your account.</p>
</div> </div>

View File

@ -1,6 +1,5 @@
{% extends 'users/settings/base.html' %} {% extends 'users/settings/base.html' %}
{% load common_extras %} {% load common_extras %}
{% load pipeline %}
{% block settings %} {% block settings %}
<p class="text-muted">Settings: Subscription</p> <p class="text-muted">Settings: Subscription</p>
@ -15,7 +14,3 @@
{% endwith %} {% endwith %}
</form> </form>
{% endblock settings %} {% endblock settings %}
{% block scripts %}
{% javascript "subscriptions" %}
{% endblock scripts %}

View File

@ -1,66 +0,0 @@
<div class="small text-muted">
<div>
<span class="fw-bold">
{% with field=form.full_name %}
{{ field.value }}
{{ field.as_hidden }}
{% endwith %}
</span>
{% with field=form.company %}
<div class="{% if not field.value %}d-none{% endif %}">
{{ field.value }}
{{ field.as_hidden }}
</div>
{% endwith %}
</div>
<div>
{% with field=form.email %}
{{ field.value }}
{{ field.as_hidden }}
{% endwith %}
</div>
<div>
{% with field=form.street_address %}
{{ field.value }}
{{ field.as_hidden }}
{% endwith %}
{% with field=form.extended_address %}
<div class="{% if not field.value %}d-none{% endif %}">
{{ field.value }}
{{ field.as_hidden }}
</div>
{% endwith %}
</div>
<div>
{% with field=form.postal_code %}
{{ field.value }}
{{ field.as_hidden }}
{% endwith %}
{% with field=form.region %}
<span class="{% if not field.value %}d-none{% endif %}">
{{ field.value }}
{{ field.as_hidden }}
</span>
{% endwith %}
{% with field=form.locality %}
{{ field.value }}
{{ field.as_hidden }}
{% endwith %}
{% with field=form.country %}
{{ field.value }}
{{ field.as_hidden }}
{% endwith %}
</div>
<div>
{% with field=form.vat_number %}
<div class="{% if not field.value %}d-none{% endif %}">
VATIN: {{ field.value }}
{{ field.as_hidden }}
</div>
{% endwith %}
</div>
</div>

View File

@ -1,23 +0,0 @@
{% load looper %}
{% load subscriptions %}
{% get_taxable current_plan_variation.price current_plan_variation.plan.product.type as current_price %}
<div>
<div class="row">
<div class="col">
<h4 class="mb-0">Renewal</h4>
</div>
<div class="col text-end">
{{ current_plan_variation.collection_method|capfirst }}, {{ current_plan_variation|recurring_pricing:current_price.price }}
</div>
</div>
<div class="row mb-3">
<div class="col fw-bold">
<h2 class="mb-0">Total</h2>
</div>
<div class="col text-end">
<h2 class="mb-0"><span>{{ current_price.price.with_currency_symbol }}</span></h2>
<p class="text-muted x-sm"><span>{{ current_price.format_tax_amount }}</span></p>
</div>
</div>
</div>

View File

@ -3,7 +3,7 @@
<table> <table>
<tbody> <tbody>
{# Owned subscriptions #} {# Owned subscriptions #}
{% for subscription in user.subscription_set.all %} {% for subscription in user.customer.subscription_set.all %}
<tr> <tr>
<td class="fw-bold">{% if subscription.team %}Team {% endif %}Subscription #{{ subscription.pk }}</td> <td class="fw-bold">{% if subscription.team %}Team {% endif %}Subscription #{{ subscription.pk }}</td>
<td>{% include "subscriptions/components/pretty_status.html" %}</td> <td>{% include "subscriptions/components/pretty_status.html" %}</td>

View File

@ -1,41 +0,0 @@
{% if form.non_field_errors %}
<div class="alert alert-sm alert-danger mt-2">
<p>Unable to proceed due to the following issue:</p>
<p>{{ form.non_field_errors }}</p>
</div>
{% endif %}
{# Payment process specific form fields, most of them a hidden and don't require special templating. #}
<div class="row">
{{ form.next_url_after_done }}
{{ form.payment_method_nonce }}
{{ form.device_data }}
{{ form.price }}
<div class="col">
<ul class="d-flooper-select ps-0" id="id_gateway">
{% with field=form.gateway %}
{% for radio in field %}
<li class="list-style-none looper-select-option looper-select-option-{{ forloop.counter0 }} mb-2">
{{ radio }}
</li>
{% endfor %}
{% endwith %}
</ul>
</div>
</div>
<div class="row">
<div class="col">
<div id="gateway-errors"></div>
</div>
</div>
{# The content of below script must be valid JSON, as type suggests. #}
<script id="bt-dropin-styles" type="application/json">
{
"input": {
"color": "black"
},
":focus": {
"color": "black"
}
}
</script>

View File

@ -43,7 +43,12 @@
<div class="row mt-3"> <div class="row mt-3">
<div class="col text-end"> <div class="col text-end">
{% if user.is_authenticated %} {% if user.is_authenticated %}
<button class="btn btn-primary w-100" id="submit-button" type="submit">{{ button_text|default:"Continue" }}</button> {% with gw=form.gateway.field.queryset.first %}
<button class="btn btn-primary w-100" id="submit-button" type="submit"
{% if gw %}name="gateway" value="{{ gw.name }}"{% endif %}>
{{ button_text|default:"Continue" }}
</button>
{% endwith %}
{% else %} {% else %}
<a class="btn btn-primary w-100 x-sign-in" href="{% url 'oauth:login' %}">Sign in with Blender ID</a> <a class="btn btn-primary w-100 x-sign-in" href="{% url 'oauth:login' %}">Sign in with Blender ID</a>
{% endif %} {% endif %}

View File

@ -6,7 +6,7 @@
{% endblock header_logo %} {% endblock header_logo %}
{% block body %} {% block body %}
<p>Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},</p> <p>Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %},</p>
{% block content %}{% endblock content %} {% block content %}{% endblock content %}
<p>Manage subscription in your billing settings: <a href="{{ billing_url }}">{{ billing_url }}</a>.</p> <p>Manage subscription in your billing settings: <a href="{{ billing_url }}">{{ billing_url }}</a>.</p>
<p> <p>

View File

@ -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 %} {% block content %}{% endblock content %}
Manage subscription in your billing settings: {{ billing_url }}. Manage subscription in your billing settings: {{ billing_url }}.

View File

@ -1,7 +1,7 @@
{% extends "subscriptions/emails/base.html" %} {% extends "subscriptions/emails/base.html" %}
{% block body %} {% block body %}
<p> <p>
{{ 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.
</p> </p>
<p> <p>

View File

@ -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. See {{ admin_url }} in the Blender Studio admin.

View File

@ -6,7 +6,7 @@
{% endblock header_logo %} {% endblock header_logo %}
{% block body %} {% block body %}
<p>Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},</p> <p>Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %},</p>
<p>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.</p> <p>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.</p>
{% if latest_posts or latest_trainings %} {% if latest_posts or latest_trainings %}
<p>Just recently, we've published:</p> <p>Just recently, we've published:</p>

View File

@ -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: 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" %} {% for post in latest_posts|slice:":2" %}

View File

@ -36,7 +36,7 @@
<div class="row"> <div class="row">
{% if messages %} {% if messages %}
{% for message in messages %} {% for message in messages %}
<p {% if message.tags %} class="alert alert-sm alert-success alert-{{ message.tags }}" {% endif %}> <p {% if message.tags %} class="alert alert-sm alert-{{ message.tags }}" {% endif %}>
{{ message }} {{ message }}
</p> </p>
{% endfor %} {% endfor %}
@ -53,6 +53,17 @@
</div> </div>
{% include 'subscriptions/components/total.html' with button_text="Continue to Payment" %} {% include 'subscriptions/components/total.html' with button_text="Continue to Payment" %}
{% with gw_bank=form.gateway.field.queryset.last %}
{% if current_plan_variation.collection_method == "manual" and gw_bank.name == "bank" %}
<div class="row mt-3">
<div class="col text-end">
<button class="btn" type="submit" name="gateway" value="bank">Pay via Bank Transfer</button>
<span data-tooltip title="{{ gw_bank.form_description|striptags }}" class="text-left"><i class="i-info text-info"></i></span>
</div>
</div>
{% endif %}
{% endwith %}
</div> </div>
{% endwith %} {% endwith %}
</form> </form>

View File

@ -1,73 +0,0 @@
{% extends 'checkout/checkout_base.html' %}
{% load common_extras %}
{% load looper %}
{% load pipeline %}
{% block content %}
<div class="mb-3">
<h2>Payment</h2>
<div class="d-flex justify-content-between">
<p class="mb-0 small">Step 3: Select a payment method.</p>
<p class=" mb-0 small">3 of 3</p>
</div>
</div>
<form id="payment-form" class="checkout-form" method="post" action="{% url 'subscriptions:join-confirm-and-pay' plan_variation_id=current_plan_variation.pk %}"
data-looper-payment-form="true" data-braintree-client-token="{{ client_token }}">
{% 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 %}
<div>
<div>
<fieldset>
{% include "subscriptions/components/payment_form.html" %}
</fieldset>
{% if GOOGLE_RECAPTCHA_SITE_KEY %}
<div id="recaptcha" class="g-recaptcha" data-sitekey="{{ GOOGLE_RECAPTCHA_SITE_KEY }}" data-size="invisible">
</div>
{% endif %}
</div>
<div class="border-bottom border-1 mb-4 pb-2">
<div class="row">
{% if messages %}
{% for message in messages %}
<p {% if message.tags %} class="alert alert-sm alert-success alert-{{ message.tags }}" {% endif %}>
{{ message }}
</p>
{% endfor %}
{% endif %}
</div>
{% include "subscriptions/components/selected_plan_info.html" with back_url=plan_url %}
<div class="row">
<div class="col-auto">
<p class="fw-bold mb-0 small">Billing:</p>
</div>
<div class="col text-end">
<fieldset>
{% include "subscriptions/components/billing_address_form_readonly.html"%}
</fieldset>
<p class="mb-0 small text-muted">(<a href="{{ billing_url }}" class="text-muted" >Change</a>)</p>
</div>
</div>
</div>
{% include 'subscriptions/components/total.html' with button_text="Confirm and Pay" %}
</div>
{% endwith %}
</form>
{% endblock content %}
{% block scripts %}
{% javascript "subscriptions" %}
{% include "looper/scripts.html" with with_recaptcha=True %}
{% endblock scripts %}

View File

@ -93,10 +93,10 @@
{% if not subscription.is_cancelled %} {% if not subscription.is_cancelled %}
<div class="col-auto"> <div class="col-auto">
<a class="small" <form method="POST" action="{% url 'subscriptions:payment-method-change' subscription_id=subscription.id %}">
href="{% url 'subscriptions:payment-method-change' subscription_id=subscription.id %}"> {% csrf_token %}
Change <button type="submit" class="small">Change</button>
</a> </form>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@ -1,52 +0,0 @@
{% extends "checkout/checkout_base_empty.html" %}
{% load looper %}
{% load pipeline %}
{% load common_extras %}
{% block content %}
<div>
<section class="checkout">
<div class="mx-auto">
<a class="float-end text-muted" href="{% url 'user-settings-billing' %}">Back to subscription settings</a>
<div class="alert alert-sm">
Your {% include "subscriptions/components/info_with_status.html" %}. It will be activated as soon as the outstanding amount is paid
</div>
<div>
<h2>Paying for Order #{{ order.display_number }}</h2>
<div class="d-flex justify-content-between">
<p class="mb-0 small">billed on {{ order.created_at|date }}</p>
<p class="mb-0 small">{{ order.price.with_currency_symbol }}</p>
</div>
</div>
<form class="checkout-form" id="payment-form" method="post"
data-looper-payment-form="true"
data-braintree-client-token="{{ client_token }}">{% csrf_token %}
<div>
<div>
<section class="checkout-form-fields mb-n2">
{% with form|add_form_classes as form %}
<a class="text-muted float-end small" href="{% url 'subscriptions:billing-address' %}">Change billing details</a>
<fieldset class="mb-2">
{% include "subscriptions/components/billing_address_form_readonly.html" %}
</fieldset>
<fieldset>
{% include "subscriptions/components/payment_form.html" %}
</fieldset>
{% endwith %}
</section>
</div>
{# will be enabled when payment gateways are initialized successfully #}
<div class="m-2">
<button id="submit-button" class="btn btn-block btn-lg btn-success" type="submit" aria-disabled="true" disabled>
Pay {{ order.price.with_currency_symbol }}
</button>
</div>
</div>
</form>
</div>
</section>
</div>
{% javascript "subscriptions" %}
{% include "looper/scripts.html" %}
{% endblock %}

View File

@ -1,63 +0,0 @@
{% extends "settings/base.html" %}
{% load common_extras %}
{% load pipeline %}
{% block settings %}
<p class="subtitle">Settings: Subscription #{{ subscription.pk }}</p>
<h1 class="mb-3">Change Payment Method</h1>
<div class="settings-billing">
<div>
<div class="alert alert-{% if subscription.status == 'active' %}primary{% else %}warning{% endif %}" role="alert">
<span>
Your {{ subscription.plan.name }} subscription is currently
<span class="fw-bolder">{{ subscription.get_status_display|lower }}</span>.
</span>
</div>
{% if current_payment_method %}
<p>
<strong>{{ current_payment_method.recognisable_name }}</strong> is used as payment method.
Feel free to change it below.
</p>
{% else %}
<div class="alert alert-danger" role="alert">
<i class="material-icons icon-inline">warning_amber</i>
You subscription is using an unsupported payment method,
please use the form below to change it.
</div>
{% endif %}
</div>
<form id="payment-form" class="checkout-form" method="post"
data-looper-payment-form="true"
data-braintree-client-token="{{ client_token }}">{% csrf_token %}
{% with form|add_form_classes as form %}
<section>
<a class="float-end text-muted" href="{% url 'subscriptions:billing-address' %}"><i class="fa fa-angle-left pe-3"></i>change billing details</a>
<fieldset class="checkout-form-billing-address-readonly">
{% include "subscriptions/components/billing_address_form_readonly.html" %}
</fieldset>
<fieldset>
{% include "subscriptions/components/payment_form.html" %}
</fieldset>
</section>
<div class="row mt-3">
<div class="col">
<a class="btn" href="{% url 'subscriptions:manage' subscription_id=subscription.pk %}">Cancel</a>
</div>
<div class="col-auto">
<button id="submit-button" class="btn btn-success" type="submit" aria-disabled="true" disabled>
<i class="fa fa-check"></i>
Switch Payment method
</button>
</div>
</div>
{% endwith %}
</form>
</div>
{% endblock settings %}
{% block scripts %}
{{ block.super }}
{% javascript "subscriptions" %}
{% include "looper/scripts.html" %}
{% endblock scripts %}

View File

@ -5,7 +5,6 @@ from django import template
from looper.models import PlanVariation, Subscription from looper.models import PlanVariation, Subscription
import looper.money import looper.money
import looper.taxes
import subscriptions.queries import subscriptions.queries

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
responses:
- response:
auto_calculate_content_length: false
body: <env:Envelope xmlns:env="http://schemas.xmlsoap.org/soap/envelope/"><env:Header/><env:Body><ns2:checkVatResponse
xmlns:ns2="urn:ec.europa.eu:taxud:vies:services:checkVat:types"><ns2:countryCode>DE</ns2:countryCode><ns2:vatNumber>260543043</ns2:vatNumber><ns2:requestDate>2024-06-14+02:00</ns2:requestDate><ns2:valid>true</ns2:valid><ns2:name>---</ns2:name><ns2:address>---</ns2:address></ns2:checkVatResponse></env:Body></env:Envelope>
content_type: text/plain
method: POST
status: 200
url: https://ec.europa.eu/taxation_customs/vies/services/checkVatService

View File

@ -0,0 +1,106 @@
responses:
- response:
auto_calculate_content_length: false
body: "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<wsdl:definitions targetNamespace=\"\
urn:ec.europa.eu:taxud:vies:services:checkVat\" xmlns:tns1=\"urn:ec.europa.eu:taxud:vies:services:checkVat:types\"\
\ xmlns:soapenc=\"http://schemas.xmlsoap.org/soap/encoding/\" xmlns:impl=\"\
urn:ec.europa.eu:taxud:vies:services:checkVat\" xmlns:apachesoap=\"http://xml.apache.org/xml-soap\"\
\ xmlns:wsdl=\"http://schemas.xmlsoap.org/wsdl/\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\"\
\ xmlns:wsdlsoap=\"http://schemas.xmlsoap.org/wsdl/soap/\">\n <xsd:documentation>\n\
\ blah blah blah time period, try again later. \n\t</xsd:documentation>\n\
\ \n <wsdl:types>\n <xsd:schema attributeFormDefault=\"qualified\" elementFormDefault=\"\
qualified\" targetNamespace=\"urn:ec.europa.eu:taxud:vies:services:checkVat:types\"\
\ xmlns=\"urn:ec.europa.eu:taxud:vies:services:checkVat:types\">\n\t\t\t<xsd:element\
\ name=\"checkVat\">\n\t\t\t\t<xsd:complexType>\n\t\t\t\t\t<xsd:sequence>\n\t\
\t\t\t\t\t<xsd:element name=\"countryCode\" type=\"xsd:string\"/>\n\t\t\t\t\t\
\t<xsd:element name=\"vatNumber\" type=\"xsd:string\"/>\n\t\t\t\t\t</xsd:sequence>\n\
\t\t\t\t</xsd:complexType>\n\t\t\t</xsd:element>\n\t\t\t<xsd:element name=\"\
checkVatResponse\">\n\t\t\t\t<xsd:complexType>\n\t\t\t\t\t<xsd:sequence>\n\t\
\t\t\t\t\t<xsd:element name=\"countryCode\" type=\"xsd:string\"/>\n\t\t\t\t\t\
\t<xsd:element name=\"vatNumber\" type=\"xsd:string\"/>\n\t\t\t\t\t\t<xsd:element\
\ name=\"requestDate\" type=\"xsd:date\"/>\n\t\t\t\t\t\t<xsd:element name=\"\
valid\" type=\"xsd:boolean\"/>\n\t\t\t\t\t\t<xsd:element maxOccurs=\"1\" minOccurs=\"\
0\" name=\"name\" nillable=\"true\" type=\"xsd:string\"/>\n\t\t\t\t\t\t<xsd:element\
\ maxOccurs=\"1\" minOccurs=\"0\" name=\"address\" nillable=\"true\" type=\"\
xsd:string\"/>\n\t\t\t\t\t</xsd:sequence>\n\t\t\t\t</xsd:complexType>\n\t\t\t\
</xsd:element>\n\t\t\t<xsd:element name=\"checkVatApprox\">\n\t\t\t\t<xsd:complexType>\n\
\t\t\t\t\t<xsd:sequence>\n\t\t\t\t\t\t<xsd:element name=\"countryCode\" type=\"\
xsd:string\"/>\n\t\t\t\t\t\t<xsd:element name=\"vatNumber\" type=\"xsd:string\"\
/>\n\t\t\t\t\t\t<xsd:element maxOccurs=\"1\" minOccurs=\"0\" name=\"traderName\"\
\ type=\"xsd:string\"/>\n\t\t\t\t\t\t<xsd:element maxOccurs=\"1\" minOccurs=\"\
0\" name=\"traderCompanyType\" type=\"tns1:companyTypeCode\"/>\n\t\t\t\t\t\t\
<xsd:element maxOccurs=\"1\" minOccurs=\"0\" name=\"traderStreet\" type=\"xsd:string\"\
/>\n\t\t\t\t\t\t<xsd:element maxOccurs=\"1\" minOccurs=\"0\" name=\"traderPostcode\"\
\ type=\"xsd:string\"/>\n\t\t\t\t\t\t<xsd:element maxOccurs=\"1\" minOccurs=\"\
0\" name=\"traderCity\" type=\"xsd:string\"/>\n\t\t\t\t\t\t<xsd:element maxOccurs=\"\
1\" minOccurs=\"0\" name=\"requesterCountryCode\" type=\"xsd:string\"/>\n\t\t\
\t\t\t\t<xsd:element maxOccurs=\"1\" minOccurs=\"0\" name=\"requesterVatNumber\"\
\ type=\"xsd:string\"/>\n\t\t\t\t\t</xsd:sequence>\n\t\t\t\t</xsd:complexType>\n\
\t\t\t</xsd:element>\n\t\t\t<xsd:element name=\"checkVatApproxResponse\">\n\t\
\t\t\t<xsd:complexType>\n\t\t\t\t\t<xsd:sequence>\n\t\t\t\t\t\t<xsd:element\
\ name=\"countryCode\" type=\"xsd:string\"/>\n\t\t\t\t\t\t<xsd:element name=\"\
vatNumber\" type=\"xsd:string\"/>\n\t\t\t\t\t\t<xsd:element name=\"requestDate\"\
\ type=\"xsd:date\"/>\n\t\t\t\t\t\t<xsd:element name=\"valid\" type=\"xsd:boolean\"\
/>\n\t\t\t\t\t\t<xsd:element maxOccurs=\"1\" minOccurs=\"0\" name=\"traderName\"\
\ nillable=\"true\" type=\"xsd:string\"/>\n\t\t\t\t\t\t<xsd:element maxOccurs=\"\
1\" minOccurs=\"0\" name=\"traderCompanyType\" nillable=\"true\" type=\"tns1:companyTypeCode\"\
/>\n\t\t\t\t\t\t<xsd:element maxOccurs=\"1\" minOccurs=\"0\" name=\"traderAddress\"\
\ type=\"xsd:string\"/>\n\t\t\t\t\t\t<xsd:element maxOccurs=\"1\" minOccurs=\"\
0\" name=\"traderStreet\" type=\"xsd:string\"/>\n\t\t\t\t\t\t<xsd:element maxOccurs=\"\
1\" minOccurs=\"0\" name=\"traderPostcode\" type=\"xsd:string\"/>\n\t\t\t\t\t\
\t<xsd:element maxOccurs=\"1\" minOccurs=\"0\" name=\"traderCity\" type=\"xsd:string\"\
/>\n\t\t\t\t\t\t<xsd:element maxOccurs=\"1\" minOccurs=\"0\" name=\"traderNameMatch\"\
\ type=\"tns1:matchCode\"/>\n\t\t\t\t\t\t<xsd:element maxOccurs=\"1\" minOccurs=\"\
0\" name=\"traderCompanyTypeMatch\" type=\"tns1:matchCode\"/>\n\t\t\t\t\t\t\
<xsd:element maxOccurs=\"1\" minOccurs=\"0\" name=\"traderStreetMatch\" type=\"\
tns1:matchCode\"/>\n\t\t\t\t\t\t<xsd:element maxOccurs=\"1\" minOccurs=\"0\"\
\ name=\"traderPostcodeMatch\" type=\"tns1:matchCode\"/>\n\t\t\t\t\t\t<xsd:element\
\ maxOccurs=\"1\" minOccurs=\"0\" name=\"traderCityMatch\" type=\"tns1:matchCode\"\
/>\n\t\t\t\t\t\t<xsd:element name=\"requestIdentifier\" type=\"xsd:string\"\
/>\n\t\t\t\t\t</xsd:sequence>\n\t\t\t\t</xsd:complexType>\n\t\t\t</xsd:element>\n\
\t\t\t<xsd:simpleType name=\"companyTypeCode\">\n\t\t\t\t<xsd:restriction base=\"\
xsd:string\">\n\t\t\t\t\t<xsd:pattern value=\"[A-Z]{2}\\-[1-9][0-9]?\"/>\n\t\
\t\t\t</xsd:restriction>\n\t\t\t</xsd:simpleType>\n\t\t\t<xsd:simpleType name=\"\
matchCode\">\n\t\t\t\t<xsd:restriction base=\"xsd:string\">\n\t\t\t\t\t<xsd:enumeration\
\ value=\"1\">\n\t\t\t\t\t\t<xsd:annotation>\n\t\t\t\t\t\t\t<xsd:documentation>VALID</xsd:documentation>\n\
\t\t\t\t\t\t</xsd:annotation>\n\t\t\t\t\t</xsd:enumeration>\n\t\t\t\t\t<xsd:enumeration\
\ value=\"2\">\n <xsd:annotation>\n \
\ <xsd:documentation>INVALID</xsd:documentation>\n \
\ </xsd:annotation>\n </xsd:enumeration>\n \
\ <xsd:enumeration value=\"3\">\n <xsd:annotation>\n\
\ <xsd:documentation>NOT_PROCESSED</xsd:documentation>\n\
\ </xsd:annotation>\n </xsd:enumeration>\n\
\t\t\t\t</xsd:restriction>\n\t\t\t</xsd:simpleType>\n\t\t</xsd:schema>\n </wsdl:types>\n\
\ <wsdl:message name=\"checkVatRequest\">\n <wsdl:part name=\"parameters\"\
\ element=\"tns1:checkVat\">\n </wsdl:part>\n </wsdl:message>\n <wsdl:message\
\ name=\"checkVatApproxResponse\">\n <wsdl:part name=\"parameters\" element=\"\
tns1:checkVatApproxResponse\">\n </wsdl:part>\n </wsdl:message>\n <wsdl:message\
\ name=\"checkVatApproxRequest\">\n <wsdl:part name=\"parameters\" element=\"\
tns1:checkVatApprox\">\n </wsdl:part>\n </wsdl:message>\n <wsdl:message\
\ name=\"checkVatResponse\">\n <wsdl:part name=\"parameters\" element=\"\
tns1:checkVatResponse\">\n </wsdl:part>\n </wsdl:message>\n <wsdl:portType\
\ name=\"checkVatPortType\">\n <wsdl:operation name=\"checkVat\">\n \
\ <wsdl:input name=\"checkVatRequest\" message=\"impl:checkVatRequest\">\n \
\ </wsdl:input>\n <wsdl:output name=\"checkVatResponse\" message=\"impl:checkVatResponse\"\
>\n </wsdl:output>\n </wsdl:operation>\n <wsdl:operation name=\"checkVatApprox\"\
>\n <wsdl:input name=\"checkVatApproxRequest\" message=\"impl:checkVatApproxRequest\"\
>\n </wsdl:input>\n <wsdl:output name=\"checkVatApproxResponse\" message=\"\
impl:checkVatApproxResponse\">\n </wsdl:output>\n </wsdl:operation>\n\
\ </wsdl:portType>\n <wsdl:binding name=\"checkVatBinding\" type=\"impl:checkVatPortType\"\
>\n <wsdlsoap:binding style=\"document\" transport=\"http://schemas.xmlsoap.org/soap/http\"\
/>\n <wsdl:operation name=\"checkVat\">\n <wsdlsoap:operation soapAction=\"\
\"/>\n <wsdl:input name=\"checkVatRequest\">\n <wsdlsoap:body use=\"\
literal\"/>\n </wsdl:input>\n <wsdl:output name=\"checkVatResponse\"\
>\n <wsdlsoap:body use=\"literal\"/>\n </wsdl:output>\n </wsdl:operation>\n\
\ <wsdl:operation name=\"checkVatApprox\">\n <wsdlsoap:operation soapAction=\"\
\"/>\n <wsdl:input name=\"checkVatApproxRequest\">\n <wsdlsoap:body\
\ use=\"literal\"/>\n </wsdl:input>\n <wsdl:output name=\"checkVatApproxResponse\"\
>\n <wsdlsoap:body use=\"literal\"/>\n </wsdl:output>\n </wsdl:operation>\n\
\ </wsdl:binding>\n <wsdl:service name=\"checkVatService\">\n <wsdl:port\
\ name=\"checkVatPort\" binding=\"impl:checkVatBinding\">\n <wsdlsoap:address\
\ location=\"http://ec.europa.eu/taxation_customs/vies/services/checkVatService\"\
/>\n </wsdl:port>\n </wsdl:service>\n</wsdl:definitions>\n"
content_type: text/plain
method: GET
status: 200
url: https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl

View File

@ -1,6 +1,6 @@
from typing import Tuple
import os import os
from django.contrib.auth import get_user_model
from django.core import mail from django.core import mail
from django.db.models import signals from django.db.models import signals
from django.test import TestCase from django.test import TestCase
@ -8,10 +8,12 @@ from django.urls import reverse
import factory import factory
import responses 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 import users.tests.util as util
User = get_user_model() responses_dir = 'subscriptions/tests/_responses/'
def _write_mail(mail, index=0): def _write_mail(mail, index=0):
@ -24,16 +26,41 @@ def _write_mail(mail, index=0):
f.write(str(content)) 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): 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) @factory.django.mute_signals(signals.pre_save, signals.post_save)
def setUp(self): def setUp(self):
super().setUp()
# Allow requests to Braintree Sandbox # Allow requests to Braintree Sandbox
responses.add_passthru('https://api.sandbox.braintreegateway.com:443/') responses.add_passthru('https://api.sandbox.braintreegateway.com:443/')
# Create the admin user used for logging # Create the admin user used for logging
self.admin_user = util.create_admin_log_user() self.admin_user = util.create_admin_log_user()
self.user = create_customer_with_billing_address( self.customer = create_customer_with_billing_address(
full_name='Алексей Н.', full_name='Алексей Н.',
company='Testcompany B.V.', company='Testcompany B.V.',
street_address='Billing street 1', street_address='Billing street 1',
@ -43,11 +70,18 @@ class BaseSubscriptionTestCase(TestCase):
region='North Holland', region='North Holland',
country='NL', country='NL',
vat_number='NL-KVK-41202535', 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 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): def _mock_vies_response(self, is_valid=True, is_broken=False):
path = os.path.abspath(__file__) path = os.path.abspath(__file__)
dir_path = os.path.join(os.path.dirname(path), 'vies') 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._assert_continue_to_payment_displayed(response)
self.assertContains(response, 'id_street_address') self.assertContains(response, 'id_street_address')
self.assertContains(response, 'id_full_name') self.assertContains(response, 'id_full_name')
self.assertContains(response, 'name="gateway" value="stripe"')
def _assert_payment_form_displayed(self, response): def _assert_pay_via_bank_not_displayed(self, response):
self.assertNotContains(response, 'Pricing has been updated') self.assertNotContains(response, 'name="gateway" value="bank"')
self.assertNotContains(response, 'Continue to Payment')
self.assertContains(response, 'payment method') def _assert_pay_via_bank_displayed(self, response):
self.assertContains(response, 'Confirm and Pay') self.assertContains(response, 'name="gateway" value="bank"')
def _assert_pricing_has_been_updated(self, response): def _assert_pricing_has_been_updated(self, response):
self.assertContains(response, 'Pricing has been updated') self.assertContains(response, 'Pricing has been updated')
@ -319,7 +354,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertContains(response, '<h2 class="h3">Bank details:</h2>', html=True) self.assertContains(response, '<h2 class="h3">Bank details:</h2>', html=True)
self.assertContains(response, 'on hold') self.assertContains(response, 'on hold')
self.assertContains(response, 'NL07 INGB 0008 4489 82') 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( self.assertContains(
response, f'Blender Studio order-{subscription.latest_order().display_number}' response, f'Blender Studio order-{subscription.latest_order().display_number}'
) )
@ -341,11 +376,11 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(len(mail.outbox), 0) self.assertEqual(len(mail.outbox), 0)
def _assert_bank_transfer_email_is_sent(self, subscription): def _assert_bank_transfer_email_is_sent(self, subscription):
user = subscription.user customer = subscription.customer
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
_write_mail(mail) _write_mail(mail)
email = mail.outbox[0] 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 # TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, []) self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL # 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(' ', ' ')) self.assertIn('Recurring total: €\xa026.45', email_body.replace(' ', ' '))
def _assert_subscription_activated_email_is_sent(self, subscription): def _assert_subscription_activated_email_is_sent(self, subscription):
user = subscription.user customer = subscription.customer
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
_write_mail(mail) _write_mail(mail)
email = mail.outbox[0] 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 # TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, []) self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL # 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') self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]): for email_body in (email.body, email.alternatives[0][0]):
self.assertIn('activated', email_body) 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(reverse('user-settings-billing'), email_body)
self.assertIn('Automatic renewal subscription', email_body) self.assertIn('Automatic renewal subscription', email_body)
self.assertIn('Blender Studio Team', email_body) self.assertIn('Blender Studio Team', email_body)
def _assert_team_subscription_activated_email_is_sent(self, subscription): def _assert_team_subscription_activated_email_is_sent(self, subscription):
user = subscription.user customer = subscription.customer
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
_write_mail(mail) _write_mail(mail)
email = mail.outbox[0] 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 # TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, []) self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL # 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') self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]): for email_body in (email.body, email.alternatives[0][0]):
self.assertIn('activated', email_body) 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(reverse('user-settings-billing'), email_body)
self.assertIn('Automatic renewal, 15 seats subscription', email_body) self.assertIn('Automatic renewal, 15 seats subscription', email_body)
self.assertIn('Blender Studio Team', email_body) self.assertIn('Blender Studio Team', email_body)
def _assert_subscription_deactivated_email_is_sent(self, subscription): def _assert_subscription_deactivated_email_is_sent(self, subscription):
user = subscription.user customer = subscription.customer
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
_write_mail(mail) _write_mail(mail)
email = mail.outbox[0] 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 # TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, []) self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL # TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -440,11 +475,12 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Blender Studio Team', email_body) self.assertIn('Blender Studio Team', email_body)
def _assert_payment_soft_failed_email_is_sent(self, subscription): 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) self.assertEqual(len(mail.outbox), 1)
_write_mail(mail) _write_mail(mail)
email = mail.outbox[0] 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 # TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, []) self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL # 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') self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]): 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('Automatic payment', email_body)
self.assertIn('failed', email_body) self.assertIn('failed', email_body)
self.assertIn('try again', email_body) self.assertIn('try again', email_body)
@ -470,11 +506,12 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Blender Studio Team', email_body) self.assertIn('Blender Studio Team', email_body)
def _assert_payment_failed_email_is_sent(self, subscription): def _assert_payment_failed_email_is_sent(self, subscription):
user = subscription.user customer = subscription.customer
user = customer.user
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
_write_mail(mail) _write_mail(mail)
email = mail.outbox[0] 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 # TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, []) self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL # 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.subject, 'Blender Studio Subscription: payment failed')
self.assertEqual(email.alternatives[0][1], 'text/html') self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]): 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('Automatic payment', email_body)
self.assertIn('failed', email_body) self.assertIn('failed', email_body)
self.assertIn('3 times', email_body) self.assertIn('3 times', email_body)
@ -497,11 +534,12 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Blender Studio Team', email_body) self.assertIn('Blender Studio Team', email_body)
def _assert_payment_paid_email_is_sent(self, subscription): def _assert_payment_paid_email_is_sent(self, subscription):
user = subscription.user customer = subscription.customer
user = customer.user
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
_write_mail(mail) _write_mail(mail)
email = mail.outbox[0] 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 # TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, []) self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL # 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.subject, 'Blender Studio Subscription: payment received')
self.assertEqual(email.alternatives[0][1], 'text/html') self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]): 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('Automatic monthly payment', email_body)
self.assertIn('successful', email_body) self.assertIn('successful', email_body)
self.assertIn('$\xa011.10', email_body) self.assertIn('$\xa011.10', email_body)
@ -523,7 +561,8 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Blender Studio Team', email_body) self.assertIn('Blender Studio Team', email_body)
def _assert_managed_subscription_notification_email_is_sent(self, subscription): 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) self.assertEqual(len(mail.outbox), 1)
_write_mail(mail) _write_mail(mail)
email = mail.outbox[0] email = mail.outbox[0]
@ -533,7 +572,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.subject, 'Blender Studio managed subscription needs attention') self.assertEqual(email.subject, 'Blender Studio managed subscription needs attention')
self.assertEqual(email.alternatives[0][1], 'text/html') self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]): 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('its next payment date', email_body)
self.assertIn('$\xa011.10', email_body) self.assertIn('$\xa011.10', email_body)
self.assertIn( self.assertIn(
@ -542,16 +581,17 @@ class BaseSubscriptionTestCase(TestCase):
) )
def _assert_subscription_expired_email_is_sent(self, subscription): def _assert_subscription_expired_email_is_sent(self, subscription):
user = subscription.user customer = subscription.customer
user = customer.user
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
_write_mail(mail) _write_mail(mail)
email = mail.outbox[0] 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.from_email, 'webmaster@localhost')
self.assertEqual(email.subject, 'We miss you at Blender Studio') self.assertEqual(email.subject, 'We miss you at Blender Studio')
self.assertEqual(email.alternatives[0][1], 'text/html') self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]): 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(f'#{subscription.pk}', email_body)
self.assertIn('has expired', email_body) self.assertIn('has expired', email_body)
self.assertIn( self.assertIn(

View File

@ -11,27 +11,26 @@ from looper import admin_log
from looper.clock import Clock from looper.clock import Clock
from looper.models import Gateway, Subscription from looper.models import Gateway, Subscription
from looper.money import Money 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 from subscriptions.tests.base import BaseSubscriptionTestCase
import subscriptions.tasks import subscriptions.tasks
import users.tasks import users.tasks
import users.tests.util as util 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: 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() now = timezone.now()
with mock.patch('django.utils.timezone.now') as mock_now: with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = now + relativedelta(months=-1) mock_now.return_value = now + relativedelta(months=-1)
# print('fake now:', mock_now.return_value) # print('fake now:', mock_now.return_value)
subscription = SubscriptionFactory( subscription = SubscriptionFactory(
user=user, customer=customer,
payment_method__user_id=user.pk, payment_method__customer_id=customer.pk,
payment_method__recognisable_name='Test payment method', payment_method__recognisable_name='Test payment method',
payment_method__gateway=Gateway.objects.get(name='braintree'), payment_method__gateway=Gateway.objects.get(name='braintree'),
currency='USD', currency='USD',
@ -52,6 +51,9 @@ class TestClock(BaseSubscriptionTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
# Allow requests to Braintree Sandbox
responses.add_passthru('https://api.sandbox.braintreegateway.com:443/')
self.subscription = self._create_subscription_due_now() self.subscription = self._create_subscription_due_now()
@patch( @patch(
@ -114,7 +116,7 @@ class TestClock(BaseSubscriptionTestCase):
# Tick the clock and check that order and transaction were created # Tick the clock and check that order and transaction were created
util.mock_blender_id_badger_badger_response( 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() Clock().tick()
@ -167,7 +169,7 @@ class TestClock(BaseSubscriptionTestCase):
# Create another active subscription for the same user # Create another active subscription for the same user
SubscriptionFactory( SubscriptionFactory(
user=self.subscription.user, customer=self.subscription.customer,
payment_method=self.subscription.payment_method, payment_method=self.subscription.payment_method,
currency='USD', currency='USD',
price=Money('USD', 1110), price=Money('USD', 1110),
@ -190,10 +192,12 @@ class TestClock(BaseSubscriptionTestCase):
) )
def test_automated_payment_paid_email_is_sent(self): def test_automated_payment_paid_email_is_sent(self):
now = timezone.now() now = timezone.now()
self.assertEqual(self.subscription.collection_method, 'automatic')
# Tick the clock and check that subscription renews, order and transaction were created # Tick the clock and check that subscription renews, order and transaction were created
with patch( 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() Clock().tick()
@ -254,9 +258,9 @@ class TestClock(BaseSubscriptionTestCase):
class TestClockExpiredSubscription(BaseSubscriptionTestCase): class TestClockExpiredSubscription(BaseSubscriptionTestCase):
def test_subscription_on_hold_not_long_enough(self): def test_subscription_on_hold_not_long_enough(self):
now = timezone.now() 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( self.subscription = SubscriptionFactory(
user=user, customer=customer,
status='on-hold', status='on-hold',
# payment date has passed, but not long enough ago # payment date has passed, but not long enough ago
next_payment=now - timedelta(weeks=4), next_payment=now - timedelta(weeks=4),
@ -283,15 +287,16 @@ class TestClockExpiredSubscription(BaseSubscriptionTestCase):
@responses.activate @responses.activate
def test_subscription_on_hold_too_long_status_changed_to_expired_email_sent(self): def test_subscription_on_hold_too_long_status_changed_to_expired_email_sent(self):
now = timezone.now() 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( self.subscription = SubscriptionFactory(
user=user, customer=customer,
status='on-hold', status='on-hold',
# payment date has passed a long long time ago # payment date has passed a long long time ago
next_payment=now - timedelta(weeks=4 * 10), next_payment=now - timedelta(weeks=4 * 10),
) )
util.mock_blender_id_badger_badger_response( 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() Clock().tick()

View File

@ -21,7 +21,6 @@ class TestBillingAddressForm(BaseSubscriptionTestCase):
def test_instance_loads_both_address_and_customer_data(self): def test_instance_loads_both_address_and_customer_data(self):
form = BillingAddressForm(instance=self.billing_address) 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['email'].value(), 'billing@example.com')
self.assertEqual(form['company'].value(), 'Testcompany B.V.') self.assertEqual(form['company'].value(), 'Testcompany B.V.')
self.assertEqual(form['country'].value(), 'NL') self.assertEqual(form['country'].value(), 'NL')
@ -215,15 +214,12 @@ class TestBillingAddressForm(BaseSubscriptionTestCase):
class TestPaymentForm(BaseSubscriptionTestCase): class TestPaymentForm(BaseSubscriptionTestCase):
required_payment_form_data = { required_payment_form_data = {
'gateway': 'bank', 'gateway': 'bank',
'payment_method_nonce': 'fake-nonce',
'plan_variation_id': 1, 'plan_variation_id': 1,
'price': '9.90',
} }
def test_instance_loads_both_address_and_customer_data(self): def test_instance_loads_both_address_and_customer_data(self):
form = PaymentForm(instance=self.billing_address) 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['email'].value(), 'billing@example.com')
self.assertEqual(form['company'].value(), 'Testcompany B.V.') self.assertEqual(form['company'].value(), 'Testcompany B.V.')
self.assertEqual(form['country'].value(), 'NL') self.assertEqual(form['country'].value(), 'NL')
@ -246,8 +242,6 @@ class TestPaymentForm(BaseSubscriptionTestCase):
'email': ['This field is required.'], 'email': ['This field is required.'],
'full_name': ['This field is required.'], 'full_name': ['This field is required.'],
'gateway': ['This field is required.'], 'gateway': ['This field is required.'],
'payment_method_nonce': ['This field is required.'],
'price': ['This field is required.'],
}, },
) )

View File

@ -1,7 +1,9 @@
from django.test import TestCase from django.test import TestCase
from looper.tests.factories import SubscriptionFactory
from common.tests.factories.users import UserFactory 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 import looper.models
@ -25,12 +27,12 @@ class TestHasActiveSubscription(TestCase):
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], 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): def test_false_when_subscription_inactive(self):
subscription = SubscriptionFactory(plan_id=1) 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): def test_false_when_team_subscription_inactive(self):
team = TeamFactory(subscription__plan_id=1) team = TeamFactory(subscription__plan_id=1)
@ -60,17 +62,17 @@ class TestHasNotYetCancelledSubscription(TestCase):
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], 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): def test_false_when_subscription_cancelled(self):
subscription = SubscriptionFactory(plan_id=1, status='cancelled') 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): def test_true_when_subscription_inactive(self):
subscription = SubscriptionFactory(plan_id=1) 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): def test_false_when_team_subscription_inactive(self):
team = TeamFactory(subscription__plan_id=1) team = TeamFactory(subscription__plan_id=1)
@ -103,7 +105,7 @@ class TestHasNotYetCancelledSubscription(TestCase):
) )
team.team_users.create(user=UserFactory()) team.team_users.create(user=UserFactory())
SubscriptionFactory( SubscriptionFactory(
user=team.team_users.first().user, customer=team.team_users.first().user.customer,
plan_id=1, plan_id=1,
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
) )
@ -117,7 +119,7 @@ class TestHasNotYetCancelledSubscription(TestCase):
) )
team.team_users.create(user=UserFactory()) team.team_users.create(user=UserFactory())
SubscriptionFactory( SubscriptionFactory(
user=team.team_users.first().user, customer=team.team_users.first().user.customer,
plan_id=1, plan_id=1,
status='cancelled', status='cancelled',
) )
@ -137,12 +139,12 @@ class TestHasSubscription(TestCase):
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0], 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): def test_true_when_subscription_inactive(self):
subscription = SubscriptionFactory(plan_id=1) 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): def test_true_when_team_subscription_inactive(self):
team = TeamFactory(subscription__plan_id=1) team = TeamFactory(subscription__plan_id=1)
@ -166,12 +168,12 @@ class TestHasSubscription(TestCase):
is_legacy=True, 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): def test_true_when_subscription_inactive_and_is_legacy(self):
subscription = SubscriptionFactory(plan_id=1, is_legacy=True) 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): def test_true_when_team_subscription_inactive_and_is_legacy(self):
team = TeamFactory(subscription__plan_id=1, subscription__is_legacy=True) 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], 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): def test_true_when_subscription_inactive_and_not_is_legacy(self):
subscription = SubscriptionFactory(plan_id=1) 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): def test_true_when_team_subscription_inactive_and_not_is_legacy(self):
team = TeamFactory(subscription__plan_id=1) team = TeamFactory(subscription__plan_id=1)
@ -208,7 +210,7 @@ class TestHasNonLegacySubscription(TestCase):
def test_false_when_subscription_inactive_and_is_legacy(self): def test_false_when_subscription_inactive_and_is_legacy(self):
subscription = SubscriptionFactory(plan_id=1, is_legacy=True) 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): def test_false_when_subscription_active_and_is_legacy(self):
subscription = SubscriptionFactory( subscription = SubscriptionFactory(
@ -217,7 +219,7 @@ class TestHasNonLegacySubscription(TestCase):
is_legacy=True, 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): def test_false_when_team_subscription_inactive_and_is_legacy(self):
team = TeamFactory(subscription__plan_id=1, subscription__is_legacy=True) team = TeamFactory(subscription__plan_id=1, subscription__is_legacy=True)

View File

@ -2,7 +2,7 @@ from django.urls import path, re_path
from looper.views import settings as looper_settings 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 ( from subscriptions.views.select_plan_variation import (
SelectPlanVariationView, SelectPlanVariationView,
SelectTeamPlanVariationView, SelectTeamPlanVariationView,
@ -26,14 +26,9 @@ urlpatterns = [
), ),
path( path(
'join/plan-variation/<int:plan_variation_id>/billing/', 'join/plan-variation/<int:plan_variation_id>/billing/',
BillingDetailsView.as_view(), JoinView.as_view(),
name='join-billing-details', name='join-billing-details',
), ),
path(
'join/plan-variation/<int:plan_variation_id>/confirm/',
ConfirmAndPayView.as_view(),
name='join-confirm-and-pay',
),
path( path(
'subscription/<int:subscription_id>/manage/', 'subscription/<int:subscription_id>/manage/',
settings.ManageSubscriptionView.as_view(), settings.ManageSubscriptionView.as_view(),
@ -49,15 +44,18 @@ urlpatterns = [
settings.PaymentMethodChangeView.as_view(), settings.PaymentMethodChangeView.as_view(),
name='payment-method-change', name='payment-method-change',
), ),
path(
'subscription/<int:subscription_id>/payment-method/change/<stripe_session_id>/',
settings.PaymentMethodChangeDoneView.as_view(),
name='payment-method-change-done',
),
path( path(
'subscription/order/<int:order_id>/pay/', 'subscription/order/<int:order_id>/pay/',
settings.PayExistingOrderView.as_view(), settings.PayExistingOrderView.as_view(),
name='pay-existing-order', name='pay-existing-order',
), ),
path( path(
'settings/billing-address/', 'settings/billing-address/', settings.BillingAddressView.as_view(), name='billing-address'
settings.BillingAddressView.as_view(),
name='billing-address',
), ),
path('settings/receipts/', looper_settings.settings_receipts, name='receipts'), path('settings/receipts/', looper_settings.settings_receipts, name='receipts'),
path( path(

View File

@ -1,23 +1,21 @@
"""Views handling subscription management.""" """Views handling subscription management."""
from decimal import Decimal
import logging import logging
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction from django.db import transaction
from django.shortcuts import redirect, get_object_or_404 from django.shortcuts import redirect, get_object_or_404
from django.urls import reverse
from django.views.generic import FormView 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.gateways
import looper.middleware import looper.middleware
import looper.models import looper.models
import looper.money import looper.money
import looper.taxes 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.middleware import preferred_currency_for_country_code
from subscriptions.queries import should_redirect_to_billing from subscriptions.queries import should_redirect_to_billing
from subscriptions.signals import subscription_created_needs_payment from subscriptions.signals import subscription_created_needs_payment
@ -27,111 +25,145 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
class _JoinMixin: class JoinView(LoginRequiredMixin, FormView):
customer: looper.models.Customer """Fill in billing details and initiate Stripe checkout session."""
# FIXME(anna): this view uses some functionality of AbstractPaymentView, # FIXME(anna): this view uses some functionality of CheckoutStripeView,
# but cannot directly inherit from them, since JoinView supports creating only one subscription. # but cannot directly inherit it, since JoinView supports creating only one subscription.
get_currency = AbstractPaymentView.get_currency _fetch_or_create_order = CheckoutStripeView._fetch_or_create_order
get_client_token = AbstractPaymentView.get_client_token
client_token_session_key = AbstractPaymentView.client_token_session_key
erase_client_token = AbstractPaymentView.erase_client_token
@property template_name = 'subscriptions/join/billing_address.html'
def session_key_prefix(self) -> str: form_class = PaymentForm
"""Separate client tokens by currency code."""
currency = self.get_currency()
return f'PAYMENT_GATEWAY_CLIENT_TOKEN_{currency}'
def _get_existing_subscription(self): def _get_existing_subscription(self):
# Exclude cancelled subscriptions because they cannot transition to active # 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 status__in=looper.models.Subscription._CANCELLED_STATUSES
) )
return existing_subscriptions.first() 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): 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'] plan_variation_id = kwargs['plan_variation_id']
self.plan_variation = get_object_or_404( self.plan_variation = get_object_or_404(
looper.models.PlanVariation, looper.models.PlanVariation,
pk=plan_variation_id, pk=plan_variation_id,
is_active=True, is_active=True,
currency=self.get_currency(),
) )
if not getattr(self, 'gateway', None):
self.gateway = looper.models.Gateway.default() self.user = request.user
self.user = self.request.user
self.customer = None
self.subscription = None
if self.user.is_authenticated:
self.customer = self.user.customer self.customer = self.user.customer
self.subscription = self._get_existing_subscription() 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) return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self) -> dict: def get_form_kwargs(self, *args, **kwargs):
"""Pass extra parameters to the form.""" """Pass request to the form."""
form_kwargs = super().get_form_kwargs() form_kwargs = super().get_form_kwargs(*args, **kwargs)
if self.user.is_authenticated: form_kwargs.update(
return { {
**form_kwargs, 'request': self.request,
'plan_variation': self.plan_variation,
'instance': self.customer.billing_address, 'instance': self.customer.billing_address,
} }
)
return form_kwargs 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: 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 { return {
**super().get_context_data(**kwargs), **super().get_context_data(**kwargs),
'current_plan_variation': self.plan_variation, 'current_plan_variation': self.plan_variation,
'subscription': self.subscription, '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): 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 product_type = self.plan_variation.plan.product.type
# Get the tax the same way the template does, # Get the tax the same way the template does,
# to detect if it was affected by changes to the billing details # to detect if it was affected by changes to the billing details
@ -144,17 +176,10 @@ class BillingDetailsView(_JoinMixin, LoginRequiredMixin, FormView):
form.save() form.save()
msg = 'Pricing has been updated to reflect changes to your billing details' msg = 'Pricing has been updated to reflect changes to your billing details'
new_country = self.customer.billing_address.country response_redirect = self._set_preferred_currency_and_redirect()
new_currency = preferred_currency_for_country_code(new_country) if response_redirect:
# 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
messages.add_message(self.request, messages.INFO, msg) messages.add_message(self.request, messages.INFO, msg)
return redirect( return response_redirect
'subscriptions:join-billing-details', plan_variation_id=plan_variation.pk
)
# Compare tax before and after the billing address is updated # Compare tax before and after the billing address is updated
new_tax = self.customer.get_tax(product_type=product_type) 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) messages.add_message(self.request, messages.INFO, msg)
return self.form_invalid(form) return self.form_invalid(form)
return redirect( gateway = form.cleaned_data['gateway']
'subscriptions:join-confirm-and-pay', plan_variation_id=self.plan_variation.pk price_cents = new_taxable.price.cents
) subscription = self._get_or_create_subscription(gateway)
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)
# Update the tax info stored on the subscription # Update the tax info stored on the subscription
subscription.update_tax() subscription.update_tax()
order = self._fetch_or_create_order(form, subscription) order = self._fetch_or_create_order(form, subscription)
# Update the order to take into account latest changes # 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() order.update()
# Make sure we are charging what we've displayed # Make sure we are charging what we've displayed
price = looper.money.Money(order.price.currency, price_cents) price = looper.money.Money(order.price.currency, price_cents)
if order.price != price: 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) return self.form_invalid(form)
if not gateway.provider.supports_transactions: 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: # Trigger an email with instructions about manual payment:
subscription_created_needs_payment.send(sender=subscription) subscription_created_needs_payment.send(sender=subscription)
return redirect(url)
response = self._charge_if_supported(form, gateway, order) success_url = self.request.build_absolute_uri(
return response 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)

View File

@ -4,7 +4,6 @@ import logging
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.forms.utils import ErrorList from django.forms.utils import ErrorList
from django.http import HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from looper.models import Subscription from looper.models import Subscription
@ -35,7 +34,9 @@ class SingleSubscriptionMixin(LoginRequiredMixin):
def get_subscription(self) -> Subscription: def get_subscription(self) -> Subscription:
"""Retrieve Subscription object.""" """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: def get_context_data(self, **kwargs) -> dict:
"""Add Subscription to the template context.""" """Add Subscription to the template context."""
@ -45,29 +46,6 @@ class SingleSubscriptionMixin(LoginRequiredMixin):
'subscription': subscription, '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: class BootstrapErrorListMixin:
"""Override get_form method changing error_class of the form.""" """Override get_form method changing error_class of the form."""

View File

@ -5,7 +5,7 @@ import logging
from django.shortcuts import redirect from django.shortcuts import redirect
from django.views.generic import FormView from django.views.generic import FormView
from looper.views.checkout import AbstractPaymentView from looper.views.checkout_stripe import CheckoutStripeView
import looper.gateways import looper.gateways
import looper.middleware import looper.middleware
import looper.models import looper.models
@ -20,7 +20,7 @@ logger.setLevel(logging.DEBUG)
class _PlanSelectorMixin: class _PlanSelectorMixin:
get_currency = AbstractPaymentView.get_currency get_currency = CheckoutStripeView.get_currency
select_team_plans = False select_team_plans = False
plan_variation = None plan_variation = None
plan = None plan = None

View File

@ -1,11 +1,7 @@
"""Views handling subscription management.""" """Views handling subscription management."""
from typing import Optional
import logging import logging
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin 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.urls import reverse_lazy, reverse
from django.views.generic import UpdateView, FormView from django.views.generic import UpdateView, FormView
@ -15,8 +11,6 @@ import looper.views.settings
from subscriptions.forms import ( from subscriptions.forms import (
BillingAddressForm, BillingAddressForm,
CancelSubscriptionForm, CancelSubscriptionForm,
ChangePaymentMethodForm,
PayExistingOrderForm,
TeamForm, TeamForm,
) )
from subscriptions.views.mixins import SingleSubscriptionMixin, BootstrapErrorListMixin from subscriptions.views.mixins import SingleSubscriptionMixin, BootstrapErrorListMixin
@ -26,24 +20,13 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
class BillingAddressView(LoginRequiredMixin, UpdateView): class BillingAddressView(looper.views.settings.BillingAddressView):
"""Combine looper's Customer and Address into a billing address.""" """Override form class and success URL of looper's view."""
template_name = 'settings/billing_address.html' template_name = 'settings/billing_address.html'
model = looper.models.Address
form_class = BillingAddressForm form_class = BillingAddressForm
success_url = reverse_lazy('subscriptions:billing-address') 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): class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
"""Confirm and cancel a subscription.""" """Confirm and cancel a subscription."""
@ -68,78 +51,40 @@ class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView): 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' success_url = 'subscriptions:payment-method-change-done'
form_class = ChangePaymentMethodForm
success_url = reverse_lazy('user-settings-billing')
subscription: looper.models.Subscription def get_cancel_url(self):
"""Return to this subscription's manage page."""
def get_initial(self) -> dict: return reverse(
"""Modify initial form data.""" 'subscriptions:manage',
initial = super().get_initial() kwargs={'subscription_id': self.kwargs['subscription_id']},
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)
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.""" """Override looper's view with our forms."""
# Redirect to LOGIN_URL instead of raising an exception # Redirect to LOGIN_URL instead of raising an exception
raise_exception = False 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: def get_cancel_url(self):
"""Prefill the payment amount and missing form data, if any.""" """Return to this subscription's manage page."""
initial = { order = self.get_object()
'price': self.order.price.decimals_string, return reverse('subscriptions:manage', kwargs={'subscription_id': order.subscription_id})
'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
)
class ManageSubscriptionView( class ManageSubscriptionView(

View File

@ -2,7 +2,7 @@
from collections import defaultdict from collections import defaultdict
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from looper.views.checkout import AbstractPaymentView from looper.views.checkout_stripe import CheckoutStripeView
import looper.models import looper.models
import subscriptions.models import subscriptions.models
@ -11,7 +11,7 @@ import subscriptions.models
class TeamsLanding(TemplateView): class TeamsLanding(TemplateView):
"""Display a selection of team plans and existing sponsors.""" """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' template_name = 'subscriptions/teams_landing.html'
@staticmethod @staticmethod

View File

@ -1,24 +1,27 @@
from typing import Tuple
from unittest.mock import patch from unittest.mock import patch
import os import os
import unittest import unittest
from django.conf import settings from django.conf import settings
from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from freezegun import freeze_time from freezegun import freeze_time
import responses import responses
# from responses import _recorder
from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4, SINGAPORE_IPV4 from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4, SINGAPORE_IPV4
from looper.money import Money from looper.money import Money
import looper.models import looper.models
from common.tests.factories.subscriptions import create_customer_with_billing_address from looper.tests.factories import create_customer_with_billing_address
from common.tests.factories.users import UserFactory from common.tests.factories.users import UserFactory, OAuthUserInfoFactory
from subscriptions.tests.base import BaseSubscriptionTestCase from subscriptions.tests.base import BaseSubscriptionTestCase, responses_from_file
import subscriptions.tasks import subscriptions.tasks
import users.tasks import users.tasks
import users.tests.util as util import users.tests.util as util
responses_dir = 'subscriptions/tests/_responses/'
required_address_data = { required_address_data = {
'country': 'NL', 'country': 'NL',
'email': 'my.billing.email@example.com', '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. # **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') @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_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1})
url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2}) 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): def test_get_prefills_full_name_and_billing_email_from_user(self):
user = UserFactory(full_name="Jane До", email='jane.doe@example.com') user = UserFactory(full_name="Jane До", email='jane.doe@example.com')
self.client.force_login(user) self.client.force_login(user)
@ -53,6 +68,7 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self._assert_total_default_variation_selected_tax_21_eur(response) self._assert_total_default_variation_selected_tax_21_eur(response)
self._assert_pay_via_bank_not_displayed(response)
self.assertContains( self.assertContains(
response, response,
'<input type="text" name="full_name" value="Jane До" maxlength="255" placeholder="Your Full Name" class="form-control" required id="id_full_name">', '<input type="text" name="full_name" value="Jane До" maxlength="255" placeholder="Your Full Name" class="form-control" required id="id_full_name">',
@ -65,7 +81,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
) )
def test_get_displays_total_and_billing_details_to_logged_in_nl(self): 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) self.client.force_login(user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4) 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) self._assert_total_default_variation_selected_tax_21_eur(response)
def test_get_displays_total_and_billing_details_to_logged_in_de(self): 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) self.client.force_login(user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4) 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) self._assert_total_default_variation_selected_tax_19_eur(response)
def test_get_displays_total_and_billing_details_to_logged_in_us(self): 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' vat_number='', country='US', region='NY', postal_code='12001'
) )
user = customer.user
self.client.force_login(user) self.client.force_login(user)
response = self.client.get(self.url_usd) response = self.client.get(self.url_usd)
@ -99,16 +118,23 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
self._assert_total_default_variation_selected_usd(response) self._assert_total_default_variation_selected_usd(response)
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required') @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): def test_get_detects_country_us_sets_preferred_currency_usd_and_redirects(self):
user = create_customer_with_billing_address() customer = create_customer_with_billing_address()
user = customer.user
self.client.force_login(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) 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') @unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
def test_get_detects_country_us_sets_preferred_currency_usd(self): 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) self.client.force_login(user)
response = self.client.get(self.url_usd, REMOTE_ADDR=USA_IPV4) 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') @unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
def test_get_detects_country_sg_sets_preferred_currency_eur(self): 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) self.client.force_login(user)
response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4) 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') @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): 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) self.client.force_login(user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4) 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) 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') @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_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1})
url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2}) 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): 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) self.client.force_login(user)
selected_variation = ( selected_variation = (
@ -174,7 +230,7 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
) )
.first() .first()
) )
data = full_billing_address_data data = {**full_billing_address_data, 'gateway': 'stripe'}
url = reverse( url = reverse(
'subscriptions:join-billing-details', 'subscriptions:join-billing-details',
kwargs={'plan_variation_id': selected_variation.pk}, kwargs={'plan_variation_id': selected_variation.pk},
@ -197,51 +253,36 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
self.assertContains(response, 'Manual ') self.assertContains(response, 'Manual ')
self.assertContains(response, '/ <span class="x-price-period">1 year</span>', html=True) self.assertContains(response, '/ <span class="x-price-period">1 year</span>', 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) self.client.force_login(self.user)
default_variation = _get_default_variation('EUR') data = {**required_address_data, 'gateway': 'stripe'}
data = required_address_data
response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4) response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual( self.assertEqual(response['Location'], self.cs_url)
response['Location'],
reverse(
'subscriptions:join-confirm-and-pay',
kwargs={'plan_variation_id': default_variation.pk},
),
)
# Follow the redirect to avoid "Couldn't retrieve content: Response code was 302 (expected 200)"
response = self.client.get(response['Location'])
# Check that we are no longer on the billing details page
self._assert_payment_form_displayed(response)
self._assert_total_default_variation_selected_tax_21_eur(response)
# The hidden price field must also be set to a matching amount
self.assertContains(
response,
'<input type="hidden" name="price" value="9.90" class="form-control" id="id_price">',
html=True,
)
@responses.activate
def test_post_updates_billing_address_and_customer_applies_reverse_charged_tax(self): 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) self.client.force_login(self.user)
data = { data = {
**required_address_data, **required_address_data,
'vat_number': 'DE 260543043', 'gateway': 'stripe',
'country': 'DE', 'country': 'DE',
'postal_code': '11111', 'postal_code': '11111',
'vat_number': 'DE 260543043',
} }
response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4) response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertEqual(self.user.customer.vat_number, 'DE260543043')
address = self.user.customer.billing_address 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.full_name, 'New Full Name')
self.assertEqual(address.postal_code, '11111') self.assertEqual(address.postal_code, '11111')
self.assertEqual(address.country, 'DE') self.assertEqual(address.country, 'DE')
@ -255,23 +296,14 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
# Post the same form again # Post the same form again
response = self.client.post(self.url, data) response = self.client.post(self.url, data)
self.assertEqual(response.status_code, 302) 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.assertEqual(response['Location'], self.cs_url, response['Location'])
self._assert_payment_form_displayed(response)
# The hidden price field must also be set to a matching amount
self.assertContains(
response,
'<input type="hidden" name="price" value="8.32" class="form-control" id="id_price">',
html=True,
)
def test_post_changing_address_from_with_region_to_without_region_clears_region(self): 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' vat_number='', country='US', region='NY', postal_code='12001'
) )
user = customer.user
self.client.force_login(user) self.client.force_login(user)
response = self.client.get(self.url_usd) response = self.client.get(self.url_usd)
@ -285,6 +317,7 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
# Post an new address that doesn't require a region # Post an new address that doesn't require a region
data = { data = {
**required_address_data, **required_address_data,
'gateway': 'stripe',
'country': 'DE', 'country': 'DE',
'postal_code': '11111', 'postal_code': '11111',
} }
@ -304,94 +337,51 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
self.assertEqual(user.customer.billing_address.country, 'DE') self.assertEqual(user.customer.billing_address.country, 'DE')
self.assertEqual(user.customer.billing_address.postal_code, '11111') 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): def test_plan_variation_does_not_match_detected_currency_usd_euro_ip(self):
url, _ = self._get_url_for(currency='USD', price=11900) 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) 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) response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 302)
expected_url, _ = self._get_url_for(currency='EUR', price=10900)
def test_plan_variation_matches_detected_currency_eur_non_eea_ip(self): self.assertEqual(response['Location'], expected_url)
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)
def test_billing_address_country_takes_precedence_over_geo_ip(self): def test_billing_address_country_takes_precedence_over_geo_ip(self):
url, _ = self._get_url_for(currency='EUR', price=990) customer = create_customer_with_billing_address(country='GE')
user = create_customer_with_billing_address(country='NL') self.client.force_login(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=SINGAPORE_IPV4) response = self.client.post(self.url, data, REMOTE_ADDR=SINGAPORE_IPV4)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self._assert_total_default_variation_selected_tax_21_eur(response) self._assert_total_default_variation_selected_tax_21_eur(response)
def test_invalid_missing_required_fields(self): def test_invalid_missing_required_fields(self):
url, _ = self._get_url_for(currency='EUR', price=990) customer = create_customer_with_billing_address(country='NL')
user = create_customer_with_billing_address(country='NL') self.client.force_login(customer.user)
self.client.force_login(user)
data = required_address_data response = self.client.post(self.url, {}, REMOTE_ADDR=EURO_IPV4)
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self._assert_total_default_variation_selected_tax_21_eur(response) self._assert_total_default_variation_selected_tax_21_eur(response)
self.assertEqual( self.assertEqual(
response.context['form'].errors, 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.'], '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): def test_invalid_bank_transfer_cannot_be_selected_for_automatic_payments(self):
url, selected_variation = self._get_url_for(currency='EUR', price=990) 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) self.client.force_login(user)
data = { data = {
@ -423,7 +413,9 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
) )
@responses.activate @responses.activate
def test_pay_with_bank_transfer_creates_order_subscription_on_hold(self): 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) self.client.force_login(user)
util.mock_blender_id_badger_badger_response( util.mock_blender_id_badger_badger_response(
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
@ -435,17 +427,12 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
interval_unit='month', interval_unit='month',
plan__name='Manual renewal', plan__name='Manual renewal',
) )
data = { data = {**required_address_data, 'full_name': 'Jane Doe', 'gateway': 'bank'}
**required_address_data,
'gateway': 'bank',
'payment_method_nonce': 'unused',
'price': '14.90',
}
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4) response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
self._assert_transactionless_done_page_displayed(response) 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.status, 'on-hold')
self.assertEqual(subscription.price, Money('EUR', 1490)) self.assertEqual(subscription.price, Money('EUR', 1490))
self.assertEqual(subscription.tax, Money('EUR', 259)) 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( def test_pay_with_bank_transfer_creates_order_subscription_on_hold_shows_reverse_charged_price(
self, self,
): ):
user = create_customer_with_billing_address( responses._add_from_file(f'{responses_dir}vies_valid.yaml')
country='ES', full_name='Jane Doe', vat_number='DE260543043' 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) self.client.force_login(user)
util.mock_blender_id_badger_badger_response( util.mock_blender_id_badger_badger_response(
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id 'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
@ -514,17 +509,12 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
interval_length=3, interval_length=3,
interval_unit='month', interval_unit='month',
) )
data = { data = {'gateway': 'bank', **address}
**required_address_data,
'gateway': 'bank',
'payment_method_nonce': 'unused',
'price': '26.45',
}
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4) response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
self._assert_transactionless_done_page_displayed(response) 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.status, 'on-hold')
self.assertEqual(subscription.price, Money('EUR', 3200)) self.assertEqual(subscription.price, Money('EUR', 3200))
self.assertEqual(subscription.tax, Money('EUR', 0)) 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, selected_variation.collection_method)
self.assertEqual(subscription.collection_method, 'manual') self.assertEqual(subscription.collection_method, 'manual')
self.assertEqual(subscription.plan, selected_variation.plan) 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() order = subscription.latest_order()
self.assertEqual(order.status, 'created') self.assertEqual(order.status, 'created')
@ -545,6 +537,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self.assertIsNotNone(order.display_number) self.assertIsNotNone(order.display_number)
self.assertIsNotNone(order.vat_number) self.assertIsNotNone(order.vat_number)
self.assertNotEqual(order.display_number, str(order.pk)) 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(subscription)
self._assert_bank_transfer_email_is_sent_tax_21_eur_reverse_charged(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( response = self.client.get(
reverse('subscriptions:manage', kwargs={'subscription_id': subscription.pk}) reverse('subscriptions:manage', kwargs={'subscription_id': subscription.pk})
) )
self.assertNotIn('32.00', response) self.assertNotIn('32.00', response)
self.assertNotIn('21%', response) self.assertNotIn('21%', response)
self.assertNotIn('Inc.', response) self.assertNotIn('Inc.', response)
@ -581,34 +576,44 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
'users.signals.tasks.grant_blender_id_role', 'users.signals.tasks.grant_blender_id_role',
new=users.tasks.grant_blender_id_role.task_function, new=users.tasks.grant_blender_id_role.task_function,
) )
@responses.activate
def test_pay_with_credit_card_creates_order_subscription_active(self): def test_pay_with_credit_card_creates_order_subscription_active(self):
url, selected_variation = self._get_url_for(currency='EUR', price=990) 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) 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 = { data = {**required_address_data, 'gateway': 'stripe'}
**required_address_data, with responses.RequestsMock() as rsps:
'gateway': 'braintree', rsps._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
# 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) 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) self._assert_done_page_displayed(response)
subscription = user.subscription_set.first() subscription.refresh_from_db()
order = subscription.latest_order() order.refresh_from_db()
self.assertEqual(subscription.status, 'active') self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.price, Money('EUR', 990)) self.assertEqual(subscription.price, Money('EUR', 990))
self.assertEqual(subscription.collection_method, selected_variation.collection_method) self.assertEqual(subscription.collection_method, selected_variation.collection_method)
@ -629,33 +634,47 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
'users.signals.tasks.grant_blender_id_role', 'users.signals.tasks.grant_blender_id_role',
new=users.tasks.grant_blender_id_role.task_function, new=users.tasks.grant_blender_id_role.task_function,
) )
@responses.activate
def test_pay_with_credit_card_creates_order_subscription_active_team(self): def test_pay_with_credit_card_creates_order_subscription_active_team(self):
url, selected_variation = self._get_url_for( url, selected_variation = self._get_url_for(
currency='EUR', currency='EUR',
price=9000, price=9000,
plan__name='Automatic renewal, 15 seats', 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) 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 = { data = {**required_address_data, 'gateway': 'stripe'}
**required_address_data, with responses.RequestsMock() as rsps:
'gateway': 'braintree', rsps._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
'payment_method_nonce': 'fake-valid-nonce',
'price': '90.00',
}
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4) 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) self._assert_done_page_displayed(response)
subscription = user.subscription_set.first() subscription = customer.subscription_set.first()
order = subscription.latest_order() order = subscription.latest_order()
self.assertEqual(subscription.status, 'active') self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.price, Money('EUR', 9000)) self.assertEqual(subscription.price, Money('EUR', 9000))
@ -670,7 +689,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self._assert_team_subscription_activated_email_is_sent(subscription) self._assert_team_subscription_activated_email_is_sent(subscription)
def test_pay_with_credit_card_creates_order_subscription_active_business_de(self): 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) self.client.force_login(user)
url, selected_variation = self._get_url_for( url, selected_variation = self._get_url_for(
@ -681,19 +701,46 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
) )
data = { data = {
**required_address_data, **required_address_data,
'vat_number': 'DE 260543043', 'gateway': 'stripe',
'country': 'DE', 'country': 'DE',
'postal_code': '11111', 'postal_code': '11111',
'gateway': 'braintree', 'vat_number': 'DE 260543043',
'payment_method_nonce': 'fake-valid-nonce',
# VAT is subtracted from the plan variation price:
'price': '12.52',
} }
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) self._assert_done_page_displayed(response)
subscription = user.subscription_set.first() subscription.refresh_from_db()
self.assertEqual(subscription.status, 'active') self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.price, Money('EUR', 1490)) self.assertEqual(subscription.price, Money('EUR', 1490))
self.assertEqual(subscription.tax, Money('EUR', 0)) self.assertEqual(subscription.tax, Money('EUR', 0))
@ -716,15 +763,17 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self.assertIsNotNone(order.number) self.assertIsNotNone(order.number)
class TestJoinConfirmAndPayLoggedInUserOnlyView(BaseSubscriptionTestCase): class TestJoinViewLoggedInUserOnly(TestCase):
url = reverse('subscriptions:join-confirm-and-pay', kwargs={'plan_variation_id': 8}) 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) 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) 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/')

View File

@ -10,11 +10,9 @@ from freezegun import freeze_time
from looper.tests.factories import PaymentMethodFactory, OrderFactory from looper.tests.factories import PaymentMethodFactory, OrderFactory
import looper.taxes import looper.taxes
from common.tests.factories.subscriptions import ( from common.tests.factories.subscriptions import TeamFactory
TeamFactory,
create_customer_with_billing_address,
)
from common.tests.factories.users import UserFactory from common.tests.factories.users import UserFactory
from looper.tests.factories import create_customer_with_billing_address
expected_text_tmpl = '''Invoice expected_text_tmpl = '''Invoice
Blender Studio B.V. Blender Studio B.V.
@ -63,16 +61,16 @@ class TestReceiptPDFView(TestCase):
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
user = create_customer_with_billing_address(email='mail1@example.com') customer = create_customer_with_billing_address(email='mail1@example.com')
cls.payment_method = PaymentMethodFactory(user=user) cls.payment_method = PaymentMethodFactory(customer=customer)
cls.paid_order = OrderFactory( cls.paid_order = OrderFactory(
user=user, customer=customer,
price=990, price=990,
status='paid', status='paid',
tax_country='NL', tax_country='NL',
payment_method=cls.payment_method, payment_method=cls.payment_method,
subscription__customer=customer,
subscription__payment_method=cls.payment_method, subscription__payment_method=cls.payment_method,
subscription__user=user,
subscription__plan__product__name='Blender Studio Subscription', subscription__plan__product__name='Blender Studio Subscription',
) )
@ -84,15 +82,15 @@ class TestReceiptPDFView(TestCase):
def test_get_pdf_unpaid_order_not_found(self): def test_get_pdf_unpaid_order_not_found(self):
unpaid_order = OrderFactory( unpaid_order = OrderFactory(
user=self.payment_method.user, customer=self.payment_method.customer,
price=990, price=990,
tax_country='NL', tax_country='NL',
payment_method=self.payment_method, payment_method=self.payment_method,
subscription__customer=self.payment_method.customer,
subscription__payment_method=self.payment_method, subscription__payment_method=self.payment_method,
subscription__user=self.payment_method.user,
subscription__plan_id=1, 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}) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': unpaid_order.pk})
response = self.client.get(url) response = self.client.get(url)
@ -115,7 +113,7 @@ class TestReceiptPDFView(TestCase):
self.assertEqual(404, response.status_code) self.assertEqual(404, response.status_code)
def test_get_pdf_has_logo(self): 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}) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': self.paid_order.pk})
response = self.client.get(url) response = self.client.get(url)
@ -136,7 +134,9 @@ class TestReceiptPDFView(TestCase):
tax_type=looper.taxes.TaxType.VAT_CHARGE, tax_type=looper.taxes.TaxType.VAT_CHARGE,
tax_rate=Decimal(19), tax_rate=Decimal(19),
) )
user = UserFactory()
order = OrderFactory( order = OrderFactory(
customer=user.customer,
price=taxable.price, price=taxable.price,
status='paid', status='paid',
tax=taxable.tax, tax=taxable.tax,
@ -144,9 +144,10 @@ class TestReceiptPDFView(TestCase):
tax_type=taxable.tax_type.value, tax_type=taxable.tax_type.value,
tax_rate=taxable.tax_rate, tax_rate=taxable.tax_rate,
email='billing@example.com', email='billing@example.com',
subscription__customer=user.customer,
subscription__plan_id=1, 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}) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
response = self.client.get(url) response = self.client.get(url)
@ -178,7 +179,9 @@ class TestReceiptPDFView(TestCase):
tax_type=looper.taxes.TaxType.VAT_REVERSE_CHARGE, tax_type=looper.taxes.TaxType.VAT_REVERSE_CHARGE,
tax_rate=Decimal(19), tax_rate=Decimal(19),
) )
user = UserFactory()
order = OrderFactory( order = OrderFactory(
customer=user.customer,
price=taxable.price, price=taxable.price,
status='paid', status='paid',
tax=taxable.tax, tax=taxable.tax,
@ -187,9 +190,10 @@ class TestReceiptPDFView(TestCase):
tax_rate=taxable.tax_rate, tax_rate=taxable.tax_rate,
vat_number='DE123456789', vat_number='DE123456789',
email='billing@example.com', email='billing@example.com',
subscription__customer=user.customer,
subscription__plan_id=1, 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}) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
response = self.client.get(url) response = self.client.get(url)
@ -225,7 +229,9 @@ class TestReceiptPDFView(TestCase):
tax_type=looper.taxes.TaxType.VAT_CHARGE, tax_type=looper.taxes.TaxType.VAT_CHARGE,
tax_rate=Decimal(21), tax_rate=Decimal(21),
) )
user = UserFactory()
order = OrderFactory( order = OrderFactory(
customer=user.customer,
price=taxable.price, price=taxable.price,
status='paid', status='paid',
tax=taxable.tax, tax=taxable.tax,
@ -234,9 +240,10 @@ class TestReceiptPDFView(TestCase):
tax_rate=taxable.tax_rate, tax_rate=taxable.tax_rate,
vat_number='NL123456789', vat_number='NL123456789',
email='billing@example.com', email='billing@example.com',
subscription__customer=user.customer,
subscription__plan_id=1, 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}) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
response = self.client.get(url) response = self.client.get(url)
@ -263,15 +270,18 @@ class TestReceiptPDFView(TestCase):
@freeze_time('2023-02-08T11:12:20+01:00') @freeze_time('2023-02-08T11:12:20+01:00')
def test_get_pdf_total_no_vat(self): def test_get_pdf_total_no_vat(self):
user = UserFactory()
order = OrderFactory( order = OrderFactory(
customer=user.customer,
price=1000, price=1000,
currency='USD', currency='USD',
status='paid', status='paid',
tax_country='US', tax_country='US',
email='billing@example.com', email='billing@example.com',
subscription__customer=user.customer,
subscription__plan_id=1, 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}) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
response = self.client.get(url) response = self.client.get(url)
@ -305,6 +315,7 @@ class TestReceiptPDFView(TestCase):
subscription__plan_id=1, subscription__plan_id=1,
) )
order = OrderFactory( order = OrderFactory(
customer=team.subscription.customer,
price=20000, price=20000,
currency='USD', currency='USD',
status='paid', status='paid',
@ -312,7 +323,7 @@ class TestReceiptPDFView(TestCase):
email='billing@example.com', email='billing@example.com',
subscription=team.subscription, 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}) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
response = self.client.get(url) response = self.client.get(url)
@ -347,6 +358,7 @@ class TestReceiptPDFView(TestCase):
invoice_reference='PO #9876', invoice_reference='PO #9876',
) )
order = OrderFactory( order = OrderFactory(
customer=team.subscription.customer,
price=20000, price=20000,
currency='USD', currency='USD',
status='paid', status='paid',
@ -354,7 +366,7 @@ class TestReceiptPDFView(TestCase):
email='billing@example.com', email='billing@example.com',
subscription=team.subscription, 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}) url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
response = self.client.get(url) response = self.client.get(url)

View File

@ -7,7 +7,7 @@ from freezegun import freeze_time
from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4 # , SINGAPORE_IPV4 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 from subscriptions.tests.base import BaseSubscriptionTestCase
# **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database. # **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) self._assert_default_variation_selected_no_tax_usd(response)
def test_get_displays_plan_selection_to_logged_in_nl(self): def test_get_displays_plan_selection_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')
self.client.force_login(user) self.client.force_login(customer.user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4) 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) self._assert_default_variation_selected_tax_21_eur(response)
def test_get_displays_plan_selection_to_logged_in_de(self): def test_get_displays_plan_selection_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')
self.client.force_login(user) self.client.force_login(customer.user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4) 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) self._assert_default_variation_selected_tax_19_eur(response)
def test_get_displays_plan_selection_to_logged_in_us(self): 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' 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) response = self.client.get(self.url)
@ -84,10 +84,10 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase):
self._assert_plan_selector_no_tax(response) self._assert_plan_selector_no_tax(response)
def test_get_team_displays_plan_selection_to_logged_in_us(self): 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' 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) response = self.client.get(self.url_team)

View File

@ -4,12 +4,16 @@ from django.urls import reverse
from looper.models import PaymentMethod, PaymentMethodAuthentication, Gateway from looper.models import PaymentMethod, PaymentMethodAuthentication, Gateway
from looper.money import Money 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.users import UserFactory
from common.tests.factories.subscriptions import SubscriptionFactory from subscriptions.tests.base import BaseSubscriptionTestCase, responses_from_file
from subscriptions.tests.base import BaseSubscriptionTestCase
import subscriptions.tasks import subscriptions.tasks
responses_dir = 'subscriptions/tests/_responses/'
required_address_data = { required_address_data = {
'country': 'NL', 'country': 'NL',
'email': 'my.billing.email@example.com', 'email': 'my.billing.email@example.com',
@ -27,16 +31,17 @@ full_billing_address_data = {
class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase): 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() user = UserFactory()
self.client.force_login(user) self.client.force_login(user)
url = reverse('subscriptions:billing-address') response = self.client.post(self.url, full_billing_address_data)
response = self.client.post(url, full_billing_address_data)
# Check that the redirect on success happened # Check that the redirect on success happened
self.assertEqual(response.status_code, 302, response.content) 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 # Check that all address fields were updated
customer = user.customer customer = user.customer
@ -51,15 +56,14 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
self.assertEqual(address.company, 'Test LLC') self.assertEqual(address.company, 'Test LLC')
# Check that customer fields were updated as well # Check that customer fields were updated as well
self.assertEqual(customer.vat_number, 'NL818152011B01') self.assertEqual(customer.billing_address.vat_number, 'NL818152011B01')
# N.B.: email is saved as Customer.billing_email self.assertEqual(customer.billing_address.email, 'my.billing.email@example.com')
self.assertEqual(customer.billing_email, 'my.billing.email@example.com')
def test_invalid_missing_required_fields(self): def test_invalid_missing_required_fields(self):
user = UserFactory() user = UserFactory()
self.client.force_login(user) 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.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist') self.assertContains(response, 'errorlist')
@ -72,7 +76,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
data = { data = {
'email': 'new@example.com', '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.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist') self.assertContains(response, 'errorlist')
@ -85,7 +89,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
data = { data = {
'full_name': 'New Full Name', '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.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist') self.assertContains(response, 'errorlist')
@ -100,11 +104,13 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
url_name = 'subscriptions:payment-method-change' url_name = 'subscriptions:payment-method-change'
success_url_name = 'user-settings-billing' 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): def test_can_change_payment_method_from_bank_to_credit_card_with_sca(self):
bank = Gateway.objects.get(name='bank') bank = Gateway.objects.get(name='bank')
subscription = SubscriptionFactory( subscription = SubscriptionFactory(
user=self.user, customer=self.user.customer,
payment_method__user_id=self.user.pk, payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=bank, payment_method__gateway=bank,
) )
self.assertEqual(PaymentMethod.objects.count(), 1) self.assertEqual(PaymentMethod.objects.count(), 1)
@ -113,15 +119,24 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
self.client.force_login(self.user) self.client.force_login(self.user)
url = reverse(self.url_name, kwargs={'subscription_id': subscription.pk}) url = reverse(self.url_name, kwargs={'subscription_id': subscription.pk})
data = { with responses.RequestsMock() as rsps:
**self.shared_payment_form_data, rsps._add_from_file(f'{responses_dir}stripe_new_cs_setup.yaml')
'gateway': 'braintree', response = self.client.post(url)
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
}
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 302) 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 # New payment method was created
self.assertEqual(PaymentMethod.objects.count(), 2) self.assertEqual(PaymentMethod.objects.count(), 2)
@ -131,40 +146,9 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
self.assertNotEqual(subscription.payment_method_id, payment_method.pk) self.assertNotEqual(subscription.payment_method_id, payment_method.pk)
self.assertEqual( self.assertEqual(
str(subscription.payment_method), str(subscription.payment_method),
'Visa credit card ending in 0002', 'visa credit card ending in 4242',
) )
# SCA was stored # SCA isn't stored for Stripe payment methods
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')
self.assertIsNone(PaymentMethodAuthentication.objects.first()) self.assertIsNone(PaymentMethodAuthentication.objects.first())
@ -173,8 +157,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
def test_can_cancel_when_on_hold(self): def test_can_cancel_when_on_hold(self):
subscription = SubscriptionFactory( subscription = SubscriptionFactory(
user=self.user, customer=self.user.customer,
payment_method__user_id=self.user.pk, payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'), payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold', status='on-hold',
) )
@ -198,8 +182,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
def test_can_cancel_when_active(self): def test_can_cancel_when_active(self):
subscription = SubscriptionFactory( subscription = SubscriptionFactory(
user=self.user, customer=self.user.customer,
payment_method__user_id=self.user.pk, payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'), payment_method__gateway=Gateway.objects.get(name='bank'),
status='active', status='active',
) )
@ -218,8 +202,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
def test_email_sent_when_pending_cancellation_changes_to_cancelled(self): def test_email_sent_when_pending_cancellation_changes_to_cancelled(self):
subscription = SubscriptionFactory( subscription = SubscriptionFactory(
user=self.user, customer=self.user.customer,
payment_method__user_id=self.user.pk, payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'), payment_method__gateway=Gateway.objects.get(name='bank'),
status='pending-cancellation', status='pending-cancellation',
) )
@ -240,12 +224,11 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
class TestPayExistingOrder(BaseSubscriptionTestCase): class TestPayExistingOrder(BaseSubscriptionTestCase):
url_name = 'subscriptions:pay-existing-order' url_name = 'subscriptions:pay-existing-order'
success_url_name = 'user-settings-billing'
def test_redirect_to_login_when_anonymous(self): def test_redirect_to_login_when_anonymous(self):
subscription = SubscriptionFactory( subscription = SubscriptionFactory(
user=self.user, customer=self.user.customer,
payment_method__user_id=self.user.pk, payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'), payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold', status='on-hold',
) )
@ -253,19 +236,15 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
self.client.logout() self.client.logout()
url = reverse(self.url_name, kwargs={'order_id': order.pk}) url = reverse(self.url_name, kwargs={'order_id': order.pk})
data = { response = self.client.get(url)
'gateway': 'braintree',
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
}
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], f'/oauth/login?next={url}') self.assertEqual(response['Location'], f'/oauth/login?next={url}')
def test_cannot_pay_someone_elses_order(self): def test_cannot_pay_someone_elses_order(self):
subscription = SubscriptionFactory( subscription = SubscriptionFactory(
user=self.user, customer=self.user.customer,
payment_method__user_id=self.user.pk, payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'), payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold', status='on-hold',
) )
@ -274,40 +253,12 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
self.client.force_login(user) self.client.force_login(user)
url = reverse(self.url_name, kwargs={'order_id': order.pk}) url = reverse(self.url_name, kwargs={'order_id': order.pk})
data = { response = self.client.get(url)
'gateway': 'braintree',
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
}
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 404)
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.'],
},
)
# @_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( @patch(
# Make sure background task is executed as a normal function # Make sure background task is executed as a normal function
'subscriptions.signals.tasks.send_mail_subscription_status_changed', '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): def test_can_pay_for_manual_subscription_with_an_order(self):
subscription = SubscriptionFactory( subscription = SubscriptionFactory(
user=self.user, plan__name='Automatic renewal subscription',
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'), payment_method__gateway=Gateway.objects.get(name='bank'),
currency='USD', currency='USD',
price=Money('USD', 1110), price=Money('USD', 1110),
status='on-hold', status='on-hold',
) )
self.assertEqual(subscription.collection_method, 'automatic')
order = subscription.generate_order() order = subscription.generate_order()
self.client.force_login(self.user) self.client.force_login(self.user)
url = reverse(self.url_name, kwargs={'order_id': order.pk}) url = reverse(self.url_name, kwargs={'order_id': order.pk})
data = { with responses.RequestsMock() as rsps:
**required_address_data, rsps._add_from_file(f'{responses_dir}stripe_new_cs_usd.yaml')
'price': order.price, response = self.client.get(url)
'gateway': 'braintree',
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
}
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 302) 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) self.assertEqual(order.transaction_set.count(), 1)
transaction = order.latest_transaction() transaction = order.latest_transaction()
self.assertEqual( self.assertEqual(
@ -350,7 +314,7 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
self.assertNotEqual(subscription.payment_method, 'bank') self.assertNotEqual(subscription.payment_method, 'bank')
self.assertEqual( self.assertEqual(
str(subscription.payment_method), str(subscription.payment_method),
'Visa credit card ending in 0002', 'PayPal account billing@example.com',
) )
self.assertEqual(subscription.status, 'active') self.assertEqual(subscription.status, 'active')

View File

@ -6,6 +6,9 @@ from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.authtoken.admin import TokenAdmin from rest_framework.authtoken.admin import TokenAdmin
import nested_admin
from looper.admin import REL_CUSTOMER_SEARCH_FIELDS
import looper.admin import looper.admin
import looper.models 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'): def user_subscriptions_link(obj, title='View subscriptions of this user'):
admin_view = looper.admin._get_admin_url_name(looper.models.Subscription, 'changelist') 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('<a href="{}">{}</a>', link, title) return format_html('<a href="{}">{}</a>', link, title)
@ -80,7 +83,7 @@ class NumberOfBraintreeCustomerIDsFilter(admin.SimpleListFilter):
@admin.register(get_user_model()) @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' change_form_template = 'loginas/change_form.html'
def has_add_permission(self, request): def has_add_permission(self, request):
@ -91,9 +94,9 @@ class UserAdmin(auth_admin.UserAdmin):
"""Count user subscriptions for subscription debugging purposes.""" """Count user subscriptions for subscription debugging purposes."""
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.annotate( queryset = queryset.annotate(
subscriptions_count=Count('subscription', distinct=True), subscriptions_count=Count('customer__subscription', distinct=True),
braintreecustomerids_count=Count( braintreecustomerids_count=Count(
'gatewaycustomerid', Q(gateway__name='braintree'), distinct=True 'customer__gatewaycustomerid', Q(customer__gateway__name='braintree'), distinct=True
), ),
) )
return queryset return queryset
@ -147,13 +150,12 @@ class UserAdmin(auth_admin.UserAdmin):
user_subscriptions_link, user_subscriptions_link,
subscriptions, subscriptions,
) )
inlines = [ inlines = (
looper.admin.AddressInline, looper.admin.LinkCustomerTokenInline,
looper.admin.CustomerInline, looper.admin.CustomerInline,
looper.admin.GatewayCustomerIdInline, )
]
ordering = ['-date_joined'] ordering = ['-date_joined']
search_fields = ['email', 'full_name', 'username'] search_fields = ['email', 'full_name', 'username', *REL_CUSTOMER_SEARCH_FIELDS]
def deletion_requested(self, obj): def deletion_requested(self, obj):
"""Display yes/no icon status of deletion request.""" """Display yes/no icon status of deletion request."""

View File

@ -174,7 +174,7 @@ class User(AbstractUser):
self.delete_oauth() self.delete_oauth()
# If there are no orders, the user account can be deleted # 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( logger.warning(
'User pk=%s requested deletion and has no orders: deleting the account', 'User pk=%s requested deletion and has no orders: deleting the account',
self.pk, self.pk,
@ -199,7 +199,7 @@ class User(AbstractUser):
logger.warning('Anonymized user pk=%s', self.pk) logger.warning('Anonymized user pk=%s', self.pk)
logger.warning('Soft-deleting payment methods records of 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 = '<deleted>' payment_method.recognisable_name = '<deleted>'
logger.warning( logger.warning(
'Deleting payment method %s of user pk=%s at the payment gateway', 'Deleting payment method %s of user pk=%s at the payment gateway',
@ -208,17 +208,14 @@ class User(AbstractUser):
) )
payment_method.delete() payment_method.delete()
logger.warning('Deleting address records of user pk=%s', self.pk) customer_id = self.customer.pk
looper.models.Address.objects.filter(user_id=self.pk).delete() 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) logger.warning('Deleting gateway customer ID records of customer pk=%s', customer_id)
looper.models.Customer.objects.exclude(user_id=None).filter(user_id=self.pk).update( looper.models.GatewayCustomerId.objects.filter(customer_id=customer_id).delete()
billing_email=f'{username}@example.com',
full_name='',
)
looper.models.GatewayCustomerId.objects.filter(user_id=self.pk).delete()
logger.warning('Deleting user pk=%s from teams', self.pk)
subscriptions.models.TeamUsers.objects.filter(user_id=self.pk).delete() subscriptions.models.TeamUsers.objects.filter(user_id=self.pk).delete()
logger.warning('Anonymizing comments of user pk=%s', self.pk) logger.warning('Anonymizing comments of user pk=%s', self.pk)

View File

@ -107,7 +107,7 @@ def handle_tracking_event_unsubscribe(event_type: str, message_id: str, event: D
def unsubscribe_from_newsletters(pk: int): def unsubscribe_from_newsletters(pk: int):
"""Remove emails of user with given pk from newsletter lists.""" """Remove emails of user with given pk from newsletter lists."""
user = User.objects.get(pk=pk) 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: for email in emails:
if not email: if not email:
continue continue
@ -135,7 +135,7 @@ def handle_deletion_request(pk: int) -> bool:
try: try:
unsubscribe_from_newsletters(pk=pk) unsubscribe_from_newsletters(pk=pk)
except Exception: 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() user.anonymize_or_delete()
return True return True

View File

@ -1,4 +1,5 @@
{% extends 'common/base.html' %} {% extends 'common/base.html' %}
{% load pipeline %}
{% block nav_drawer_inner %} {% block nav_drawer_inner %}
{% include 'users/settings/tabs.html' %} {% include 'users/settings/tabs.html' %}
@ -21,9 +22,13 @@
</nav> </nav>
</div> </div>
<div class="col-md-9"> <div class="col-md-9">
{% block settings%} {% block settings %}
{% endblock settings %} {% endblock settings %}
</div> </div>
</div> </div>
</div> </div>
{% endblock content %} {% endblock content %}
{% block scripts %}
{% javascript "subscriptions" %}
{% endblock scripts %}

View File

@ -4,7 +4,7 @@
{% block settings %} {% block settings %}
<p class="text-muted">Settings</p> <p class="text-muted">Settings</p>
<h1 class="mb-3">Subscription{% if user.subscription_set.count > 1 %}s{% endif %}</h1> <h1 class="mb-3">Subscription{% if user.customer.subscription_set.count > 1 %}s{% endif %}</h1>
<p class="mb-2 text-muted">If you have any problems with billing, contact the team directly on <a href="mailto:{{ ADMIN_MAIL }}">{{ ADMIN_MAIL }}</a>.</p> <p class="mb-2 text-muted">If you have any problems with billing, contact the team directly on <a href="mailto:{{ ADMIN_MAIL }}">{{ ADMIN_MAIL }}</a>.</p>
<div> <div>
{% if user|has_group:"demo" %} {% if user|has_group:"demo" %}
@ -18,7 +18,8 @@
</p> </p>
</div> </div>
</div> </div>
{% else %}{# for everyone except demo #} {% endif %}
{% if not user|has_group:"demo" or user.customer.subscription_set.count %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include "subscriptions/components/list.html" %} {% include "subscriptions/components/list.html" %}

View File

@ -5,15 +5,17 @@ from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.utils import timezone from django.utils import timezone
from comments.queries import set_comment_like from looper.tests.factories import (
from common.tests.factories.comments import CommentFactory
from common.tests.factories.subscriptions import (
TeamFactory,
PaymentMethodFactory, PaymentMethodFactory,
TransactionFactory, TransactionFactory,
create_customer_with_billing_address, 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.tasks as tasks
import users.tests.util as util import users.tests.util as util
@ -49,9 +51,13 @@ class TestTasks(TestCase):
def test_handle_deletion_request(self): def test_handle_deletion_request(self):
now = timezone.now() 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) 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 # this user made some comments
user_comments = [CommentFactory(user=user) for _ in range(2)] user_comments = [CommentFactory(user=user) for _ in range(2)]
# this user liked some comments as well # this user liked some comments as well
@ -102,22 +108,27 @@ class TestTasks(TestCase):
def test_handle_deletion_request_user_has_orders(self): def test_handle_deletion_request_user_has_orders(self):
now = timezone.now() 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) 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 # 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( transaction = TransactionFactory(
user=user, customer=customer,
order__price=990, order__customer=customer,
order__tax_country='NL',
order__payment_method=payment_method, order__payment_method=payment_method,
order__price=990,
order__subscription__customer=customer,
order__subscription__payment_method=payment_method, order__subscription__payment_method=payment_method,
order__subscription__user=user,
order__subscription__status='cancelled', order__subscription__status='cancelled',
order__user=user, order__tax_country='NL',
payment_method=payment_method, payment_method=payment_method,
) )
billing_address = customer.billing_address
# this user made some comments # this user made some comments
user_comments = [CommentFactory(user=user) for _ in range(2)] user_comments = [CommentFactory(user=user) for _ in range(2)]
# this user liked some comments as well # this user liked some comments as well
@ -142,8 +153,9 @@ class TestTasks(TestCase):
f'Anonymized user pk={user.pk}', f'Anonymized user pk={user.pk}',
f'Soft-deleting payment methods records of 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', rf'Deleting payment method \d+ of user pk={user.pk} at the payment gateway',
f'Deleting address records of user pk={user.pk}', f'Deleting address records of customer pk={customer.pk}',
f'Anonymizing Customer record of user pk={user.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 comments of user pk={user.pk}',
f'Anonymizing likes of user pk={user.pk}', f'Anonymizing likes of user pk={user.pk}',
f'Deleting actions 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.assertEqual(user.full_name, '')
self.assertTrue(user.email.startswith('del')) self.assertTrue(user.email.startswith('del'))
self.assertTrue(user.email.endswith('@example.com')) self.assertTrue(user.email.endswith('@example.com'))
user.customer.refresh_from_db() # billing address was deleted
self.assertTrue(user.customer.billing_email.startswith('del'), user.customer.billing_email) with self.assertRaises(looper.models.Address.DoesNotExist):
self.assertTrue(user.customer.billing_email.endswith('@example.com'), user.customer.billing_email) billing_address.refresh_from_db()
self.assertEqual(user.customer.full_name, '' , user.customer.full_name) customer.refresh_from_db()
self.assertEqual(user.address_set.count(), 0) self.assertEqual(customer.paymentmethod_set.first().recognisable_name, '')
self.assertEqual(user.paymentmethod_set.first().recognisable_name, '')
# user actions got deleted # user actions got deleted
for action in user_actions: for action in user_actions:
@ -192,22 +203,23 @@ class TestTasks(TestCase):
def test_handle_deletion_request_user_has_not_yet_cancelled_subscription(self): def test_handle_deletion_request_user_has_not_yet_cancelled_subscription(self):
now = timezone.now() now = timezone.now()
user = create_customer_with_billing_address( customer = create_customer_with_billing_address(
full_name='Joe Dane', full_name='Joe Dane',
email='mail1@example.com', email='mail1@example.com',
date_deletion_requested=now - timedelta(days=30), date_deletion_requested=now - timedelta(days=30),
) )
user = customer.user
# this user has a subscription with an order and a transaction # this user has a subscription with an order and a transaction
payment_method = PaymentMethodFactory(user=user) payment_method = PaymentMethodFactory(customer=customer)
transaction = TransactionFactory( transaction = TransactionFactory(
user=user, customer=customer,
order__price=990, order__customer=customer,
order__tax_country='NL',
order__payment_method=payment_method, order__payment_method=payment_method,
order__price=990,
order__subscription__customer=customer,
order__subscription__payment_method=payment_method, order__subscription__payment_method=payment_method,
order__subscription__user=user,
order__subscription__status='on-hold', order__subscription__status='on-hold',
order__user=user, order__tax_country='NL',
payment_method=payment_method, 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): def test_handle_deletion_request_user_has_orders_and_is_on_a_team(self):
now = timezone.now() now = timezone.now()
team = TeamFactory( team = TeamFactory(
subscription__user=create_customer_with_billing_address( subscription__customer=create_customer_with_billing_address(
full_name='Joe Manager Dane', full_name='Joe Manager Dane',
email='mail1@example.com', email='mail1@example.com',
) )
@ -246,25 +258,27 @@ class TestTasks(TestCase):
team.users.add(user_to_be_deleted) team.users.add(user_to_be_deleted)
self.assertEqual(3, team.users.count()) self.assertEqual(3, team.users.count())
# this user also has a subscription with an order and a transaction # 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( TransactionFactory(
user=user_to_be_deleted, customer=user_to_be_deleted.customer,
order__price=990, order__price=990,
order__tax_country='NL', order__tax_country='NL',
order__customer=user_to_be_deleted.customer,
order__payment_method=payment_method, order__payment_method=payment_method,
order__subscription__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__subscription__status='cancelled',
order__user=user_to_be_deleted,
payment_method=payment_method, payment_method=payment_method,
) )
tasks.handle_deletion_request.task_function(pk=user_to_be_deleted.pk) tasks.handle_deletion_request.task_function(pk=user_to_be_deleted.pk)
# sanity check: nothing happened to the user owning the team subscription # sanity check: nothing happened to the user owning the team subscription
team.subscription.user.refresh_from_db() team.subscription.customer.user.refresh_from_db()
self.assertEqual('Joe Manager Dane', team.subscription.user.full_name) self.assertEqual('Joe Manager Dane', team.subscription.customer.user.full_name)
self.assertTrue(team.subscription.user.is_active) self.assertTrue(team.subscription.customer.user.is_active)
# user wasn't deleted but anonymised # user wasn't deleted but anonymised
user_to_be_deleted.refresh_from_db() 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): def test_handle_deletion_request_user_and_is_on_a_team(self):
now = timezone.now() now = timezone.now()
team = TeamFactory( team = TeamFactory(
subscription__user=create_customer_with_billing_address( subscription__customer=create_customer_with_billing_address(
full_name='Joe Manager Dane', full_name='Joe Manager Dane',
email='mail1@example.com', email='mail1@example.com',
) )
@ -294,9 +308,9 @@ class TestTasks(TestCase):
tasks.handle_deletion_request.task_function(pk=user_to_be_deleted.pk) tasks.handle_deletion_request.task_function(pk=user_to_be_deleted.pk)
# sanity check: nothing happened to the user owning the team subscription # sanity check: nothing happened to the user owning the team subscription
team.subscription.user.refresh_from_db() team.subscription.customer.user.refresh_from_db()
self.assertEqual('Joe Manager Dane', team.subscription.user.full_name) self.assertEqual('Joe Manager Dane', team.subscription.customer.user.full_name)
self.assertTrue(team.subscription.user.is_active) self.assertTrue(team.subscription.customer.user.is_active)
# user was deleted # user was deleted
with self.assertRaises(User.DoesNotExist): with self.assertRaises(User.DoesNotExist):

View File

@ -5,8 +5,6 @@ from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
import responses import responses
from common.tests.factories.users import UserFactory
User = get_user_model() User = get_user_model()
@ -145,7 +143,7 @@ def mock_mailgun_responses() -> None:
def create_admin_log_user() -> User: def create_admin_log_user() -> User:
"""Create the admin user used for logging.""" """Create the admin user used for logging."""
admin_user = UserFactory(id=1, email='admin@blender.studio', is_staff=True, is_superuser=True) admin_user, _ = User.objects.update_or_create(
# Reset ID sequence to avoid clashing with an already used ID 1 id=1, defaults={'email': 'admin@blender.studio', 'is_staff': True, 'is_superuser': True}
UserFactory.reset_sequence(100, force=True) )
return admin_user return admin_user