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_SECRET=
GOOGLE_RECAPTCHA_SITE_KEY=
GOOGLE_RECAPTCHA_SECRET_KEY=
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):
user = UserFactory()
SubscriptionFactory(user=user, status='active')
SubscriptionFactory(customer=user.customer, status='active')
character_version = CharacterVersionFactory(is_free=False)
self.client.force_login(user)
@ -222,7 +222,7 @@ class TestCharacterShowcaseDownload(TestCase):
def test_can_download_non_free_when_subscribed(self):
user = UserFactory()
SubscriptionFactory(user=user, status='active')
SubscriptionFactory(customer=user.customer, status='active')
character_showcase = CharacterShowcaseFactory(is_free=False)
self.client.force_login(user)

View File

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

View File

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

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
import factory
import looper.models
from looper.tests.factories import SubscriptionFactory
from common.tests.factories.users import UserFactory
from subscriptions.models import Team
User = get_user_model()
class PaymentMethodFactory(DjangoModelFactory):
class Meta:
model = looper.models.PaymentMethod
gateway_id = factory.LazyAttribute(lambda _: looper.models.Gateway.objects.first().pk)
user = factory.SubFactory('common.tests.factories.users.UserFactory')
class SubscriptionFactory(DjangoModelFactory):
class Meta:
model = looper.models.Subscription
plan_id = factory.LazyAttribute(lambda _: looper.models.Plan.objects.first().pk)
payment_method = factory.SubFactory(PaymentMethodFactory)
user = factory.SubFactory('common.tests.factories.users.UserFactory')
class OrderFactory(DjangoModelFactory):
class Meta:
model = looper.models.Order
user = factory.SubFactory('common.tests.factories.users.UserFactory')
subscription = factory.SubFactory(SubscriptionFactory)
payment_method = factory.SubFactory(PaymentMethodFactory)
class TransactionFactory(DjangoModelFactory):
class Meta:
model = looper.models.Transaction
user = factory.SubFactory('common.tests.factories.users.UserFactory')
order = factory.SubFactory(OrderFactory)
payment_method = factory.SubFactory(PaymentMethodFactory)
class TeamFactory(DjangoModelFactory):
class Meta:
@ -55,38 +12,3 @@ class TeamFactory(DjangoModelFactory):
name = factory.Faker('text', max_nb_chars=15)
subscription = factory.SubFactory(SubscriptionFactory)
@factory.django.mute_signals(signals.pre_save, signals.post_save)
class CustomerFactory(DjangoModelFactory):
class Meta:
model = looper.models.Customer
billing_email = factory.LazyAttribute(lambda o: '%s.billing@example.com' % o.user.username)
user = factory.SubFactory('common.tests.factories.users.UserFactory')
@factory.django.mute_signals(signals.pre_save, signals.post_save)
class AddressFactory(DjangoModelFactory):
class Meta:
model = looper.models.Address
user = factory.SubFactory('common.tests.factories.users.UserFactory')
# TODO(anna): this should probably move to looper
@factory.django.mute_signals(signals.pre_save, signals.post_save)
def create_customer_with_billing_address(**data):
"""Use factories to create a User with a Customer and Address records."""
customer_field_names = {f.name for f in looper.models.Customer._meta.get_fields()}
address_field_names = {f.name for f in looper.models.Address._meta.get_fields()}
user_field_names = {f.name for f in User._meta.get_fields()}
address_kwargs = {k: v for k, v in data.items() if k in address_field_names}
customer_kwargs = {k: v for k, v in data.items() if k in customer_field_names}
user_kwargs = {k: v for k, v in data.items() if k in user_field_names}
user = UserFactory(**user_kwargs)
AddressFactory(user=user, **address_kwargs)
CustomerFactory(user=user, **customer_kwargs)
return user

View File

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

View File

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

View File

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

36
poetry.lock generated
View File

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

View File

@ -14,7 +14,7 @@ libsasscompiler = "^0.1.5"
jsmin = "3.0.0"
sorl-thumbnail = "^12.10.0"
mistune = "2.0.0a4"
looper = {git = "https://projects.blender.org/infrastructure/looper.git", rev = "10bca5a012"}
looper = {git = "https://projects.blender.org/infrastructure/looper.git", rev = "8cd4da9"}
Pillow = "^8.0"
django-storages = {extras = ["google"], version = "1.11.1"}
pymongo = "^3.10.1"

View File

@ -504,6 +504,12 @@ GATEWAYS = {
'supported_collection_methods': {'automatic', 'manual'},
},
'bank': {'supported_collection_methods': {'manual'}},
'stripe': {
'api_publishable_key': _get('STRIPE_API_PUBLISHABLE_KEY'),
'api_secret_key': _get('STRIPE_API_SECRET_KEY'),
'endpoint_secret': _get('STRIPE_ENDPOINT_SECRET'),
'supported_collection_methods': {'automatic', 'manual'},
},
}
# Optional Sentry configuration
@ -630,8 +636,6 @@ if MAILGUN_SENDER_DOMAIN:
GEOIP2_DB = _get('GEOIP2_DB')
GOOGLE_ANALYTICS_TRACKING_ID = _get('GOOGLE_ANALYTICS_TRACKING_ID')
GOOGLE_RECAPTCHA_SECRET_KEY = _get('GOOGLE_RECAPTCHA_SECRET_KEY')
GOOGLE_RECAPTCHA_SITE_KEY = _get('GOOGLE_RECAPTCHA_SITE_KEY')
S3DIRECT_DESTINATIONS = {
'default': {
@ -676,5 +680,15 @@ S3DIRECT_DESTINATIONS = {
},
}
# A list of payment method types used with stripe for setting a recurring payment:
# https://stripe.com/docs/api/checkout/sessions/create#create_checkout_session-payment_method_types
STRIPE_OFF_SESSION_PAYMENT_METHOD_TYPES = [
'card',
'link',
'paypal',
]
STRIPE_CHECKOUT_SUBMIT_TYPE = 'pay'
# Maximum number of attempts for failing background tasks
MAX_ATTEMPTS = 3

View File

@ -5,13 +5,12 @@ import logging
from django import forms
from django.core.exceptions import ValidationError
from django.forms.fields import Field
from django.forms.models import model_to_dict
from localflavor.administrative_areas import ADMINISTRATIVE_AREAS
from localflavor.generic.validators import validate_country_postcode
from looper.middleware import COUNTRY_CODE_SESSION_KEY
from stdnum.eu import vat
import localflavor.exceptions
import looper.form_fields
import looper.forms
import looper.models
@ -48,17 +47,11 @@ REQUIRED_FIELDS = {
class BillingAddressForm(forms.ModelForm):
"""Unify Customer and Address in a single form."""
# Customer.billing_email is exposed as email in the Form
# because Looper scripts and forms already use "email" everywhere.
__customer_fields = {'billing_email': 'email', 'vat_number': 'vat_number'}
# Colliding "full_name" and "company" values are taken from and saved to the Address.
# FIXME(anna): do we need to use company and full_name on the Customer or only Address?
"""Fill in billing address and prepare for intitiating Stripe checkout session."""
class Meta:
model = looper.models.Address
fields = looper.models.Address.PUBLIC_FIELDS
exclude = ['category', 'customer', 'tax_exempt']
# What kind of choices are allowed depends on the selected country
# and is not yet known when the form is rendered.
@ -81,20 +74,6 @@ class BillingAddressForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
"""Load additional model data from Customer and set form placeholders."""
instance: looper.models.Address = kwargs.get('instance')
if instance:
assert isinstance(instance, looper.models.Address), 'Must be an instance of Address'
customer = instance.user.customer
initial = kwargs.get('initial') or {}
customer_data = model_to_dict(customer, self.__customer_fields.keys(), {})
# Remap the fields, e.g. turning "billing_email" into "email"
customer_form_data = {v: customer_data[k] for k, v in self.__customer_fields.items()}
# Add Customer data into initial,
# making sure that it still overrides the instance data, as it's supposed to
kwargs['initial'] = {
**customer_form_data,
**initial,
}
super().__init__(*args, **kwargs)
# Set placeholder values on all form fields
@ -160,43 +139,62 @@ class BillingAddressForm(forms.ModelForm):
)
def save(self, commit=True):
"""Save Customer data as well."""
"""Save cleared region field."""
# Validation against region choices is already done, because choices are set on __init__,
# however Django won't set the updated blank region value if was omitted from the form.
if self.cleaned_data['region'] == '':
self.instance.region = ''
instance = super().save(commit=commit)
customer = instance.user.customer
for model_field, form_field in self.__customer_fields.items():
setattr(customer, model_field, self.cleaned_data[form_field])
if commit:
customer.save(update_fields=self.__customer_fields)
return instance
class BillingAddressReadonlyForm(forms.ModelForm):
"""Display the billing details in a payment form but neither validate nor update them.
class PaymentForm(BillingAddressForm):
"""Handle PlanVariation ID and payment method details in the second step of the checkout.
Used in PaymentMethodChangeView and PayExistingOrderView.
Billing details are displayed as read-only and cannot be edited,
but are still used by the payment flow.
"""
class Meta:
model = looper.models.Address
fields = looper.models.Address.PUBLIC_FIELDS
gateway = looper.form_fields.GatewayChoiceField(
queryset=looper.models.Gateway.objects.filter(name__in={'stripe', 'bank'}).order_by(
'-is_default'
)
)
def __init__(self, *args, **kwargs):
"""Disable all the billing details fields.
"""Pre-fill additional initial data from request."""
self.request = kwargs.pop('request', None)
self.plan_variation = kwargs.pop('plan_variation', None)
The billing details are only for display and for use by the payment flow.
"""
super().__init__(*args, **kwargs)
for field_name, field in self.fields.items():
if field_name not in BILLING_DETAILS_PLACEHOLDERS:
continue
field.disabled = True
email = forms.EmailField(required=False, disabled=True)
self._set_initial_from_request()
def _set_initial_from_request(self):
if not self.request:
return
# Only preset country when it's not already selected by the customer
geoip_country = self.request.session.get(COUNTRY_CODE_SESSION_KEY)
if geoip_country and (not self.instance.country):
self.initial['country'] = geoip_country
# Only set initial values if they aren't already saved to the billing address.
# Initial values always override form data, which leads to confusing issues with views.
if not self.instance.full_name:
# Fall back to user's full name, if no full name set already in the billing address:
if self.request.user.full_name:
self.initial['full_name'] = self.request.user.full_name
def clean_gateway(self):
"""Validate gateway against selected plan variation."""
gw = self.cleaned_data['gateway']
if not self.plan_variation:
return gw
if self.plan_variation.collection_method not in gw.provider.supported_collection_methods:
msg = self.fields['gateway'].default_error_messages['invalid_choice']
self.add_error('gateway', msg)
return gw
class SelectPlanVariationForm(forms.Form):
@ -223,58 +221,6 @@ class SelectPlanVariationForm(forms.Form):
)
class PaymentForm(BillingAddressForm):
"""Handle PlanVariation ID and payment method details in the second step of the checkout.
Billing details are displayed as read-only and cannot be edited,
but are still used by the payment flow.
"""
payment_method_nonce = forms.CharField(initial='set-in-javascript', widget=forms.HiddenInput())
gateway = looper.form_fields.GatewayChoiceField()
device_data = forms.CharField(
initial='set-in-javascript', widget=forms.HiddenInput(), required=False
)
price = forms.CharField(widget=forms.HiddenInput(), required=True)
# These are used when a payment fails, so that the next attempt to pay can reuse
# the already-created subscription and order.
subscription_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
order_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
class AutomaticPaymentForm(PaymentForm):
"""Same as the PaymentForm, but only allows payment gateways that support transactions."""
gateway = looper.form_fields.GatewayChoiceField(
queryset=looper.models.Gateway.objects.filter(
name__in=looper.gateways.Registry.gateway_names_supports_transactions()
)
)
class PayExistingOrderForm(BillingAddressReadonlyForm):
"""Same as AutomaticPaymentForm, but doesn't validate or update billing details."""
payment_method_nonce = forms.CharField(initial='set-in-javascript', widget=forms.HiddenInput())
gateway = looper.form_fields.GatewayChoiceField(
queryset=looper.models.Gateway.objects.filter(
name__in=looper.gateways.Registry.gateway_names_supports_transactions()
)
)
device_data = forms.CharField(
initial='set-in-javascript', widget=forms.HiddenInput(), required=False
)
price = forms.CharField(widget=forms.HiddenInput(), required=True)
class ChangePaymentMethodForm(BillingAddressReadonlyForm, looper.forms.ChangePaymentMethodForm):
"""Add full billing address to the change payment form."""
pass
class CancelSubscriptionForm(forms.Form):
"""Confirm cancellation of a subscription."""

View File

@ -1,29 +1,10 @@
from django.conf import settings
from django.core.management import call_command
from django.db import migrations
form_description = {
'bank': 'When choosing to pay by bank, you will be required to manually perform payments and the subscription will be activated only when we receive the funds.',
'braintree': 'Automatic charges',
}
frontend_names = {
'bank': 'Bank Transfer',
'braintree': 'Credit Card or PayPal',
}
def add_gateways(apps, schema_editor):
Gateway = apps.get_model('looper', 'Gateway')
for (name, _) in Gateway._meta.get_field('name').choices:
if name == 'mock':
continue
Gateway.objects.get_or_create(
name=name,
frontend_name=frontend_names.get(name),
is_default=name.lower() == 'braintree',
form_description=form_description.get(name),
)
call_command('loaddata', 'gateways.json', app_label='looper')
def remove_gateways(apps, schema_editor):

View File

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

View File

@ -19,7 +19,7 @@ def has_active_subscription(user: User) -> bool:
active_subscriptions: 'QuerySet[Subscription]' = Subscription.objects.active()
return active_subscriptions.filter(
Q(user_id=user.id) | Q(team__team_users__user_id=user.id)
Q(customer=user.customer) | Q(team__team_users__user_id=user.id)
).exists()
@ -33,7 +33,9 @@ def has_non_legacy_subscription(user: User) -> bool:
subscriptions: 'QuerySet[Subscription]' = Subscription.objects.filter(is_legacy=False)
return subscriptions.filter(Q(user_id=user.id) | Q(team__team_users__user_id=user.id)).exists()
return subscriptions.filter(
Q(customer=user.customer) | Q(team__team_users__user_id=user.id)
).exists()
def has_subscription(user: User) -> bool:
@ -42,7 +44,7 @@ def has_subscription(user: User) -> bool:
return False
return Subscription.objects.filter(
Q(user_id=user.id) | Q(team__team_users__user_id=user.id)
Q(customer=user.customer) | Q(team__team_users__user_id=user.id)
).exists()
@ -51,7 +53,8 @@ def should_redirect_to_billing(user: User) -> bool:
if not user.is_authenticated:
return False
if user.subscription_set.exclude(status__in=Subscription._CANCELLED_STATUSES).count() == 0:
customer = user.customer
if customer.subscription_set.exclude(status__in=Subscription._CANCELLED_STATUSES).count() == 0:
# Only cancelled subscriptions, no need to redirect to billing
return False
@ -60,7 +63,7 @@ def should_redirect_to_billing(user: User) -> bool:
# so this seems to be the only currently available way to tell
# when to stop showing the checkout to the customer.
subscription.latest_order() and subscription.payment_method
for subscription in user.subscription_set.all()
for subscription in customer.subscription_set.all()
)
@ -76,4 +79,4 @@ def has_not_yet_cancelled_subscription(user: User) -> bool:
status__in=Subscription._CANCELLED_STATUSES
)
return not_yet_cancelled_subscriptions.filter(Q(user_id=user.id)).exists()
return not_yet_cancelled_subscriptions.filter(Q(customer=user.customer)).exists()

View File

@ -3,6 +3,7 @@ from typing import Set
import logging
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q
from django.dispatch import receiver
import alphabetic_timestamp as ats
@ -30,17 +31,39 @@ def timebased_order_number():
@receiver(django_signals.post_save, sender=User)
def create_customer(sender, instance: User, created, **kwargs):
def create_customer(sender, instance: User, created, raw, **kwargs):
"""Create Customer on User creation."""
if raw:
return
if not created:
return
logger.debug("Creating Customer for user %i" % instance.id)
# Assume billing name and email are the same, they should be able to change them later
Customer.objects.create(
user_id=instance.pk,
billing_email=instance.email,
full_name=instance.full_name,
try:
customer = instance.customer
except Customer.DoesNotExist:
pass
else:
logger.debug(
'Newly created User %d already has a Customer %d, not creating new one',
instance.pk,
customer.pk,
)
billing_address = customer.billing_address
logger.info('Creating new billing address due to creation of user %s', instance.pk)
if not billing_address.pk:
billing_address.email = instance.email
billing_address.full_name = instance.full_name
billing_address.save()
return
logger.info('Creating new Customer due to creation of user %s', instance.pk)
with transaction.atomic():
customer = Customer.objects.create(user=instance)
billing_address = customer.billing_address
billing_address.email = instance.email
billing_address.full_name = instance.full_name
billing_address.save()
@receiver(django_signals.pre_save, sender=Order)
@ -54,7 +77,8 @@ def _set_order_number(sender, instance: Order, **kwargs):
@receiver(subscription_created_needs_payment)
def _on_subscription_created_needs_payment(sender: looper.models.Subscription, **kwargs):
tasks.send_mail_bank_transfer_required(subscription_id=sender.pk)
users.tasks.grant_blender_id_role(pk=sender.user_id, role='cloud_has_subscription')
user = sender.customer.user
users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription')
@receiver(looper.signals.subscription_activated)
@ -65,8 +89,9 @@ def _on_subscription_status_changed(sender: looper.models.Subscription, **kwargs
@receiver(looper.signals.subscription_activated)
def _on_subscription_status_activated(sender: looper.models.Subscription, **kwargs):
users.tasks.grant_blender_id_role(pk=sender.user_id, role='cloud_has_subscription')
users.tasks.grant_blender_id_role(pk=sender.user_id, role='cloud_subscriber')
user = sender.customer.user
users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription')
users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_subscriber')
if not hasattr(sender, 'team'):
return
@ -79,8 +104,9 @@ def _on_subscription_status_activated(sender: looper.models.Subscription, **kwar
@receiver(looper.signals.subscription_expired)
def _on_subscription_status_deactivated(sender: looper.models.Subscription, **kwargs):
# No other active subscription exists, subscriber badge can be revoked
if not queries.has_active_subscription(sender.user):
users.tasks.revoke_blender_id_role(pk=sender.user_id, role='cloud_subscriber')
user = sender.customer.user
if not queries.has_active_subscription(user):
users.tasks.revoke_blender_id_role(pk=user.pk, role='cloud_subscriber')
if not hasattr(sender, 'team'):
return
@ -118,7 +144,8 @@ def _on_subscription_expired(sender: looper.models.Subscription, **kwargs):
assert sender.status == 'expired', f'Expected expired, got "{sender.status} (pk={sender.pk})"'
# Only send a "subscription expired" email when there are no other active subscriptions
if not queries.has_active_subscription(sender.user):
user = sender.customer.user
if not queries.has_active_subscription(user):
tasks.send_mail_subscription_expired(subscription_id=sender.pk)

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

View File

@ -14,7 +14,7 @@
<div class="container flat-page-content">
<div class="row">
<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>
<p>Make sure to follow the instructions below to activate your account.</p>
</div>

View File

@ -1,6 +1,5 @@
{% extends 'users/settings/base.html' %}
{% load common_extras %}
{% load pipeline %}
{% block settings %}
<p class="text-muted">Settings: Subscription</p>
@ -15,7 +14,3 @@
{% endwith %}
</form>
{% 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>
<tbody>
{# Owned subscriptions #}
{% for subscription in user.subscription_set.all %}
{% for subscription in user.customer.subscription_set.all %}
<tr>
<td class="fw-bold">{% if subscription.team %}Team {% endif %}Subscription #{{ subscription.pk }}</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="col text-end">
{% 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 %}
<a class="btn btn-primary w-100 x-sign-in" href="{% url 'oauth:login' %}">Sign in with Blender ID</a>
{% endif %}

View File

@ -6,7 +6,7 @@
{% endblock header_logo %}
{% 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 %}
<p>Manage subscription in your billing settings: <a href="{{ billing_url }}">{{ billing_url }}</a>.</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 %}
Manage subscription in your billing settings: {{ billing_url }}.

View File

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

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.

View File

@ -6,7 +6,7 @@
{% endblock header_logo %}
{% 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>
{% if latest_posts or latest_trainings %}
<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:
{% for post in latest_posts|slice:":2" %}

View File

@ -36,7 +36,7 @@
<div class="row">
{% if 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 }}
</p>
{% endfor %}
@ -53,6 +53,17 @@
</div>
{% 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>
{% endwith %}
</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 %}
<div class="col-auto">
<a class="small"
href="{% url 'subscriptions:payment-method-change' subscription_id=subscription.id %}">
Change
</a>
<form method="POST" action="{% url 'subscriptions:payment-method-change' subscription_id=subscription.id %}">
{% csrf_token %}
<button type="submit" class="small">Change</button>
</form>
</div>
{% endif %}
</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
import looper.money
import looper.taxes
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
from django.contrib.auth import get_user_model
from django.core import mail
from django.db.models import signals
from django.test import TestCase
@ -8,10 +8,12 @@ from django.urls import reverse
import factory
import responses
from common.tests.factories.subscriptions import create_customer_with_billing_address
from looper.tests.factories import create_customer_with_billing_address
import looper.models
import users.tests.util as util
User = get_user_model()
responses_dir = 'subscriptions/tests/_responses/'
def _write_mail(mail, index=0):
@ -24,16 +26,41 @@ def _write_mail(mail, index=0):
f.write(str(content))
def responses_from_file(file_name: str, rsps=responses, order_id=None):
"""Add a response mock from file, override `order_id` metadata with a given one."""
rsps._add_from_file(f'{responses_dir}{file_name}')
# Replace metadata's "order_id" hardcoded in the response YAML with current order ID,
# because it differs depending on whether this test is run alone or with all the tests.
for _ in rsps.registered():
if '%5D=payment_intent' in _.url:
assert '\"order_id\": \"1' in _.body
_.body = _.body.replace('\"order_id\": \"1', f'\"order_id\": \"{order_id}')
class BaseSubscriptionTestCase(TestCase):
fixtures = ['gateways']
def _get_url_for(self, **filter_params) -> Tuple[str, looper.models.PlanVariation]:
plan_variation = looper.models.PlanVariation.objects.active().get(**filter_params)
return (
reverse(
'subscriptions:join-billing-details',
kwargs={'plan_variation_id': plan_variation.pk},
),
plan_variation,
)
@factory.django.mute_signals(signals.pre_save, signals.post_save)
def setUp(self):
super().setUp()
# Allow requests to Braintree Sandbox
responses.add_passthru('https://api.sandbox.braintreegateway.com:443/')
# Create the admin user used for logging
self.admin_user = util.create_admin_log_user()
self.user = create_customer_with_billing_address(
self.customer = create_customer_with_billing_address(
full_name='Алексей Н.',
company='Testcompany B.V.',
street_address='Billing street 1',
@ -43,11 +70,18 @@ class BaseSubscriptionTestCase(TestCase):
region='North Holland',
country='NL',
vat_number='NL-KVK-41202535',
billing_email='billing@example.com',
email='billing@example.com',
)
self.customer = self.user.customer
self.user = self.customer.user
self.billing_address = self.customer.billing_address
responses._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
def tearDown(self):
super().tearDown()
responses.stop()
responses.reset()
def _mock_vies_response(self, is_valid=True, is_broken=False):
path = os.path.abspath(__file__)
dir_path = os.path.join(os.path.dirname(path), 'vies')
@ -86,12 +120,13 @@ class BaseSubscriptionTestCase(TestCase):
self._assert_continue_to_payment_displayed(response)
self.assertContains(response, 'id_street_address')
self.assertContains(response, 'id_full_name')
self.assertContains(response, 'name="gateway" value="stripe"')
def _assert_payment_form_displayed(self, response):
self.assertNotContains(response, 'Pricing has been updated')
self.assertNotContains(response, 'Continue to Payment')
self.assertContains(response, 'payment method')
self.assertContains(response, 'Confirm and Pay')
def _assert_pay_via_bank_not_displayed(self, response):
self.assertNotContains(response, 'name="gateway" value="bank"')
def _assert_pay_via_bank_displayed(self, response):
self.assertContains(response, 'name="gateway" value="bank"')
def _assert_pricing_has_been_updated(self, response):
self.assertContains(response, 'Pricing has been updated')
@ -319,7 +354,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertContains(response, '<h2 class="h3">Bank details:</h2>', html=True)
self.assertContains(response, 'on hold')
self.assertContains(response, 'NL07 INGB 0008 4489 82')
subscription = response.wsgi_request.user.subscription_set.first()
subscription = response.wsgi_request.user.customer.subscription_set.first()
self.assertContains(
response, f'Blender Studio order-{subscription.latest_order().display_number}'
)
@ -341,11 +376,11 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(len(mail.outbox), 0)
def _assert_bank_transfer_email_is_sent(self, subscription):
user = subscription.user
customer = subscription.customer
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [user.customer.billing_email])
self.assertEqual(email.to, [customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -384,11 +419,11 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Recurring total: €\xa026.45', email_body.replace(' ', ' '))
def _assert_subscription_activated_email_is_sent(self, subscription):
user = subscription.user
customer = subscription.customer
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [user.customer.billing_email])
self.assertEqual(email.to, [customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -397,17 +432,17 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn('activated', email_body)
self.assertIn(f'Dear {user.customer.full_name},', email_body)
self.assertIn(f'Dear {customer.billing_address.full_name},', email_body)
self.assertIn(reverse('user-settings-billing'), email_body)
self.assertIn('Automatic renewal subscription', email_body)
self.assertIn('Blender Studio Team', email_body)
def _assert_team_subscription_activated_email_is_sent(self, subscription):
user = subscription.user
customer = subscription.customer
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [user.customer.billing_email])
self.assertEqual(email.to, [customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -416,17 +451,17 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn('activated', email_body)
self.assertIn(f'Dear {user.customer.full_name},', email_body)
self.assertIn(f'Dear {customer.billing_address.full_name},', email_body)
self.assertIn(reverse('user-settings-billing'), email_body)
self.assertIn('Automatic renewal, 15 seats subscription', email_body)
self.assertIn('Blender Studio Team', email_body)
def _assert_subscription_deactivated_email_is_sent(self, subscription):
user = subscription.user
customer = subscription.customer
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [user.customer.billing_email])
self.assertEqual(email.to, [customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -440,11 +475,12 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Blender Studio Team', email_body)
def _assert_payment_soft_failed_email_is_sent(self, subscription):
user = subscription.user
customer = subscription.customer
user = customer.user
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [user.customer.billing_email])
self.assertEqual(email.to, [user.customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -454,7 +490,7 @@ class BaseSubscriptionTestCase(TestCase):
)
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn(f'Dear {user.customer.full_name},', email_body)
self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
self.assertIn('Automatic payment', email_body)
self.assertIn('failed', email_body)
self.assertIn('try again', email_body)
@ -470,11 +506,12 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Blender Studio Team', email_body)
def _assert_payment_failed_email_is_sent(self, subscription):
user = subscription.user
customer = subscription.customer
user = customer.user
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [user.customer.billing_email])
self.assertEqual(email.to, [user.customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -482,7 +519,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.subject, 'Blender Studio Subscription: payment failed')
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn(f'Dear {user.customer.full_name},', email_body)
self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
self.assertIn('Automatic payment', email_body)
self.assertIn('failed', email_body)
self.assertIn('3 times', email_body)
@ -497,11 +534,12 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Blender Studio Team', email_body)
def _assert_payment_paid_email_is_sent(self, subscription):
user = subscription.user
customer = subscription.customer
user = customer.user
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [user.customer.billing_email])
self.assertEqual(email.to, [user.customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -509,7 +547,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.subject, 'Blender Studio Subscription: payment received')
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn(f'Dear {user.customer.full_name},', email_body)
self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
self.assertIn('Automatic monthly payment', email_body)
self.assertIn('successful', email_body)
self.assertIn('$\xa011.10', email_body)
@ -523,7 +561,8 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Blender Studio Team', email_body)
def _assert_managed_subscription_notification_email_is_sent(self, subscription):
user = subscription.user
customer = subscription.customer
user = customer.user
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
@ -533,7 +572,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.subject, 'Blender Studio managed subscription needs attention')
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn(f'{user.customer.full_name} has', email_body)
self.assertIn(f'{user.customer.billing_address.full_name} has', email_body)
self.assertIn('its next payment date', email_body)
self.assertIn('$\xa011.10', email_body)
self.assertIn(
@ -542,16 +581,17 @@ class BaseSubscriptionTestCase(TestCase):
)
def _assert_subscription_expired_email_is_sent(self, subscription):
user = subscription.user
customer = subscription.customer
user = customer.user
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [subscription.user.email])
self.assertEqual(email.to, [user.email])
self.assertEqual(email.from_email, 'webmaster@localhost')
self.assertEqual(email.subject, 'We miss you at Blender Studio')
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn(f'Dear {user.customer.full_name}', email_body)
self.assertIn(f'Dear {user.customer.billing_address.full_name}', email_body)
self.assertIn(f'#{subscription.pk}', email_body)
self.assertIn('has expired', email_body)
self.assertIn(

View File

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

View File

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

View File

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

View File

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

View File

@ -1,23 +1,21 @@
"""Views handling subscription management."""
from decimal import Decimal
import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction
from django.shortcuts import redirect, get_object_or_404
from django.urls import reverse
from django.views.generic import FormView
from looper.middleware import COUNTRY_CODE_SESSION_KEY
from looper.views.checkout import AbstractPaymentView, CheckoutView
import looper.gateways
import looper.middleware
import looper.models
import looper.money
import looper.taxes
from looper.views.checkout_stripe import CheckoutStripeView
from subscriptions.forms import BillingAddressForm, PaymentForm, AutomaticPaymentForm
from subscriptions.forms import PaymentForm
from subscriptions.middleware import preferred_currency_for_country_code
from subscriptions.queries import should_redirect_to_billing
from subscriptions.signals import subscription_created_needs_payment
@ -27,111 +25,145 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
class _JoinMixin:
customer: looper.models.Customer
class JoinView(LoginRequiredMixin, FormView):
"""Fill in billing details and initiate Stripe checkout session."""
# FIXME(anna): this view uses some functionality of AbstractPaymentView,
# but cannot directly inherit from them, since JoinView supports creating only one subscription.
get_currency = AbstractPaymentView.get_currency
get_client_token = AbstractPaymentView.get_client_token
client_token_session_key = AbstractPaymentView.client_token_session_key
erase_client_token = AbstractPaymentView.erase_client_token
# FIXME(anna): this view uses some functionality of CheckoutStripeView,
# but cannot directly inherit it, since JoinView supports creating only one subscription.
_fetch_or_create_order = CheckoutStripeView._fetch_or_create_order
@property
def session_key_prefix(self) -> str:
"""Separate client tokens by currency code."""
currency = self.get_currency()
return f'PAYMENT_GATEWAY_CLIENT_TOKEN_{currency}'
template_name = 'subscriptions/join/billing_address.html'
form_class = PaymentForm
def _get_existing_subscription(self):
# Exclude cancelled subscriptions because they cannot transition to active
existing_subscriptions = self.request.user.subscription_set.exclude(
existing_subscriptions = self.request.user.customer.subscription_set.exclude(
status__in=looper.models.Subscription._CANCELLED_STATUSES
)
return existing_subscriptions.first()
def _set_preferred_currency_and_redirect(self):
# If no country is set in the existing address, use GeoIP's
geoip_country = self.request.session.get(looper.middleware.COUNTRY_CODE_SESSION_KEY)
if geoip_country and (not self.customer or not self.customer.billing_address.country):
country = geoip_country
else:
country = self.customer.billing_address.country
currency = preferred_currency_for_country_code(country)
if self.plan_variation.currency != currency:
# If variation's currency doesn't match, redirect to another plan variation
plan_variation = self.plan_variation.in_other_currency(currency)
self.request.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY] = currency
self.request.session.modified = True
return redirect(
'subscriptions:join-billing-details', plan_variation_id=plan_variation.pk
)
return None # nothing to do, no need to redirect
def dispatch(self, request, *args, **kwargs):
"""Set customer for authenticated user, same as AbstractPaymentView does."""
"""Redirect to login or to billing, or prepare plan variation."""
if not request.user.is_authenticated:
return self.handle_no_permission()
if should_redirect_to_billing(request.user):
return redirect('user-settings-billing')
plan_variation_id = kwargs['plan_variation_id']
self.plan_variation = get_object_or_404(
looper.models.PlanVariation,
pk=plan_variation_id,
is_active=True,
currency=self.get_currency(),
)
if not getattr(self, 'gateway', None):
self.gateway = looper.models.Gateway.default()
self.user = self.request.user
self.customer = None
self.subscription = None
if self.user.is_authenticated:
self.user = request.user
self.customer = self.user.customer
self.subscription = self._get_existing_subscription()
response_redirect = self._set_preferred_currency_and_redirect()
if response_redirect:
return response_redirect
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self) -> dict:
"""Pass extra parameters to the form."""
form_kwargs = super().get_form_kwargs()
if self.user.is_authenticated:
return {
**form_kwargs,
def get_form_kwargs(self, *args, **kwargs):
"""Pass request to the form."""
form_kwargs = super().get_form_kwargs(*args, **kwargs)
form_kwargs.update(
{
'request': self.request,
'plan_variation': self.plan_variation,
'instance': self.customer.billing_address,
}
)
return form_kwargs
def get(self, request, *args, **kwargs):
"""Redirect to the Store if subscriptions are not enabled."""
if should_redirect_to_billing(request.user):
return redirect('user-settings-billing')
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
"""Redirect anonymous users to login."""
if request.user.is_anonymous:
return redirect('{}?next={}'.format(settings.LOGIN_URL, request.path))
if request.user.is_authenticated:
if self.subscription and self.subscription.status in self.subscription._ACTIVE_STATUSES:
return redirect('user-settings-billing')
return super().post(request, *args, **kwargs)
class BillingDetailsView(_JoinMixin, LoginRequiredMixin, FormView):
"""Display billing details form and save them as billing Address and Customer."""
template_name = 'subscriptions/join/billing_address.html'
form_class = BillingAddressForm
customer: looper.models.Customer
def get_initial(self) -> dict:
"""Prefill default payment gateway, country and selected plan options."""
initial = super().get_initial()
# Only preset country when it's not already selected by the customer
geoip_country = self.request.session.get(COUNTRY_CODE_SESSION_KEY)
if geoip_country and (not self.customer or not self.customer.billing_address.country):
initial['country'] = geoip_country
# Only set initial values if they aren't already saved to the billing address.
# Initial values always override form data, which leads to confusing issues with views.
if not (self.customer and self.customer.billing_address.full_name):
# Fall back to user's full name, if no full name set already in the billing address:
if self.request.user.full_name:
initial['full_name'] = self.request.user.full_name
return initial
def get_context_data(self, **kwargs) -> dict:
"""Add an extra form and gateway's client token."""
"""Add existing subscription to the view and the context."""
return {
**super().get_context_data(**kwargs),
'current_plan_variation': self.plan_variation,
'subscription': self.subscription,
}
def _get_or_create_subscription(
self, gateway: looper.models.Gateway
) -> looper.models.Subscription:
subscription = self.subscription
is_new = False
if not subscription:
subscription = looper.models.Subscription(customer=self.customer)
is_new = True
logger_args = [self.customer.pk, gateway]
logger.debug('Creating a new subscription for customer pk=%s, %s', *logger_args)
collection_method = self.plan_variation.collection_method
if collection_method not in gateway.provider.supported_collection_methods:
# FIXME(anna): this breaks plan selector because collection method
# might not match the one selected by the customer.
collection_method = next(iter(gateway.provider.supported_collection_methods))
with transaction.atomic():
subscription.plan = self.plan_variation.plan
subscription.customer = self.customer
# Currency must be set before the price, in case it was changed
subscription.currency = self.plan_variation.currency
subscription.price = self.plan_variation.price
subscription.interval_unit = self.plan_variation.interval_unit
subscription.interval_length = self.plan_variation.interval_length
subscription.collection_method = collection_method
subscription.save()
if gateway.name == 'bank':
payment_method = self.customer.payment_method_add(None, gateway)
if subscription.payment_method_id != payment_method.pk:
logger.info(
'Switching subscription pk=%d from payment method pk=%d to pk=%d',
*[subscription.pk, subscription.payment_method_id, payment_method.pk],
)
subscription.switch_payment_method(payment_method)
# Configure the team if this is a team plan
if hasattr(subscription.plan, 'team_properties'):
team_properties = subscription.plan.team_properties
team, team_is_new = subscriptions.models.Team.objects.get_or_create(
subscription=subscription,
seats=team_properties.seats,
)
logger.info(
'%s a team for subscription pk=%r, seats: %s',
team_is_new and 'Created' or 'Updated',
subscription.pk,
team.seats and team.seats or 'unlimited',
)
logger.debug('%s subscription pk=%r', is_new and 'Created' or 'Updated', subscription.pk)
return subscription
def form_invalid(self, form, *args, **kwargs):
"""Temporarily log all validation errors."""
logger.exception('Validation error in JoinView: %s, %s', form.errors, form.data)
return super().form_invalid(form, *args, **kwargs)
def form_valid(self, form):
"""Save the billing details and pass the data to the payment form."""
"""Save the billing details and redirect to Stripe's checkout."""
product_type = self.plan_variation.plan.product.type
# Get the tax the same way the template does,
# to detect if it was affected by changes to the billing details
@ -144,17 +176,10 @@ class BillingDetailsView(_JoinMixin, LoginRequiredMixin, FormView):
form.save()
msg = 'Pricing has been updated to reflect changes to your billing details'
new_country = self.customer.billing_address.country
new_currency = preferred_currency_for_country_code(new_country)
# Compare currency before and after the billing address is updated
if self.plan_variation.currency != new_currency:
# If currency has changed, find a matching plan variation for this new currency
plan_variation = self.plan_variation.in_other_currency(new_currency)
self.request.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY] = new_currency
response_redirect = self._set_preferred_currency_and_redirect()
if response_redirect:
messages.add_message(self.request, messages.INFO, msg)
return redirect(
'subscriptions:join-billing-details', plan_variation_id=plan_variation.pk
)
return response_redirect
# Compare tax before and after the billing address is updated
new_tax = self.customer.get_tax(product_type=product_type)
@ -164,149 +189,55 @@ class BillingDetailsView(_JoinMixin, LoginRequiredMixin, FormView):
messages.add_message(self.request, messages.INFO, msg)
return self.form_invalid(form)
return redirect(
'subscriptions:join-confirm-and-pay', plan_variation_id=self.plan_variation.pk
)
class ConfirmAndPayView(_JoinMixin, LoginRequiredMixin, FormView):
"""Display the payment form and handle the payment flow."""
raise_exception = True
template_name = 'subscriptions/join/payment_method.html'
form_class = PaymentForm
log = logger
gateway: looper.models.Gateway
# FIXME(anna): this view uses some functionality of AbstractPaymentView/CheckoutView,
# but cannot directly inherit from them.
gateway_from_form = AbstractPaymentView.gateway_from_form
_check_customer_ip_address = AbstractPaymentView._check_customer_ip_address
_check_payment_method_nonce = CheckoutView._check_payment_method_nonce
_check_recaptcha = CheckoutView._check_recaptcha
_charge_if_supported = CheckoutView._charge_if_supported
_fetch_or_create_order = CheckoutView._fetch_or_create_order
def get_form_class(self):
"""Override the payment form based on the selected plan variation, before validation."""
if self.plan_variation.collection_method == 'automatic':
return AutomaticPaymentForm
return PaymentForm
def get_initial(self) -> dict:
"""Prefill default payment gateway, country and selected plan options."""
product_type = self.plan_variation.plan.product.type
customer_tax = self.customer.get_tax(product_type=product_type)
taxable = looper.taxes.Taxable(self.plan_variation.price, *customer_tax)
return {
**super().get_initial(),
'price': taxable.price.decimals_string,
'gateway': self.gateway.name,
}
def get_context_data(self, **kwargs) -> dict:
"""Add an extra form and gateway's client token."""
currency = self.get_currency()
ctx = {
**super().get_context_data(**kwargs),
'current_plan_variation': self.plan_variation,
'client_token': self.get_client_token(currency) if self.customer else None,
'subscription': self.subscription,
}
return ctx
def _get_or_create_subscription(
self, gateway: looper.models.Gateway, payment_method: looper.models.PaymentMethod
) -> looper.models.Subscription:
subscription = self._get_existing_subscription()
is_new = False
if not subscription:
subscription = looper.models.Subscription()
is_new = True
self.log.debug('Creating an new subscription for %s, %s', gateway, payment_method)
collection_method = self.plan_variation.collection_method
supported = set(gateway.provider.supported_collection_methods)
if collection_method not in supported:
# FIXME(anna): this breaks plan selector because collection method
# might not match the one selected by the customer.
collection_method = supported.pop()
with transaction.atomic():
subscription.plan = self.plan_variation.plan
subscription.user = self.user
subscription.payment_method = payment_method
# Currency must be set before the price, in case it was changed
subscription.currency = self.plan_variation.currency
subscription.price = self.plan_variation.price
subscription.interval_unit = self.plan_variation.interval_unit
subscription.interval_length = self.plan_variation.interval_length
subscription.collection_method = collection_method
subscription.save()
# Configure the team if this is a team plan
if hasattr(subscription.plan, 'team_properties'):
team_properties = subscription.plan.team_properties
team, team_is_new = subscriptions.models.Team.objects.get_or_create(
subscription=subscription,
seats=team_properties.seats,
)
self.log.info(
'%s a team for subscription pk=%r, seats: %s',
team_is_new and 'Created' or 'Updated',
subscription.pk,
team.seats and team.seats or 'unlimited',
)
self.log.debug('%s subscription pk=%r', is_new and 'Created' or 'Updated', subscription.pk)
return subscription
def form_invalid(self, form):
"""Temporarily log all validation errors."""
logger.exception('Validation error in ConfirmAndPayView: %s', form.errors)
return super().form_invalid(form)
def form_valid(self, form):
"""Handle valid form data.
Confirm and Pay view doesn't update the billing address,
only displays it for use by payment flow and validates it on submit.
The billing address is assumed to be saved at the previous step.
"""
assert self.request.method == 'POST'
response = self._check_recaptcha(form)
if response:
return response
response = self._check_customer_ip_address(form)
if response:
return response
gateway = self.gateway_from_form(form)
payment_method = self._check_payment_method_nonce(form, gateway)
if payment_method is None:
return self.form_invalid(form)
price_cents = int(Decimal(form.cleaned_data['price']) * 100)
subscription = self._get_or_create_subscription(gateway, payment_method)
gateway = form.cleaned_data['gateway']
price_cents = new_taxable.price.cents
subscription = self._get_or_create_subscription(gateway)
# Update the tax info stored on the subscription
subscription.update_tax()
order = self._fetch_or_create_order(form, subscription)
# Update the order to take into account latest changes
if order.payment_method_id != subscription.payment_method_id:
order.switch_payment_method(subscription.payment_method)
order.update()
# Make sure we are charging what we've displayed
price = looper.money.Money(order.price.currency, price_cents)
if order.price != price:
form.add_error('', 'Payment failed: please reload the page and try again')
logger.error("Order price %s doesn't match form price %s", order.price, price)
msg = 'Please reload the page and try again'
messages.warning(self.request, msg)
return self.form_invalid(form)
if not gateway.provider.supports_transactions:
logger.info(
'Not creating transaction for order pk=%r because gateway %r does '
'not support it',
order.pk,
gateway.name,
)
url = reverse(
'looper:transactionless_checkout_done',
kwargs={'pk': order.pk, 'gateway_name': gateway.name},
)
# Trigger an email with instructions about manual payment:
subscription_created_needs_payment.send(sender=subscription)
return redirect(url)
response = self._charge_if_supported(form, gateway, order)
return response
success_url = self.request.build_absolute_uri(
reverse(
'looper:stripe_success',
kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
)
)
# we have to do it to avoid uri-encoding of curly braces,
# otherwise stripe doesn't do the template substitution
success_url = success_url.replace('CHECKOUT_SESSION_ID', '{CHECKOUT_SESSION_ID}', 1)
cancel_url = self.request.build_absolute_uri(self.request.get_full_path())
session = looper.stripe_utils.create_stripe_checkout_session_for_order(
order,
success_url,
cancel_url,
payment_intent_metadata={'order_id': order.pk},
)
return redirect(session.url)

View File

@ -4,7 +4,6 @@ import logging
from django.contrib.auth.mixins import LoginRequiredMixin
from django.forms.utils import ErrorList
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from looper.models import Subscription
@ -35,7 +34,9 @@ class SingleSubscriptionMixin(LoginRequiredMixin):
def get_subscription(self) -> Subscription:
"""Retrieve Subscription object."""
return get_object_or_404(self.request.user.subscription_set, pk=self.subscription_id)
return get_object_or_404(
self.request.user.customer.subscription_set, pk=self.subscription_id
)
def get_context_data(self, **kwargs) -> dict:
"""Add Subscription to the template context."""
@ -45,29 +46,6 @@ class SingleSubscriptionMixin(LoginRequiredMixin):
'subscription': subscription,
}
def dispatch(self, request, *args, **kwargs):
"""Allow the view to do things that rely on auth state before dispatch.
The AnonymousUser instance doesn't have a 'subscriptions' property,
but login checking only happens in the super().dispatch() call.
"""
if not hasattr(request.user, 'subscription_set'):
return self.handle_no_permission()
response = self.pre_dispatch(request, *args, **kwargs)
if response:
return response
return super().dispatch(request, *args, **kwargs)
def pre_dispatch(self, request, *args, **kwargs) -> Optional[HttpResponse]:
"""Called between a permission check and calling super().dispatch().
This allows you to get the current subscription, or otherwise do things
that require the user to be logged in.
:return: None to continue handling this request, or a
HttpResponse to stop processing early.
"""
class BootstrapErrorListMixin:
"""Override get_form method changing error_class of the form."""

View File

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

View File

@ -1,11 +1,7 @@
"""Views handling subscription management."""
from typing import Optional
import logging
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy, reverse
from django.views.generic import UpdateView, FormView
@ -15,8 +11,6 @@ import looper.views.settings
from subscriptions.forms import (
BillingAddressForm,
CancelSubscriptionForm,
ChangePaymentMethodForm,
PayExistingOrderForm,
TeamForm,
)
from subscriptions.views.mixins import SingleSubscriptionMixin, BootstrapErrorListMixin
@ -26,24 +20,13 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
class BillingAddressView(LoginRequiredMixin, UpdateView):
"""Combine looper's Customer and Address into a billing address."""
class BillingAddressView(looper.views.settings.BillingAddressView):
"""Override form class and success URL of looper's view."""
template_name = 'settings/billing_address.html'
model = looper.models.Address
form_class = BillingAddressForm
success_url = reverse_lazy('subscriptions:billing-address')
def _get_customer_object(self) -> Optional[looper.models.Customer]:
if self.request.user.is_anonymous:
return None
return self.request.user.customer
def get_object(self, queryset=None) -> Optional[looper.models.Address]:
"""Get billing address."""
customer = self._get_customer_object()
return customer.billing_address if customer else None
class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
"""Confirm and cancel a subscription."""
@ -68,78 +51,40 @@ class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView):
"""Use the Braintree drop-in UI to switch a subscription's payment method."""
"""Override cancel and success URLs."""
template_name = 'subscriptions/payment_method_change.html'
form_class = ChangePaymentMethodForm
success_url = reverse_lazy('user-settings-billing')
success_url = 'subscriptions:payment-method-change-done'
subscription: looper.models.Subscription
def get_initial(self) -> dict:
"""Modify initial form data."""
initial = super().get_initial()
initial['next_url_after_done'] = self.success_url
# Looper's view uses customer full_name, we don't
initial.pop('full_name', None)
# Only set initial values if they aren't already saved to the billing address.
# Initial values always override form data, which leads to confusing issues with views.
if not (self.customer and self.customer.billing_address.full_name):
# Fall back to user's full name, if no full name set already in the billing address:
if self.request.user.full_name:
initial['full_name'] = self.request.user.full_name
return initial
def form_invalid(self, form):
"""Temporarily log all validation errors."""
logger.exception('Validation error in ChangePaymentMethodForm: %s', form.errors)
return super().form_invalid(form)
def get_cancel_url(self):
"""Return to this subscription's manage page."""
return reverse(
'subscriptions:manage',
kwargs={'subscription_id': self.kwargs['subscription_id']},
)
class PayExistingOrderView(looper.views.checkout.CheckoutExistingOrderView):
class PaymentMethodChangeDoneView(looper.views.settings.PaymentMethodChangeDoneView):
"""Change payment method in response to a successful payment setup."""
@property
def success_url(self):
"""Return to this subscription's manage page."""
return reverse(
'subscriptions:manage',
kwargs={'subscription_id': self.kwargs['subscription_id']},
)
class PayExistingOrderView(looper.views.checkout_stripe.CheckoutExistingOrderView):
"""Override looper's view with our forms."""
# Redirect to LOGIN_URL instead of raising an exception
raise_exception = False
template_name = 'subscriptions/pay_existing_order.html'
form_class = PayExistingOrderForm
success_url = reverse_lazy('user-settings-billing')
def get_initial(self) -> dict:
"""Prefill the payment amount and missing form data, if any."""
initial = {
'price': self.order.price.decimals_string,
'email': self.customer.billing_email,
}
# Only set initial values if they aren't already saved to the billing address.
# Initial values always override form data, which leads to confusing issues with views.
if not (self.customer and self.customer.billing_address.full_name):
# Fall back to user's full name, if no full name set already in the billing address:
if self.request.user.full_name:
initial['full_name'] = self.request.user.full_name
return initial
def form_invalid(self, form):
"""Temporarily log all validation errors."""
logger.exception('Validation error in PayExistingOrderView: %s', form.errors)
return super().form_invalid(form)
def dispatch(self, request, *args, **kwargs):
"""Return 403 unless current session and the order belong to the same user.
Looper renders a template instead, we just want to display the standard 403 page
or redirect to login, like LoginRequiredMixin does with raise_exception=False.
"""
self.order = get_object_or_404(looper.models.Order, pk=kwargs['order_id'])
if request.user.is_authenticated and self.order.user_id != request.user.id:
return HttpResponseForbidden()
self.plan = self.order.subscription.plan
return super(looper.views.checkout.CheckoutExistingOrderView, self).dispatch(
request, *args, **kwargs
)
def get_cancel_url(self):
"""Return to this subscription's manage page."""
order = self.get_object()
return reverse('subscriptions:manage', kwargs={'subscription_id': order.subscription_id})
class ManageSubscriptionView(

View File

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

View File

@ -1,24 +1,27 @@
from typing import Tuple
from unittest.mock import patch
import os
import unittest
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from freezegun import freeze_time
import responses
# from responses import _recorder
from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4, SINGAPORE_IPV4
from looper.money import Money
import looper.models
from common.tests.factories.subscriptions import create_customer_with_billing_address
from common.tests.factories.users import UserFactory
from subscriptions.tests.base import BaseSubscriptionTestCase
from looper.tests.factories import create_customer_with_billing_address
from common.tests.factories.users import UserFactory, OAuthUserInfoFactory
from subscriptions.tests.base import BaseSubscriptionTestCase, responses_from_file
import subscriptions.tasks
import users.tasks
import users.tests.util as util
responses_dir = 'subscriptions/tests/_responses/'
required_address_data = {
'country': 'NL',
'email': 'my.billing.email@example.com',
@ -36,15 +39,27 @@ full_billing_address_data = {
# **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database.
def _get_default_variation(currency='USD'):
return looper.models.Plan.objects.first().variation_for_currency(currency)
@freeze_time('2023-05-19 11:41:11')
class TestGETBillingDetailsView(BaseSubscriptionTestCase):
class TestGETJoinView(BaseSubscriptionTestCase):
url_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1})
url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2})
def test_pay_via_bank_transfer_button_is_shown_for_manual_plan_variation(self):
customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
user = customer.user
self.client.force_login(user)
url, selected_variation = self._get_url_for(
currency='EUR',
interval_length=1,
interval_unit='month',
plan__name='Manual renewal',
)
response = self.client.get(url, REMOTE_ADDR=EURO_IPV4)
self.assertEqual(response.status_code, 200)
self._assert_pay_via_bank_displayed(response)
def test_get_prefills_full_name_and_billing_email_from_user(self):
user = UserFactory(full_name="Jane До", email='jane.doe@example.com')
self.client.force_login(user)
@ -53,6 +68,7 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
self.assertEqual(response.status_code, 200)
self._assert_total_default_variation_selected_tax_21_eur(response)
self._assert_pay_via_bank_not_displayed(response)
self.assertContains(
response,
'<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):
user = create_customer_with_billing_address(vat_number='', country='NL')
customer = create_customer_with_billing_address(vat_number='', country='NL')
user = customer.user
self.client.force_login(user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
@ -76,7 +93,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
self._assert_total_default_variation_selected_tax_21_eur(response)
def test_get_displays_total_and_billing_details_to_logged_in_de(self):
user = create_customer_with_billing_address(vat_number='', country='DE')
customer = create_customer_with_billing_address(vat_number='', country='DE')
user = customer.user
self.client.force_login(user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
@ -86,9 +104,10 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
self._assert_total_default_variation_selected_tax_19_eur(response)
def test_get_displays_total_and_billing_details_to_logged_in_us(self):
user = create_customer_with_billing_address(
customer = create_customer_with_billing_address(
vat_number='', country='US', region='NY', postal_code='12001'
)
user = customer.user
self.client.force_login(user)
response = self.client.get(self.url_usd)
@ -99,16 +118,23 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
self._assert_total_default_variation_selected_usd(response)
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
def test_get_detects_country_us_sets_preferred_currency_usd_invalid_variation(self):
user = create_customer_with_billing_address()
def test_get_detects_country_us_sets_preferred_currency_usd_and_redirects(self):
customer = create_customer_with_billing_address()
user = customer.user
self.client.force_login(user)
self.assertTrue(looper.middleware.PREFERRED_CURRENCY_SESSION_KEY not in self.client.session)
response = self.client.get(self.url, REMOTE_ADDR=USA_IPV4)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], self.url_usd)
self.assertEqual(
self.client.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY], 'USD'
)
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
def test_get_detects_country_us_sets_preferred_currency_usd(self):
user = create_customer_with_billing_address()
customer = create_customer_with_billing_address()
user = customer.user
self.client.force_login(user)
response = self.client.get(self.url_usd, REMOTE_ADDR=USA_IPV4)
@ -125,7 +151,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
def test_get_detects_country_sg_sets_preferred_currency_eur(self):
user = create_customer_with_billing_address()
customer = create_customer_with_billing_address()
user = customer.user
self.client.force_login(user)
response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4)
@ -142,7 +169,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
def test_get_detects_country_nl_sets_preferred_currency_eur_displays_correct_vat(self):
user = create_customer_with_billing_address()
customer = create_customer_with_billing_address()
user = customer.user
self.client.force_login(user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
@ -156,14 +184,42 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
)
self._assert_total_default_variation_selected_tax_21_eur(response)
def test_plan_variation_matches_detected_currency_eur_non_eea_ip(self):
customer = create_customer_with_billing_address()
self.client.force_login(customer.user)
response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4)
self.assertEqual(response.status_code, 200)
# Check that prices are in EUR and there is no tax
self._assert_total_default_variation_selected_no_tax_eur(response)
def test_billing_address_country_takes_precedence_over_geo_ip(self):
customer = create_customer_with_billing_address(country='NL')
self.client.force_login(customer.user)
response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4)
self.assertEqual(response.status_code, 200)
self._assert_total_default_variation_selected_tax_21_eur(response)
@freeze_time('2023-05-19 11:41:11')
class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
class TestPOSTJoinView(BaseSubscriptionTestCase):
url_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1})
url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2})
cs_url = 'https://checkout.stripe.com/c/pay/cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl'
cs_id = 'cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW'
def setUp(self):
super().setUp()
responses._add_from_file(f'{responses_dir}vies_wsdl.yaml')
responses._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
def test_post_updates_billing_address_and_customer_renders_next_form_de(self):
user = create_customer_with_billing_address(vat_number='', country='DE')
customer = create_customer_with_billing_address(vat_number='', country='DE')
user = customer.user
self.client.force_login(user)
selected_variation = (
@ -174,7 +230,7 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
)
.first()
)
data = full_billing_address_data
data = {**full_billing_address_data, 'gateway': 'stripe'}
url = reverse(
'subscriptions:join-billing-details',
kwargs={'plan_variation_id': selected_variation.pk},
@ -197,51 +253,36 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
self.assertContains(response, 'Manual ')
self.assertContains(response, '/ <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)
default_variation = _get_default_variation('EUR')
data = required_address_data
data = {**required_address_data, 'gateway': 'stripe'}
response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response['Location'],
reverse(
'subscriptions:join-confirm-and-pay',
kwargs={'plan_variation_id': default_variation.pk},
),
)
# Follow the redirect to avoid "Couldn't retrieve content: Response code was 302 (expected 200)"
response = self.client.get(response['Location'])
# Check that we are no longer on the billing details page
self._assert_payment_form_displayed(response)
self._assert_total_default_variation_selected_tax_21_eur(response)
# The hidden price field must also be set to a matching amount
self.assertContains(
response,
'<input type="hidden" name="price" value="9.90" class="form-control" id="id_price">',
html=True,
)
self.assertEqual(response['Location'], self.cs_url)
@responses.activate
def test_post_updates_billing_address_and_customer_applies_reverse_charged_tax(self):
responses._add_from_file(f'{responses_dir}vies_valid.yaml')
self.client.force_login(self.user)
data = {
**required_address_data,
'vat_number': 'DE 260543043',
'gateway': 'stripe',
'country': 'DE',
'postal_code': '11111',
'vat_number': 'DE 260543043',
}
response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertEqual(self.user.customer.vat_number, 'DE260543043')
address = self.user.customer.billing_address
address.refresh_from_db()
self.assertEqual(address.vat_number, 'DE260543043')
self.assertEqual(address.full_name, 'New Full Name')
self.assertEqual(address.postal_code, '11111')
self.assertEqual(address.country, 'DE')
@ -255,23 +296,14 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
# Post the same form again
response = self.client.post(self.url, data)
self.assertEqual(response.status_code, 302)
# Follow the redirect to avoid unexpected assertion errors
response = self.client.get(response['Location'])
# Check that we are no longer on the billing details page
self._assert_payment_form_displayed(response)
# The hidden price field must also be set to a matching amount
self.assertContains(
response,
'<input type="hidden" name="price" value="8.32" class="form-control" id="id_price">',
html=True,
)
self.assertEqual(response['Location'], self.cs_url, response['Location'])
def test_post_changing_address_from_with_region_to_without_region_clears_region(self):
user = create_customer_with_billing_address(
customer = create_customer_with_billing_address(
vat_number='', country='US', region='NY', postal_code='12001'
)
user = customer.user
self.client.force_login(user)
response = self.client.get(self.url_usd)
@ -285,6 +317,7 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
# Post an new address that doesn't require a region
data = {
**required_address_data,
'gateway': 'stripe',
'country': 'DE',
'postal_code': '11111',
}
@ -304,94 +337,51 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
self.assertEqual(user.customer.billing_address.country, 'DE')
self.assertEqual(user.customer.billing_address.postal_code, '11111')
@freeze_time('2023-05-19 11:41:11')
class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
def _get_url_for(self, **filter_params) -> Tuple[str, looper.models.PlanVariation]:
plan_variation = looper.models.PlanVariation.objects.active().get(**filter_params)
return (
reverse(
'subscriptions:join-confirm-and-pay',
kwargs={'plan_variation_id': plan_variation.pk},
),
plan_variation,
)
def test_plan_variation_does_not_match_detected_currency_usd_euro_ip(self):
url, _ = self._get_url_for(currency='USD', price=11900)
user = create_customer_with_billing_address(country='NL')
customer = create_customer_with_billing_address(country='NL')
user = customer.user
self.client.force_login(user)
data = required_address_data
data = {**required_address_data, 'gateway': 'stripe'}
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
self.assertEqual(response.status_code, 404)
def test_plan_variation_matches_detected_currency_eur_non_eea_ip(self):
url, _ = self._get_url_for(currency='EUR', price=990)
user = create_customer_with_billing_address()
self.client.force_login(user)
data = required_address_data
response = self.client.post(url, data, REMOTE_ADDR=SINGAPORE_IPV4)
self.assertEqual(response.status_code, 200)
# Check that prices are in EUR and there is no tax
self._assert_total_default_variation_selected_no_tax_eur(response)
self.assertEqual(response.status_code, 302)
expected_url, _ = self._get_url_for(currency='EUR', price=10900)
self.assertEqual(response['Location'], expected_url)
def test_billing_address_country_takes_precedence_over_geo_ip(self):
url, _ = self._get_url_for(currency='EUR', price=990)
user = create_customer_with_billing_address(country='NL')
self.client.force_login(user)
customer = create_customer_with_billing_address(country='GE')
self.client.force_login(customer.user)
data = required_address_data
response = self.client.post(url, data, REMOTE_ADDR=SINGAPORE_IPV4)
data = {**required_address_data, 'gateway': 'stripe'}
response = self.client.post(self.url, data, REMOTE_ADDR=SINGAPORE_IPV4)
self.assertEqual(response.status_code, 200)
self._assert_total_default_variation_selected_tax_21_eur(response)
def test_invalid_missing_required_fields(self):
url, _ = self._get_url_for(currency='EUR', price=990)
user = create_customer_with_billing_address(country='NL')
self.client.force_login(user)
customer = create_customer_with_billing_address(country='NL')
self.client.force_login(customer.user)
data = required_address_data
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
response = self.client.post(self.url, {}, REMOTE_ADDR=EURO_IPV4)
self.assertEqual(response.status_code, 200)
self._assert_total_default_variation_selected_tax_21_eur(response)
self.assertEqual(
response.context['form'].errors,
{
'country': ['This field is required.'],
'email': ['This field is required.'],
'full_name': ['This field is required.'],
'gateway': ['This field is required.'],
'payment_method_nonce': ['This field is required.'],
'price': ['This field is required.'],
},
)
def test_invalid_price_does_not_match_selected_plan_variation(self):
url, selected_variation = self._get_url_for(currency='EUR', price=990)
user = create_customer_with_billing_address(country='NL')
self.client.force_login(user)
data = {
**required_address_data,
'gateway': 'braintree',
'payment_method_nonce': 'fake-valid-nonce',
'price': '999.09',
}
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
self.assertEqual(response.status_code, 200)
self._assert_total_default_variation_selected_tax_21_eur(response)
self.assertEqual(
response.context['form'].errors,
{'__all__': ['Payment failed: please reload the page and try again']},
)
def test_invalid_bank_transfer_cannot_be_selected_for_automatic_payments(self):
url, selected_variation = self._get_url_for(currency='EUR', price=990)
user = create_customer_with_billing_address(country='NL')
customer = create_customer_with_billing_address(country='NL')
user = customer.user
self.client.force_login(user)
data = {
@ -423,7 +413,9 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
)
@responses.activate
def test_pay_with_bank_transfer_creates_order_subscription_on_hold(self):
user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
customer = create_customer_with_billing_address(country='NL')
user = customer.user
OAuthUserInfoFactory(user=user, oauth_user_id=554433)
self.client.force_login(user)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
@ -435,17 +427,12 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
interval_unit='month',
plan__name='Manual renewal',
)
data = {
**required_address_data,
'gateway': 'bank',
'payment_method_nonce': 'unused',
'price': '14.90',
}
data = {**required_address_data, 'full_name': 'Jane Doe', 'gateway': 'bank'}
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
self._assert_transactionless_done_page_displayed(response)
subscription = user.subscription_set.first()
subscription = user.customer.subscription_set.first()
self.assertEqual(subscription.status, 'on-hold')
self.assertEqual(subscription.price, Money('EUR', 1490))
self.assertEqual(subscription.tax, Money('EUR', 259))
@ -500,9 +487,17 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
def test_pay_with_bank_transfer_creates_order_subscription_on_hold_shows_reverse_charged_price(
self,
):
user = create_customer_with_billing_address(
country='ES', full_name='Jane Doe', vat_number='DE260543043'
)
responses._add_from_file(f'{responses_dir}vies_valid.yaml')
address = {
**required_address_data,
'country': 'ES',
'full_name': 'Jane Doe',
'postal_code': '11111',
'vat_number': 'ES A78374725',
}
customer = create_customer_with_billing_address(**address)
user = customer.user
OAuthUserInfoFactory(user=user, oauth_user_id=554433)
self.client.force_login(user)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
@ -514,17 +509,12 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
interval_length=3,
interval_unit='month',
)
data = {
**required_address_data,
'gateway': 'bank',
'payment_method_nonce': 'unused',
'price': '26.45',
}
data = {'gateway': 'bank', **address}
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
self._assert_transactionless_done_page_displayed(response)
subscription = user.subscription_set.first()
subscription = user.customer.subscription_set.first()
self.assertEqual(subscription.status, 'on-hold')
self.assertEqual(subscription.price, Money('EUR', 3200))
self.assertEqual(subscription.tax, Money('EUR', 0))
@ -533,6 +523,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self.assertEqual(subscription.collection_method, selected_variation.collection_method)
self.assertEqual(subscription.collection_method, 'manual')
self.assertEqual(subscription.plan, selected_variation.plan)
self.assertEqual(str(subscription.payment_method), 'Bank Transfer')
self.assertIsNone(subscription.payment_method.token)
order = subscription.latest_order()
self.assertEqual(order.status, 'created')
@ -545,6 +537,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self.assertIsNotNone(order.display_number)
self.assertIsNotNone(order.vat_number)
self.assertNotEqual(order.display_number, str(order.pk))
self.assertEqual(str(order.payment_method), 'Bank Transfer')
self.assertIsNone(order.payment_method.token)
self._assert_bank_transfer_email_is_sent(subscription)
self._assert_bank_transfer_email_is_sent_tax_21_eur_reverse_charged(subscription)
@ -553,6 +547,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
response = self.client.get(
reverse('subscriptions:manage', kwargs={'subscription_id': subscription.pk})
)
self.assertNotIn('32.00', response)
self.assertNotIn('21%', response)
self.assertNotIn('Inc.', response)
@ -581,34 +576,44 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
'users.signals.tasks.grant_blender_id_role',
new=users.tasks.grant_blender_id_role.task_function,
)
@responses.activate
def test_pay_with_credit_card_creates_order_subscription_active(self):
url, selected_variation = self._get_url_for(currency='EUR', price=990)
user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
user = customer.user
OAuthUserInfoFactory(user=user, oauth_user_id=554433)
self.client.force_login(user)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id
)
data = {
**required_address_data,
'gateway': 'braintree',
# fake-three-d-secure-visa-full-authentication-nonce
# causes the following error:
# Merchant account must match the 3D Secure authorization merchant account: code 91584
# TODO(anna): figure out if this is due to our settings or a quirk of the sandbox
'payment_method_nonce': 'fake-valid-nonce',
'price': '9.90',
}
data = {**required_address_data, 'gateway': 'stripe'}
with responses.RequestsMock() as rsps:
rsps._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], self.cs_url, response['Location'])
# **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
# Pretend that checkout session was completed and we've returned to the success page with its ID:
subscription = user.customer.subscription_set.first()
order = subscription.latest_order()
url = reverse(
'looper:stripe_success',
kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
)
url = url.replace('CHECKOUT_SESSION_ID', self.cs_id)
with responses.RequestsMock() as rsps:
responses_from_file('stripe_get_cs_eur.yaml', order_id=order.pk, rsps=rsps)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id, rsps
)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id, rsps
)
response = self.client.get(url)
self._assert_done_page_displayed(response)
subscription = user.subscription_set.first()
order = subscription.latest_order()
subscription.refresh_from_db()
order.refresh_from_db()
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.price, Money('EUR', 990))
self.assertEqual(subscription.collection_method, selected_variation.collection_method)
@ -629,33 +634,47 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
'users.signals.tasks.grant_blender_id_role',
new=users.tasks.grant_blender_id_role.task_function,
)
@responses.activate
def test_pay_with_credit_card_creates_order_subscription_active_team(self):
url, selected_variation = self._get_url_for(
currency='EUR',
price=9000,
plan__name='Automatic renewal, 15 seats',
)
user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
user = customer.user
OAuthUserInfoFactory(user=user, oauth_user_id=554433)
self.client.force_login(user)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id
)
data = {
**required_address_data,
'gateway': 'braintree',
'payment_method_nonce': 'fake-valid-nonce',
'price': '90.00',
}
data = {**required_address_data, 'gateway': 'stripe'}
with responses.RequestsMock() as rsps:
rsps._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], self.cs_url, response['Location'])
# **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
# Pretend that checkout session was completed and we've returned to the success page with its ID:
subscription = user.customer.subscription_set.first()
order = subscription.latest_order()
url = reverse(
'looper:stripe_success',
kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
)
url = url.replace('CHECKOUT_SESSION_ID', self.cs_id)
with responses.RequestsMock() as rsps:
responses_from_file('stripe_get_cs_eur.yaml', order_id=order.pk, rsps=rsps)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id, rsps
)
util.mock_blender_id_badger_badger_response(
'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id, rsps
)
response = self.client.get(url)
self._assert_done_page_displayed(response)
subscription = user.subscription_set.first()
subscription = customer.subscription_set.first()
order = subscription.latest_order()
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.price, Money('EUR', 9000))
@ -670,7 +689,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self._assert_team_subscription_activated_email_is_sent(subscription)
def test_pay_with_credit_card_creates_order_subscription_active_business_de(self):
user = create_customer_with_billing_address(country='DE', vat_number='DE260543043')
customer = create_customer_with_billing_address(country='DE', vat_number='DE260543043')
user = customer.user
self.client.force_login(user)
url, selected_variation = self._get_url_for(
@ -681,19 +701,46 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
)
data = {
**required_address_data,
'vat_number': 'DE 260543043',
'gateway': 'stripe',
'country': 'DE',
'postal_code': '11111',
'gateway': 'braintree',
'payment_method_nonce': 'fake-valid-nonce',
# VAT is subtracted from the plan variation price:
'price': '12.52',
'vat_number': 'DE 260543043',
}
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
# @_recorder.record(file_path=f'{responses_dir}stripe_new_cs_eur.yaml')
def _continue_to_payment(): # noqa: E306
return self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
# request to wsdl doesn't happen on subsequent calls to the validator,
# hence assert_all_requests_are_fired = False.
rsps._add_from_file(f'{responses_dir}vies_wsdl.yaml')
rsps._add_from_file(f'{responses_dir}vies_valid.yaml')
rsps._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
response = _continue_to_payment()
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], self.cs_url, response['Location'])
# **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
# Pretend that checkout session was completed and we've returned to the success page with its ID:
subscription = user.customer.subscription_set.first()
order = subscription.latest_order()
url = reverse(
'looper:stripe_success',
kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
)
url = url.replace('CHECKOUT_SESSION_ID', self.cs_id)
# @_recorder.record(file_path=f'{responses_dir}stripe_get_cs_eur.yaml')
def _back_to_success_url(): # noqa: E306
return self.client.get(url)
with responses.RequestsMock() as rsps:
responses_from_file('stripe_get_cs_eur.yaml', order_id=order.pk, rsps=rsps)
response = _back_to_success_url()
self._assert_done_page_displayed(response)
subscription = user.subscription_set.first()
subscription.refresh_from_db()
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.price, Money('EUR', 1490))
self.assertEqual(subscription.tax, Money('EUR', 0))
@ -716,15 +763,17 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self.assertIsNotNone(order.number)
class TestJoinConfirmAndPayLoggedInUserOnlyView(BaseSubscriptionTestCase):
url = reverse('subscriptions:join-confirm-and-pay', kwargs={'plan_variation_id': 8})
class TestJoinViewLoggedInUserOnly(TestCase):
url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2})
def test_get_anonymous_403(self):
def test_get_anonymous_redirects_to_login_with_next(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], '/oauth/login?next=/join/plan-variation/2/billing/')
def test_join_post_anonymous_403(self):
def test_post_anonymous_redirects_to_login_with_next(self):
response = self.client.post(self.url)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], '/oauth/login?next=/join/plan-variation/2/billing/')

View File

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

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 common.tests.factories.subscriptions import create_customer_with_billing_address
from looper.tests.factories import create_customer_with_billing_address
from subscriptions.tests.base import BaseSubscriptionTestCase
# **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database.
@ -49,8 +49,8 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase):
self._assert_default_variation_selected_no_tax_usd(response)
def test_get_displays_plan_selection_to_logged_in_nl(self):
user = create_customer_with_billing_address(vat_number='', country='NL')
self.client.force_login(user)
customer = create_customer_with_billing_address(vat_number='', country='NL')
self.client.force_login(customer.user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
@ -60,8 +60,8 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase):
self._assert_default_variation_selected_tax_21_eur(response)
def test_get_displays_plan_selection_to_logged_in_de(self):
user = create_customer_with_billing_address(vat_number='', country='DE')
self.client.force_login(user)
customer = create_customer_with_billing_address(vat_number='', country='DE')
self.client.force_login(customer.user)
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
@ -71,10 +71,10 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase):
self._assert_default_variation_selected_tax_19_eur(response)
def test_get_displays_plan_selection_to_logged_in_us(self):
user = create_customer_with_billing_address(
customer = create_customer_with_billing_address(
vat_number='', country='US', region='NY', postal_code='12001'
)
self.client.force_login(user)
self.client.force_login(customer.user)
response = self.client.get(self.url)
@ -84,10 +84,10 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase):
self._assert_plan_selector_no_tax(response)
def test_get_team_displays_plan_selection_to_logged_in_us(self):
user = create_customer_with_billing_address(
customer = create_customer_with_billing_address(
vat_number='', country='US', region='NY', postal_code='12001'
)
self.client.force_login(user)
self.client.force_login(customer.user)
response = self.client.get(self.url_team)

View File

@ -4,12 +4,16 @@ from django.urls import reverse
from looper.models import PaymentMethod, PaymentMethodAuthentication, Gateway
from looper.money import Money
from looper.tests.factories import SubscriptionFactory
import responses
# from responses import _recorder
from common.tests.factories.users import UserFactory
from common.tests.factories.subscriptions import SubscriptionFactory
from subscriptions.tests.base import BaseSubscriptionTestCase
from subscriptions.tests.base import BaseSubscriptionTestCase, responses_from_file
import subscriptions.tasks
responses_dir = 'subscriptions/tests/_responses/'
required_address_data = {
'country': 'NL',
'email': 'my.billing.email@example.com',
@ -27,16 +31,17 @@ full_billing_address_data = {
class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
def test_saves_both_address_and_customer(self):
url = reverse('subscriptions:billing-address')
def test_saves_full_billing_address(self):
user = UserFactory()
self.client.force_login(user)
url = reverse('subscriptions:billing-address')
response = self.client.post(url, full_billing_address_data)
response = self.client.post(self.url, full_billing_address_data)
# Check that the redirect on success happened
self.assertEqual(response.status_code, 302, response.content)
self.assertEqual(response['Location'], url)
self.assertEqual(response['Location'], self.url)
# Check that all address fields were updated
customer = user.customer
@ -51,15 +56,14 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
self.assertEqual(address.company, 'Test LLC')
# Check that customer fields were updated as well
self.assertEqual(customer.vat_number, 'NL818152011B01')
# N.B.: email is saved as Customer.billing_email
self.assertEqual(customer.billing_email, 'my.billing.email@example.com')
self.assertEqual(customer.billing_address.vat_number, 'NL818152011B01')
self.assertEqual(customer.billing_address.email, 'my.billing.email@example.com')
def test_invalid_missing_required_fields(self):
user = UserFactory()
self.client.force_login(user)
response = self.client.post(reverse('subscriptions:billing-address'), {})
response = self.client.post(self.url, {})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist')
@ -72,7 +76,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
data = {
'email': 'new@example.com',
}
response = self.client.post(reverse('subscriptions:billing-address'), data)
response = self.client.post(self.url, data)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist')
@ -85,7 +89,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
data = {
'full_name': 'New Full Name',
}
response = self.client.post(reverse('subscriptions:billing-address'), data)
response = self.client.post(self.url, data)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist')
@ -100,11 +104,13 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
url_name = 'subscriptions:payment-method-change'
success_url_name = 'user-settings-billing'
# @_recorder.record(file_path=f'{responses_dir}stripe_new_cs_setup.yaml')
# @_recorder.record(file_path=f'{responses_dir}stripe_get_cs_setup.yaml')
def test_can_change_payment_method_from_bank_to_credit_card_with_sca(self):
bank = Gateway.objects.get(name='bank')
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=bank,
)
self.assertEqual(PaymentMethod.objects.count(), 1)
@ -113,15 +119,24 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
self.client.force_login(self.user)
url = reverse(self.url_name, kwargs={'subscription_id': subscription.pk})
data = {
**self.shared_payment_form_data,
'gateway': 'braintree',
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
}
response = self.client.post(url, data=data)
with responses.RequestsMock() as rsps:
rsps._add_from_file(f'{responses_dir}stripe_new_cs_setup.yaml')
response = self.client.post(url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], reverse(self.success_url_name))
expected_redirect_url = 'https://checkout.stripe.com/c/pay/cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBaZmppcGhrJyknYGtkZ2lgVWlkZmBtamlhYHd2Jz9xd3BgeCUl'
self.assertEqual(response['Location'], expected_redirect_url, response['Location'])
# **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
# Pretend that checkout session was completed and we've returned to the success page with its ID:
checkout_session_id = 'cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9'
success_url = url + f'{checkout_session_id}/'
with responses.RequestsMock() as rsps:
rsps._add_from_file(f'{responses_dir}stripe_get_cs_setup.yaml')
response = self.client.get(success_url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], f'/subscription/{subscription.pk}/manage/')
# New payment method was created
self.assertEqual(PaymentMethod.objects.count(), 2)
@ -131,40 +146,9 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
self.assertNotEqual(subscription.payment_method_id, payment_method.pk)
self.assertEqual(
str(subscription.payment_method),
'Visa credit card ending in 0002',
'visa credit card ending in 4242',
)
# SCA was stored
self.assertIsNotNone(PaymentMethodAuthentication.objects.first())
def test_can_change_payment_method_from_credit_card_to_bank(self):
braintree = Gateway.objects.get(name='braintree')
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
payment_method__gateway=braintree,
)
self.assertEqual(PaymentMethod.objects.count(), 1)
payment_method = subscription.payment_method
self.client.force_login(self.user)
url = reverse(self.url_name, kwargs={'subscription_id': subscription.pk})
data = {
**self.shared_payment_form_data,
'gateway': 'bank',
'payment_method_nonce': 'unused',
}
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], reverse(self.success_url_name))
# New payment method was created
self.assertEqual(PaymentMethod.objects.count(), 2)
subscription.refresh_from_db()
subscription.payment_method.refresh_from_db()
# Subscription's payment method was changed to bank transfer
self.assertNotEqual(subscription.payment_method_id, payment_method.pk)
self.assertEqual(str(subscription.payment_method), 'Bank Transfer')
# SCA isn't stored for Stripe payment methods
self.assertIsNone(PaymentMethodAuthentication.objects.first())
@ -173,8 +157,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
def test_can_cancel_when_on_hold(self):
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold',
)
@ -198,8 +182,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
def test_can_cancel_when_active(self):
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='active',
)
@ -218,8 +202,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
def test_email_sent_when_pending_cancellation_changes_to_cancelled(self):
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='pending-cancellation',
)
@ -240,12 +224,11 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
class TestPayExistingOrder(BaseSubscriptionTestCase):
url_name = 'subscriptions:pay-existing-order'
success_url_name = 'user-settings-billing'
def test_redirect_to_login_when_anonymous(self):
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold',
)
@ -253,19 +236,15 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
self.client.logout()
url = reverse(self.url_name, kwargs={'order_id': order.pk})
data = {
'gateway': 'braintree',
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
}
response = self.client.post(url, data=data)
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], f'/oauth/login?next={url}')
def test_cannot_pay_someone_elses_order(self):
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold',
)
@ -274,40 +253,12 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
self.client.force_login(user)
url = reverse(self.url_name, kwargs={'order_id': order.pk})
data = {
'gateway': 'braintree',
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
}
response = self.client.post(url, data=data)
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_invalid_missing_required_form_data(self):
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold',
)
order = subscription.generate_order()
self.client.force_login(self.user)
url = reverse(self.url_name, kwargs={'order_id': order.pk})
response = self.client.post(url, data={})
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.context['form'].errors,
{
# 'full_name': ['This field is required.'],
# 'country': ['This field is required.'],
# 'email': ['This field is required.'],
'payment_method_nonce': ['This field is required.'],
'gateway': ['This field is required.'],
'price': ['This field is required.'],
},
)
self.assertEqual(response.status_code, 404)
# @_recorder.record(file_path=f'{responses_dir}stripe_new_cs_usd.yaml')
# @_recorder.record(file_path=f'{responses_dir}stripe_get_cs_usd.yaml')
@patch(
# Make sure background task is executed as a normal function
'subscriptions.signals.tasks.send_mail_subscription_status_changed',
@ -315,26 +266,39 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
)
def test_can_pay_for_manual_subscription_with_an_order(self):
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
plan__name='Automatic renewal subscription',
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
currency='USD',
price=Money('USD', 1110),
status='on-hold',
)
self.assertEqual(subscription.collection_method, 'automatic')
order = subscription.generate_order()
self.client.force_login(self.user)
url = reverse(self.url_name, kwargs={'order_id': order.pk})
data = {
**required_address_data,
'price': order.price,
'gateway': 'braintree',
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
}
response = self.client.post(url, data=data)
with responses.RequestsMock() as rsps:
rsps._add_from_file(f'{responses_dir}stripe_new_cs_usd.yaml')
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
expected_redirect_url = 'https://checkout.stripe.com/c/pay/cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl'
self.assertEqual(response['Location'], expected_redirect_url)
# **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
# Pretend that checkout session was completed and we've returned to the success page with its ID:
checkout_session_id = 'cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w'
url = reverse(
'looper:stripe_success',
kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
)
url = url.replace('CHECKOUT_SESSION_ID', checkout_session_id)
with responses.RequestsMock() as rsps:
responses_from_file('stripe_get_cs_usd.yaml', order_id=order.pk, rsps=rsps)
response = self.client.get(url)
self.assertEqual(order.transaction_set.count(), 1)
transaction = order.latest_transaction()
self.assertEqual(
@ -350,7 +314,7 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
self.assertNotEqual(subscription.payment_method, 'bank')
self.assertEqual(
str(subscription.payment_method),
'Visa credit card ending in 0002',
'PayPal account billing@example.com',
)
self.assertEqual(subscription.status, 'active')

View File

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

View File

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

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):
"""Remove emails of user with given pk from newsletter lists."""
user = User.objects.get(pk=pk)
emails = (user.email, user.customer.billing_email)
emails = (user.email, user.customer.billing_address.email)
for email in emails:
if not email:
continue
@ -135,7 +135,7 @@ def handle_deletion_request(pk: int) -> bool:
try:
unsubscribe_from_newsletters(pk=pk)
except Exception:
logger.warning('Error while trying to unsubscribe user pk=%s from newsletters')
logger.warning('Error while trying to unsubscribe user pk=%s from newsletters', pk)
user.anonymize_or_delete()
return True

View File

@ -1,4 +1,5 @@
{% extends 'common/base.html' %}
{% load pipeline %}
{% block nav_drawer_inner %}
{% include 'users/settings/tabs.html' %}
@ -27,3 +28,7 @@
</div>
</div>
{% endblock content %}
{% block scripts %}
{% javascript "subscriptions" %}
{% endblock scripts %}

View File

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

View File

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