Stripe checkout #104411
@ -49,6 +49,8 @@ MAILGUN_API_KEY=
|
|||||||
MAILGUN_WEBHOOK_SIGNING_KEY=
|
MAILGUN_WEBHOOK_SIGNING_KEY=
|
||||||
MAILGUN_WEBHOOK_SECRET=
|
MAILGUN_WEBHOOK_SECRET=
|
||||||
|
|
||||||
GOOGLE_RECAPTCHA_SITE_KEY=
|
|
||||||
GOOGLE_RECAPTCHA_SECRET_KEY=
|
|
||||||
GOOGLE_ANALYTICS_TRACKING_ID=
|
GOOGLE_ANALYTICS_TRACKING_ID=
|
||||||
|
|
||||||
|
STRIPE_API_PUBLISHABLE_KEY=
|
||||||
|
STRIPE_API_SECRET_KEY=
|
||||||
|
STRIPE_ENDPOINT_SECRET=
|
||||||
|
@ -174,7 +174,7 @@ class TestCharacterVersionDownload(TestCase):
|
|||||||
|
|
||||||
def test_can_download_non_free_when_subscribed(self):
|
def test_can_download_non_free_when_subscribed(self):
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
SubscriptionFactory(user=user, status='active')
|
SubscriptionFactory(customer=user.customer, status='active')
|
||||||
character_version = CharacterVersionFactory(is_free=False)
|
character_version = CharacterVersionFactory(is_free=False)
|
||||||
|
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
@ -222,7 +222,7 @@ class TestCharacterShowcaseDownload(TestCase):
|
|||||||
|
|
||||||
def test_can_download_non_free_when_subscribed(self):
|
def test_can_download_non_free_when_subscribed(self):
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
SubscriptionFactory(user=user, status='active')
|
SubscriptionFactory(customer=user.customer, status='active')
|
||||||
character_showcase = CharacterShowcaseFactory(is_free=False)
|
character_showcase = CharacterShowcaseFactory(is_free=False)
|
||||||
|
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
@ -28,5 +28,4 @@ def extra_context(request: HttpRequest) -> Dict[str, str]:
|
|||||||
},
|
},
|
||||||
'canonical_url': request.build_absolute_uri(request.path),
|
'canonical_url': request.build_absolute_uri(request.path),
|
||||||
'ADMIN_MAIL': settings.ADMIN_MAIL,
|
'ADMIN_MAIL': settings.ADMIN_MAIL,
|
||||||
'GOOGLE_RECAPTCHA_SITE_KEY': settings.GOOGLE_RECAPTCHA_SITE_KEY,
|
|
||||||
}
|
}
|
||||||
|
@ -714,3 +714,19 @@ button,
|
|||||||
&.rounded-lg
|
&.rounded-lg
|
||||||
.plyr
|
.plyr
|
||||||
border-radius: var(--border-radius-lg)
|
border-radius: var(--border-radius-lg)
|
||||||
|
|
||||||
|
|
||||||
|
/* Simple CSS-only tooltips. */
|
||||||
|
[data-tooltip]
|
||||||
|
&:hover
|
||||||
|
&:before, &:after
|
||||||
|
display: block
|
||||||
|
position: absolute
|
||||||
|
color: var(--color-text-primary)
|
||||||
|
&:before
|
||||||
|
border-radius: var(--spacer-1)
|
||||||
|
content: attr(title)
|
||||||
|
background-color: var(--color-bg-primary-subtle)
|
||||||
|
margin-top: var(--spacer)
|
||||||
|
padding: var(--spacer)
|
||||||
|
font-size: var(--fs-sm)
|
||||||
|
@ -1,53 +1,10 @@
|
|||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.db.models import signals
|
|
||||||
|
|
||||||
from factory.django import DjangoModelFactory
|
from factory.django import DjangoModelFactory
|
||||||
import factory
|
import factory
|
||||||
|
|
||||||
import looper.models
|
from looper.tests.factories import SubscriptionFactory
|
||||||
|
|
||||||
from common.tests.factories.users import UserFactory
|
|
||||||
from subscriptions.models import Team
|
from subscriptions.models import Team
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentMethodFactory(DjangoModelFactory):
|
|
||||||
class Meta:
|
|
||||||
model = looper.models.PaymentMethod
|
|
||||||
|
|
||||||
gateway_id = factory.LazyAttribute(lambda _: looper.models.Gateway.objects.first().pk)
|
|
||||||
|
|
||||||
user = factory.SubFactory('common.tests.factories.users.UserFactory')
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionFactory(DjangoModelFactory):
|
|
||||||
class Meta:
|
|
||||||
model = looper.models.Subscription
|
|
||||||
|
|
||||||
plan_id = factory.LazyAttribute(lambda _: looper.models.Plan.objects.first().pk)
|
|
||||||
payment_method = factory.SubFactory(PaymentMethodFactory)
|
|
||||||
|
|
||||||
user = factory.SubFactory('common.tests.factories.users.UserFactory')
|
|
||||||
|
|
||||||
|
|
||||||
class OrderFactory(DjangoModelFactory):
|
|
||||||
class Meta:
|
|
||||||
model = looper.models.Order
|
|
||||||
|
|
||||||
user = factory.SubFactory('common.tests.factories.users.UserFactory')
|
|
||||||
subscription = factory.SubFactory(SubscriptionFactory)
|
|
||||||
payment_method = factory.SubFactory(PaymentMethodFactory)
|
|
||||||
|
|
||||||
|
|
||||||
class TransactionFactory(DjangoModelFactory):
|
|
||||||
class Meta:
|
|
||||||
model = looper.models.Transaction
|
|
||||||
|
|
||||||
user = factory.SubFactory('common.tests.factories.users.UserFactory')
|
|
||||||
order = factory.SubFactory(OrderFactory)
|
|
||||||
payment_method = factory.SubFactory(PaymentMethodFactory)
|
|
||||||
|
|
||||||
|
|
||||||
class TeamFactory(DjangoModelFactory):
|
class TeamFactory(DjangoModelFactory):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -55,38 +12,3 @@ class TeamFactory(DjangoModelFactory):
|
|||||||
|
|
||||||
name = factory.Faker('text', max_nb_chars=15)
|
name = factory.Faker('text', max_nb_chars=15)
|
||||||
subscription = factory.SubFactory(SubscriptionFactory)
|
subscription = factory.SubFactory(SubscriptionFactory)
|
||||||
|
|
||||||
|
|
||||||
@factory.django.mute_signals(signals.pre_save, signals.post_save)
|
|
||||||
class CustomerFactory(DjangoModelFactory):
|
|
||||||
class Meta:
|
|
||||||
model = looper.models.Customer
|
|
||||||
|
|
||||||
billing_email = factory.LazyAttribute(lambda o: '%s.billing@example.com' % o.user.username)
|
|
||||||
user = factory.SubFactory('common.tests.factories.users.UserFactory')
|
|
||||||
|
|
||||||
|
|
||||||
@factory.django.mute_signals(signals.pre_save, signals.post_save)
|
|
||||||
class AddressFactory(DjangoModelFactory):
|
|
||||||
class Meta:
|
|
||||||
model = looper.models.Address
|
|
||||||
|
|
||||||
user = factory.SubFactory('common.tests.factories.users.UserFactory')
|
|
||||||
|
|
||||||
|
|
||||||
# TODO(anna): this should probably move to looper
|
|
||||||
@factory.django.mute_signals(signals.pre_save, signals.post_save)
|
|
||||||
def create_customer_with_billing_address(**data):
|
|
||||||
"""Use factories to create a User with a Customer and Address records."""
|
|
||||||
customer_field_names = {f.name for f in looper.models.Customer._meta.get_fields()}
|
|
||||||
address_field_names = {f.name for f in looper.models.Address._meta.get_fields()}
|
|
||||||
user_field_names = {f.name for f in User._meta.get_fields()}
|
|
||||||
|
|
||||||
address_kwargs = {k: v for k, v in data.items() if k in address_field_names}
|
|
||||||
customer_kwargs = {k: v for k, v in data.items() if k in customer_field_names}
|
|
||||||
user_kwargs = {k: v for k, v in data.items() if k in user_field_names}
|
|
||||||
|
|
||||||
user = UserFactory(**user_kwargs)
|
|
||||||
AddressFactory(user=user, **address_kwargs)
|
|
||||||
CustomerFactory(user=user, **customer_kwargs)
|
|
||||||
return user
|
|
||||||
|
@ -249,8 +249,6 @@ Symbol | Description
|
|||||||
│ ├── 🔴 bank_transfer_details.txt
|
│ ├── 🔴 bank_transfer_details.txt
|
||||||
│ ├── 🔴 bank_transfer_reference.txt
|
│ ├── 🔴 bank_transfer_reference.txt
|
||||||
│ ├── 🟢 billing_address_form.html
|
│ ├── 🟢 billing_address_form.html
|
||||||
│ ├── 🟢 billing_address_form_readonly.html
|
|
||||||
│ ├── 🟢 current_plan_variation.html
|
|
||||||
│ ├── 🟢 footer.html
|
│ ├── 🟢 footer.html
|
||||||
│ ├── ❌ header_jumbotron.html
|
│ ├── ❌ header_jumbotron.html
|
||||||
│ ├── 🟢 info.html
|
│ ├── 🟢 info.html
|
||||||
@ -265,11 +263,8 @@ Symbol | Description
|
|||||||
│ └── 🟢 total.html
|
│ └── 🟢 total.html
|
||||||
├── join
|
├── join
|
||||||
│ ├── 🟢 billing_address.html
|
│ ├── 🟢 billing_address.html
|
||||||
│ ├── 🟢 payment_method.html
|
|
||||||
│ └── ⚪ select_plan_variation.html
|
│ └── ⚪ select_plan_variation.html
|
||||||
├── 🟢 manage.html
|
├── 🟢 manage.html
|
||||||
├── 🟢 pay_existing_order.html
|
|
||||||
├── 🟢 payment_method_change.html
|
|
||||||
└── widgets
|
└── widgets
|
||||||
└── ⚪ region_select.html
|
└── ⚪ region_select.html
|
||||||
```
|
```
|
||||||
|
@ -93,12 +93,13 @@ class SubscriptionEmailPreviewAdmin(looper.admin.mixins.NoAddDeleteMixin, EmailA
|
|||||||
|
|
||||||
def get_object(self, request, object_id, from_field=None):
|
def get_object(self, request, object_id, from_field=None):
|
||||||
"""Construct the Email on th fly from known subscription email templates."""
|
"""Construct the Email on th fly from known subscription email templates."""
|
||||||
user = User()
|
user = User(full_name='Jane Doe')
|
||||||
user.customer = looper.models.Customer(full_name='Jane Doe')
|
customer = looper.models.Customer(user=user)
|
||||||
|
user.customer = customer
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
subscription = looper.models.Subscription(
|
subscription = looper.models.Subscription(
|
||||||
id=1234567890,
|
id=1234567890,
|
||||||
user=user,
|
customer=user.customer,
|
||||||
payment_method=looper.models.PaymentMethod(
|
payment_method=looper.models.PaymentMethod(
|
||||||
method_type='cc',
|
method_type='cc',
|
||||||
gateway_id=1,
|
gateway_id=1,
|
||||||
@ -112,7 +113,7 @@ class SubscriptionEmailPreviewAdmin(looper.admin.mixins.NoAddDeleteMixin, EmailA
|
|||||||
)
|
)
|
||||||
order = looper.models.Order(subscription=subscription)
|
order = looper.models.Order(subscription=subscription)
|
||||||
context = {
|
context = {
|
||||||
'user': subscription.user,
|
'user': user,
|
||||||
'subscription': subscription,
|
'subscription': subscription,
|
||||||
'order': order,
|
'order': order,
|
||||||
# Also add context for the expired email
|
# Also add context for the expired email
|
||||||
|
@ -54,6 +54,8 @@ MAILGUN_API_KEY=
|
|||||||
MAILGUN_WEBHOOK_SIGNING_KEY=
|
MAILGUN_WEBHOOK_SIGNING_KEY=
|
||||||
MAILGUN_WEBHOOK_SECRET=
|
MAILGUN_WEBHOOK_SECRET=
|
||||||
|
|
||||||
GOOGLE_RECAPTCHA_SITE_KEY=
|
|
||||||
GOOGLE_RECAPTCHA_SECRET_KEY=
|
|
||||||
GOOGLE_ANALYTICS_TRACKING_ID=
|
GOOGLE_ANALYTICS_TRACKING_ID=
|
||||||
|
|
||||||
|
STRIPE_API_PUBLISHABLE_KEY=
|
||||||
|
STRIPE_API_SECRET_KEY=
|
||||||
|
STRIPE_ENDPOINT_SECRET=
|
||||||
|
36
poetry.lock
generated
36
poetry.lock
generated
@ -1300,7 +1300,7 @@ six = ">=1.11.0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "looper"
|
name = "looper"
|
||||||
version = "2.1.3"
|
version = "3.2.9"
|
||||||
description = ""
|
description = ""
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -1316,19 +1316,21 @@ braintree = "4.17.1"
|
|||||||
colorhash = "^1.0.3"
|
colorhash = "^1.0.3"
|
||||||
django = "^2.2.0 || 3.0 || 3.0.* || 3.2.*"
|
django = "^2.2.0 || 3.0 || 3.0.* || 3.2.*"
|
||||||
django-countries = "^7.2.1"
|
django-countries = "^7.2.1"
|
||||||
|
django-nested-admin = "^4.0.2"
|
||||||
django-pipeline = "^2.0.6"
|
django-pipeline = "^2.0.6"
|
||||||
geoip2 = "^3.0"
|
geoip2 = "^3.0"
|
||||||
python-dateutil = "^2.7"
|
python-dateutil = "^2.7"
|
||||||
python-stdnum = "^1.16"
|
python-stdnum = "^1.16"
|
||||||
requests = "^2.22"
|
requests = "^2.22"
|
||||||
|
stripe = "7.1.0"
|
||||||
xhtml2pdf = "^0.2"
|
xhtml2pdf = "^0.2"
|
||||||
zeep = "4.0.0"
|
zeep = "4.0.0"
|
||||||
|
|
||||||
[package.source]
|
[package.source]
|
||||||
type = "git"
|
type = "git"
|
||||||
url = "https://projects.blender.org/infrastructure/looper.git"
|
url = "https://projects.blender.org/infrastructure/looper.git"
|
||||||
reference = "10bca5a012"
|
reference = "8cd4da9"
|
||||||
resolved_reference = "10bca5a012f3deeede160b3520db630da6b9dfa5"
|
resolved_reference = "8cd4da950b21b85e0a5ecfa8e8e3d62774215a67"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lxml"
|
name = "lxml"
|
||||||
@ -2687,6 +2689,22 @@ files = [
|
|||||||
{file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"},
|
{file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stripe"
|
||||||
|
version = "7.1.0"
|
||||||
|
description = "Python bindings for the Stripe API"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.6"
|
||||||
|
files = [
|
||||||
|
{file = "stripe-7.1.0-py2.py3-none-any.whl", hash = "sha256:efd1e54825752c41bb311497cb5b6ae745464a57ca63bbe2847984a2409bcb0a"},
|
||||||
|
{file = "stripe-7.1.0.tar.gz", hash = "sha256:9cc2632230d5742eeb779af2b41c1510e724f498a296dfb40507de98d563f9a2"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
requests = {version = ">=2.20", markers = "python_version >= \"3.0\""}
|
||||||
|
typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""}
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tblib"
|
name = "tblib"
|
||||||
version = "3.0.0"
|
version = "3.0.0"
|
||||||
@ -2783,14 +2801,14 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.4.0"
|
version = "4.12.1"
|
||||||
description = "Backported and Experimental Type Hints for Python 3.7+"
|
description = "Backported and Experimental Type Hints for Python 3.8+"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"},
|
{file = "typing_extensions-4.12.1-py3-none-any.whl", hash = "sha256:6024b58b69089e5a89c347397254e35f1bf02a907728ec7fee9bf0fe837d203a"},
|
||||||
{file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"},
|
{file = "typing_extensions-4.12.1.tar.gz", hash = "sha256:915f5e35ff76f56588223f15fdd5938f9a1cf9195c0de25130c627e4d597f6d1"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2939,4 +2957,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.8"
|
python-versions = "^3.8"
|
||||||
content-hash = "1039e10638790d46a95584572ec99691f740e74be5166fe4c86ebe52b4e953c5"
|
content-hash = "69a6d187007cb4d99d90cf4472b9295a03c6c18518efcea4b6c50dc0d3dfef9f"
|
||||||
|
@ -14,7 +14,7 @@ libsasscompiler = "^0.1.5"
|
|||||||
jsmin = "3.0.0"
|
jsmin = "3.0.0"
|
||||||
sorl-thumbnail = "^12.10.0"
|
sorl-thumbnail = "^12.10.0"
|
||||||
mistune = "2.0.0a4"
|
mistune = "2.0.0a4"
|
||||||
looper = {git = "https://projects.blender.org/infrastructure/looper.git", rev = "10bca5a012"}
|
looper = {git = "https://projects.blender.org/infrastructure/looper.git", rev = "8cd4da9"}
|
||||||
Pillow = "^8.0"
|
Pillow = "^8.0"
|
||||||
django-storages = {extras = ["google"], version = "1.11.1"}
|
django-storages = {extras = ["google"], version = "1.11.1"}
|
||||||
pymongo = "^3.10.1"
|
pymongo = "^3.10.1"
|
||||||
|
@ -504,6 +504,12 @@ GATEWAYS = {
|
|||||||
'supported_collection_methods': {'automatic', 'manual'},
|
'supported_collection_methods': {'automatic', 'manual'},
|
||||||
},
|
},
|
||||||
'bank': {'supported_collection_methods': {'manual'}},
|
'bank': {'supported_collection_methods': {'manual'}},
|
||||||
|
'stripe': {
|
||||||
|
'api_publishable_key': _get('STRIPE_API_PUBLISHABLE_KEY'),
|
||||||
|
'api_secret_key': _get('STRIPE_API_SECRET_KEY'),
|
||||||
|
'endpoint_secret': _get('STRIPE_ENDPOINT_SECRET'),
|
||||||
|
'supported_collection_methods': {'automatic', 'manual'},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Optional Sentry configuration
|
# Optional Sentry configuration
|
||||||
@ -630,8 +636,6 @@ if MAILGUN_SENDER_DOMAIN:
|
|||||||
GEOIP2_DB = _get('GEOIP2_DB')
|
GEOIP2_DB = _get('GEOIP2_DB')
|
||||||
|
|
||||||
GOOGLE_ANALYTICS_TRACKING_ID = _get('GOOGLE_ANALYTICS_TRACKING_ID')
|
GOOGLE_ANALYTICS_TRACKING_ID = _get('GOOGLE_ANALYTICS_TRACKING_ID')
|
||||||
GOOGLE_RECAPTCHA_SECRET_KEY = _get('GOOGLE_RECAPTCHA_SECRET_KEY')
|
|
||||||
GOOGLE_RECAPTCHA_SITE_KEY = _get('GOOGLE_RECAPTCHA_SITE_KEY')
|
|
||||||
|
|
||||||
S3DIRECT_DESTINATIONS = {
|
S3DIRECT_DESTINATIONS = {
|
||||||
'default': {
|
'default': {
|
||||||
@ -676,5 +680,15 @@ S3DIRECT_DESTINATIONS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A list of payment method types used with stripe for setting a recurring payment:
|
||||||
|
# https://stripe.com/docs/api/checkout/sessions/create#create_checkout_session-payment_method_types
|
||||||
|
STRIPE_OFF_SESSION_PAYMENT_METHOD_TYPES = [
|
||||||
|
'card',
|
||||||
|
'link',
|
||||||
|
'paypal',
|
||||||
|
]
|
||||||
|
|
||||||
|
STRIPE_CHECKOUT_SUBMIT_TYPE = 'pay'
|
||||||
|
|
||||||
# Maximum number of attempts for failing background tasks
|
# Maximum number of attempts for failing background tasks
|
||||||
MAX_ATTEMPTS = 3
|
MAX_ATTEMPTS = 3
|
||||||
|
@ -5,13 +5,12 @@ import logging
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.forms.fields import Field
|
from django.forms.fields import Field
|
||||||
from django.forms.models import model_to_dict
|
|
||||||
|
|
||||||
from localflavor.administrative_areas import ADMINISTRATIVE_AREAS
|
from localflavor.administrative_areas import ADMINISTRATIVE_AREAS
|
||||||
from localflavor.generic.validators import validate_country_postcode
|
from localflavor.generic.validators import validate_country_postcode
|
||||||
|
from looper.middleware import COUNTRY_CODE_SESSION_KEY
|
||||||
from stdnum.eu import vat
|
from stdnum.eu import vat
|
||||||
import localflavor.exceptions
|
import localflavor.exceptions
|
||||||
|
|
||||||
import looper.form_fields
|
import looper.form_fields
|
||||||
import looper.forms
|
import looper.forms
|
||||||
import looper.models
|
import looper.models
|
||||||
@ -48,17 +47,11 @@ REQUIRED_FIELDS = {
|
|||||||
|
|
||||||
|
|
||||||
class BillingAddressForm(forms.ModelForm):
|
class BillingAddressForm(forms.ModelForm):
|
||||||
"""Unify Customer and Address in a single form."""
|
"""Fill in billing address and prepare for intitiating Stripe checkout session."""
|
||||||
|
|
||||||
# Customer.billing_email is exposed as email in the Form
|
|
||||||
# because Looper scripts and forms already use "email" everywhere.
|
|
||||||
__customer_fields = {'billing_email': 'email', 'vat_number': 'vat_number'}
|
|
||||||
# Colliding "full_name" and "company" values are taken from and saved to the Address.
|
|
||||||
# FIXME(anna): do we need to use company and full_name on the Customer or only Address?
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = looper.models.Address
|
model = looper.models.Address
|
||||||
fields = looper.models.Address.PUBLIC_FIELDS
|
exclude = ['category', 'customer', 'tax_exempt']
|
||||||
|
|
||||||
# What kind of choices are allowed depends on the selected country
|
# What kind of choices are allowed depends on the selected country
|
||||||
# and is not yet known when the form is rendered.
|
# and is not yet known when the form is rendered.
|
||||||
@ -81,20 +74,6 @@ class BillingAddressForm(forms.ModelForm):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Load additional model data from Customer and set form placeholders."""
|
"""Load additional model data from Customer and set form placeholders."""
|
||||||
instance: looper.models.Address = kwargs.get('instance')
|
|
||||||
if instance:
|
|
||||||
assert isinstance(instance, looper.models.Address), 'Must be an instance of Address'
|
|
||||||
customer = instance.user.customer
|
|
||||||
initial = kwargs.get('initial') or {}
|
|
||||||
customer_data = model_to_dict(customer, self.__customer_fields.keys(), {})
|
|
||||||
# Remap the fields, e.g. turning "billing_email" into "email"
|
|
||||||
customer_form_data = {v: customer_data[k] for k, v in self.__customer_fields.items()}
|
|
||||||
# Add Customer data into initial,
|
|
||||||
# making sure that it still overrides the instance data, as it's supposed to
|
|
||||||
kwargs['initial'] = {
|
|
||||||
**customer_form_data,
|
|
||||||
**initial,
|
|
||||||
}
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Set placeholder values on all form fields
|
# Set placeholder values on all form fields
|
||||||
@ -160,43 +139,62 @@ class BillingAddressForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
"""Save Customer data as well."""
|
"""Save cleared region field."""
|
||||||
# Validation against region choices is already done, because choices are set on __init__,
|
# Validation against region choices is already done, because choices are set on __init__,
|
||||||
# however Django won't set the updated blank region value if was omitted from the form.
|
# however Django won't set the updated blank region value if was omitted from the form.
|
||||||
if self.cleaned_data['region'] == '':
|
if self.cleaned_data['region'] == '':
|
||||||
self.instance.region = ''
|
self.instance.region = ''
|
||||||
instance = super().save(commit=commit)
|
instance = super().save(commit=commit)
|
||||||
|
|
||||||
customer = instance.user.customer
|
|
||||||
for model_field, form_field in self.__customer_fields.items():
|
|
||||||
setattr(customer, model_field, self.cleaned_data[form_field])
|
|
||||||
if commit:
|
|
||||||
customer.save(update_fields=self.__customer_fields)
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
class BillingAddressReadonlyForm(forms.ModelForm):
|
class PaymentForm(BillingAddressForm):
|
||||||
"""Display the billing details in a payment form but neither validate nor update them.
|
"""Handle PlanVariation ID and payment method details in the second step of the checkout.
|
||||||
|
|
||||||
Used in PaymentMethodChangeView and PayExistingOrderView.
|
Billing details are displayed as read-only and cannot be edited,
|
||||||
|
but are still used by the payment flow.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
gateway = looper.form_fields.GatewayChoiceField(
|
||||||
model = looper.models.Address
|
queryset=looper.models.Gateway.objects.filter(name__in={'stripe', 'bank'}).order_by(
|
||||||
fields = looper.models.Address.PUBLIC_FIELDS
|
'-is_default'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Disable all the billing details fields.
|
"""Pre-fill additional initial data from request."""
|
||||||
|
self.request = kwargs.pop('request', None)
|
||||||
|
self.plan_variation = kwargs.pop('plan_variation', None)
|
||||||
|
|
||||||
The billing details are only for display and for use by the payment flow.
|
|
||||||
"""
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
for field_name, field in self.fields.items():
|
|
||||||
if field_name not in BILLING_DETAILS_PLACEHOLDERS:
|
|
||||||
continue
|
|
||||||
field.disabled = True
|
|
||||||
|
|
||||||
email = forms.EmailField(required=False, disabled=True)
|
self._set_initial_from_request()
|
||||||
|
|
||||||
|
def _set_initial_from_request(self):
|
||||||
|
if not self.request:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only preset country when it's not already selected by the customer
|
||||||
|
geoip_country = self.request.session.get(COUNTRY_CODE_SESSION_KEY)
|
||||||
|
if geoip_country and (not self.instance.country):
|
||||||
|
self.initial['country'] = geoip_country
|
||||||
|
|
||||||
|
# Only set initial values if they aren't already saved to the billing address.
|
||||||
|
# Initial values always override form data, which leads to confusing issues with views.
|
||||||
|
if not self.instance.full_name:
|
||||||
|
# Fall back to user's full name, if no full name set already in the billing address:
|
||||||
|
if self.request.user.full_name:
|
||||||
|
self.initial['full_name'] = self.request.user.full_name
|
||||||
|
|
||||||
|
def clean_gateway(self):
|
||||||
|
"""Validate gateway against selected plan variation."""
|
||||||
|
gw = self.cleaned_data['gateway']
|
||||||
|
if not self.plan_variation:
|
||||||
|
return gw
|
||||||
|
if self.plan_variation.collection_method not in gw.provider.supported_collection_methods:
|
||||||
|
msg = self.fields['gateway'].default_error_messages['invalid_choice']
|
||||||
|
self.add_error('gateway', msg)
|
||||||
|
return gw
|
||||||
|
|
||||||
|
|
||||||
class SelectPlanVariationForm(forms.Form):
|
class SelectPlanVariationForm(forms.Form):
|
||||||
@ -223,58 +221,6 @@ class SelectPlanVariationForm(forms.Form):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PaymentForm(BillingAddressForm):
|
|
||||||
"""Handle PlanVariation ID and payment method details in the second step of the checkout.
|
|
||||||
|
|
||||||
Billing details are displayed as read-only and cannot be edited,
|
|
||||||
but are still used by the payment flow.
|
|
||||||
"""
|
|
||||||
|
|
||||||
payment_method_nonce = forms.CharField(initial='set-in-javascript', widget=forms.HiddenInput())
|
|
||||||
gateway = looper.form_fields.GatewayChoiceField()
|
|
||||||
device_data = forms.CharField(
|
|
||||||
initial='set-in-javascript', widget=forms.HiddenInput(), required=False
|
|
||||||
)
|
|
||||||
|
|
||||||
price = forms.CharField(widget=forms.HiddenInput(), required=True)
|
|
||||||
|
|
||||||
# These are used when a payment fails, so that the next attempt to pay can reuse
|
|
||||||
# the already-created subscription and order.
|
|
||||||
subscription_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
|
|
||||||
order_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
|
|
||||||
|
|
||||||
|
|
||||||
class AutomaticPaymentForm(PaymentForm):
|
|
||||||
"""Same as the PaymentForm, but only allows payment gateways that support transactions."""
|
|
||||||
|
|
||||||
gateway = looper.form_fields.GatewayChoiceField(
|
|
||||||
queryset=looper.models.Gateway.objects.filter(
|
|
||||||
name__in=looper.gateways.Registry.gateway_names_supports_transactions()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PayExistingOrderForm(BillingAddressReadonlyForm):
|
|
||||||
"""Same as AutomaticPaymentForm, but doesn't validate or update billing details."""
|
|
||||||
|
|
||||||
payment_method_nonce = forms.CharField(initial='set-in-javascript', widget=forms.HiddenInput())
|
|
||||||
gateway = looper.form_fields.GatewayChoiceField(
|
|
||||||
queryset=looper.models.Gateway.objects.filter(
|
|
||||||
name__in=looper.gateways.Registry.gateway_names_supports_transactions()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
device_data = forms.CharField(
|
|
||||||
initial='set-in-javascript', widget=forms.HiddenInput(), required=False
|
|
||||||
)
|
|
||||||
price = forms.CharField(widget=forms.HiddenInput(), required=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ChangePaymentMethodForm(BillingAddressReadonlyForm, looper.forms.ChangePaymentMethodForm):
|
|
||||||
"""Add full billing address to the change payment form."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class CancelSubscriptionForm(forms.Form):
|
class CancelSubscriptionForm(forms.Form):
|
||||||
"""Confirm cancellation of a subscription."""
|
"""Confirm cancellation of a subscription."""
|
||||||
|
|
||||||
|
@ -1,29 +1,10 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.management import call_command
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
form_description = {
|
|
||||||
'bank': 'When choosing to pay by bank, you will be required to manually perform payments and the subscription will be activated only when we receive the funds.',
|
|
||||||
'braintree': 'Automatic charges',
|
|
||||||
}
|
|
||||||
frontend_names = {
|
|
||||||
'bank': 'Bank Transfer',
|
|
||||||
'braintree': 'Credit Card or PayPal',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def add_gateways(apps, schema_editor):
|
def add_gateways(apps, schema_editor):
|
||||||
Gateway = apps.get_model('looper', 'Gateway')
|
call_command('loaddata', 'gateways.json', app_label='looper')
|
||||||
|
|
||||||
for (name, _) in Gateway._meta.get_field('name').choices:
|
|
||||||
if name == 'mock':
|
|
||||||
continue
|
|
||||||
Gateway.objects.get_or_create(
|
|
||||||
name=name,
|
|
||||||
frontend_name=frontend_names.get(name),
|
|
||||||
is_default=name.lower() == 'braintree',
|
|
||||||
form_description=form_description.get(name),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_gateways(apps, schema_editor):
|
def remove_gateways(apps, schema_editor):
|
||||||
|
@ -90,7 +90,7 @@ class Team(mixins.CreatedUpdatedMixin, models.Model):
|
|||||||
"""Add given user to the team."""
|
"""Add given user to the team."""
|
||||||
seats_taken = self.users.count()
|
seats_taken = self.users.count()
|
||||||
# Not adding the team manager to the team
|
# Not adding the team manager to the team
|
||||||
if user.pk == self.subscription.user_id:
|
if user.pk == self.subscription.customer.user_id:
|
||||||
return
|
return
|
||||||
if self.seats is not None and seats_taken >= self.seats:
|
if self.seats is not None and seats_taken >= self.seats:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
@ -19,7 +19,7 @@ def has_active_subscription(user: User) -> bool:
|
|||||||
active_subscriptions: 'QuerySet[Subscription]' = Subscription.objects.active()
|
active_subscriptions: 'QuerySet[Subscription]' = Subscription.objects.active()
|
||||||
|
|
||||||
return active_subscriptions.filter(
|
return active_subscriptions.filter(
|
||||||
Q(user_id=user.id) | Q(team__team_users__user_id=user.id)
|
Q(customer=user.customer) | Q(team__team_users__user_id=user.id)
|
||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
@ -33,7 +33,9 @@ def has_non_legacy_subscription(user: User) -> bool:
|
|||||||
|
|
||||||
subscriptions: 'QuerySet[Subscription]' = Subscription.objects.filter(is_legacy=False)
|
subscriptions: 'QuerySet[Subscription]' = Subscription.objects.filter(is_legacy=False)
|
||||||
|
|
||||||
return subscriptions.filter(Q(user_id=user.id) | Q(team__team_users__user_id=user.id)).exists()
|
return subscriptions.filter(
|
||||||
|
Q(customer=user.customer) | Q(team__team_users__user_id=user.id)
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
def has_subscription(user: User) -> bool:
|
def has_subscription(user: User) -> bool:
|
||||||
@ -42,7 +44,7 @@ def has_subscription(user: User) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
return Subscription.objects.filter(
|
return Subscription.objects.filter(
|
||||||
Q(user_id=user.id) | Q(team__team_users__user_id=user.id)
|
Q(customer=user.customer) | Q(team__team_users__user_id=user.id)
|
||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
@ -51,7 +53,8 @@ def should_redirect_to_billing(user: User) -> bool:
|
|||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if user.subscription_set.exclude(status__in=Subscription._CANCELLED_STATUSES).count() == 0:
|
customer = user.customer
|
||||||
|
if customer.subscription_set.exclude(status__in=Subscription._CANCELLED_STATUSES).count() == 0:
|
||||||
# Only cancelled subscriptions, no need to redirect to billing
|
# Only cancelled subscriptions, no need to redirect to billing
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -60,7 +63,7 @@ def should_redirect_to_billing(user: User) -> bool:
|
|||||||
# so this seems to be the only currently available way to tell
|
# so this seems to be the only currently available way to tell
|
||||||
# when to stop showing the checkout to the customer.
|
# when to stop showing the checkout to the customer.
|
||||||
subscription.latest_order() and subscription.payment_method
|
subscription.latest_order() and subscription.payment_method
|
||||||
for subscription in user.subscription_set.all()
|
for subscription in customer.subscription_set.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -76,4 +79,4 @@ def has_not_yet_cancelled_subscription(user: User) -> bool:
|
|||||||
status__in=Subscription._CANCELLED_STATUSES
|
status__in=Subscription._CANCELLED_STATUSES
|
||||||
)
|
)
|
||||||
|
|
||||||
return not_yet_cancelled_subscriptions.filter(Q(user_id=user.id)).exists()
|
return not_yet_cancelled_subscriptions.filter(Q(customer=user.customer)).exists()
|
||||||
|
@ -3,6 +3,7 @@ from typing import Set
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
import alphabetic_timestamp as ats
|
import alphabetic_timestamp as ats
|
||||||
@ -30,17 +31,39 @@ def timebased_order_number():
|
|||||||
|
|
||||||
|
|
||||||
@receiver(django_signals.post_save, sender=User)
|
@receiver(django_signals.post_save, sender=User)
|
||||||
def create_customer(sender, instance: User, created, **kwargs):
|
def create_customer(sender, instance: User, created, raw, **kwargs):
|
||||||
"""Create Customer on User creation."""
|
"""Create Customer on User creation."""
|
||||||
|
if raw:
|
||||||
|
return
|
||||||
|
|
||||||
if not created:
|
if not created:
|
||||||
return
|
return
|
||||||
logger.debug("Creating Customer for user %i" % instance.id)
|
|
||||||
# Assume billing name and email are the same, they should be able to change them later
|
try:
|
||||||
Customer.objects.create(
|
customer = instance.customer
|
||||||
user_id=instance.pk,
|
except Customer.DoesNotExist:
|
||||||
billing_email=instance.email,
|
pass
|
||||||
full_name=instance.full_name,
|
else:
|
||||||
|
logger.debug(
|
||||||
|
'Newly created User %d already has a Customer %d, not creating new one',
|
||||||
|
instance.pk,
|
||||||
|
customer.pk,
|
||||||
)
|
)
|
||||||
|
billing_address = customer.billing_address
|
||||||
|
logger.info('Creating new billing address due to creation of user %s', instance.pk)
|
||||||
|
if not billing_address.pk:
|
||||||
|
billing_address.email = instance.email
|
||||||
|
billing_address.full_name = instance.full_name
|
||||||
|
billing_address.save()
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info('Creating new Customer due to creation of user %s', instance.pk)
|
||||||
|
with transaction.atomic():
|
||||||
|
customer = Customer.objects.create(user=instance)
|
||||||
|
billing_address = customer.billing_address
|
||||||
|
billing_address.email = instance.email
|
||||||
|
billing_address.full_name = instance.full_name
|
||||||
|
billing_address.save()
|
||||||
|
|
||||||
|
|
||||||
@receiver(django_signals.pre_save, sender=Order)
|
@receiver(django_signals.pre_save, sender=Order)
|
||||||
@ -54,7 +77,8 @@ def _set_order_number(sender, instance: Order, **kwargs):
|
|||||||
@receiver(subscription_created_needs_payment)
|
@receiver(subscription_created_needs_payment)
|
||||||
def _on_subscription_created_needs_payment(sender: looper.models.Subscription, **kwargs):
|
def _on_subscription_created_needs_payment(sender: looper.models.Subscription, **kwargs):
|
||||||
tasks.send_mail_bank_transfer_required(subscription_id=sender.pk)
|
tasks.send_mail_bank_transfer_required(subscription_id=sender.pk)
|
||||||
users.tasks.grant_blender_id_role(pk=sender.user_id, role='cloud_has_subscription')
|
user = sender.customer.user
|
||||||
|
users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription')
|
||||||
|
|
||||||
|
|
||||||
@receiver(looper.signals.subscription_activated)
|
@receiver(looper.signals.subscription_activated)
|
||||||
@ -65,8 +89,9 @@ def _on_subscription_status_changed(sender: looper.models.Subscription, **kwargs
|
|||||||
|
|
||||||
@receiver(looper.signals.subscription_activated)
|
@receiver(looper.signals.subscription_activated)
|
||||||
def _on_subscription_status_activated(sender: looper.models.Subscription, **kwargs):
|
def _on_subscription_status_activated(sender: looper.models.Subscription, **kwargs):
|
||||||
users.tasks.grant_blender_id_role(pk=sender.user_id, role='cloud_has_subscription')
|
user = sender.customer.user
|
||||||
users.tasks.grant_blender_id_role(pk=sender.user_id, role='cloud_subscriber')
|
users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_has_subscription')
|
||||||
|
users.tasks.grant_blender_id_role(pk=user.pk, role='cloud_subscriber')
|
||||||
|
|
||||||
if not hasattr(sender, 'team'):
|
if not hasattr(sender, 'team'):
|
||||||
return
|
return
|
||||||
@ -79,8 +104,9 @@ def _on_subscription_status_activated(sender: looper.models.Subscription, **kwar
|
|||||||
@receiver(looper.signals.subscription_expired)
|
@receiver(looper.signals.subscription_expired)
|
||||||
def _on_subscription_status_deactivated(sender: looper.models.Subscription, **kwargs):
|
def _on_subscription_status_deactivated(sender: looper.models.Subscription, **kwargs):
|
||||||
# No other active subscription exists, subscriber badge can be revoked
|
# No other active subscription exists, subscriber badge can be revoked
|
||||||
if not queries.has_active_subscription(sender.user):
|
user = sender.customer.user
|
||||||
users.tasks.revoke_blender_id_role(pk=sender.user_id, role='cloud_subscriber')
|
if not queries.has_active_subscription(user):
|
||||||
|
users.tasks.revoke_blender_id_role(pk=user.pk, role='cloud_subscriber')
|
||||||
|
|
||||||
if not hasattr(sender, 'team'):
|
if not hasattr(sender, 'team'):
|
||||||
return
|
return
|
||||||
@ -118,7 +144,8 @@ def _on_subscription_expired(sender: looper.models.Subscription, **kwargs):
|
|||||||
assert sender.status == 'expired', f'Expected expired, got "{sender.status} (pk={sender.pk})"'
|
assert sender.status == 'expired', f'Expected expired, got "{sender.status} (pk={sender.pk})"'
|
||||||
|
|
||||||
# Only send a "subscription expired" email when there are no other active subscriptions
|
# Only send a "subscription expired" email when there are no other active subscriptions
|
||||||
if not queries.has_active_subscription(sender.user):
|
user = sender.customer.user
|
||||||
|
if not queries.has_active_subscription(user):
|
||||||
tasks.send_mail_subscription_expired(subscription_id=sender.pk)
|
tasks.send_mail_subscription_expired(subscription_id=sender.pk)
|
||||||
|
|
||||||
|
|
||||||
|
@ -44,8 +44,9 @@ def _construct_subscription_mail(mail_name: str, context: Dict[str, Any]) -> Tup
|
|||||||
def send_mail_bank_transfer_required(subscription_id: int):
|
def send_mail_bank_transfer_required(subscription_id: int):
|
||||||
"""Send out an email notifying about the required bank transfer payment."""
|
"""Send out an email notifying about the required bank transfer payment."""
|
||||||
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
|
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
|
||||||
user = subscription.user
|
customer = subscription.customer
|
||||||
email = user.customer.billing_email or user.email
|
user = customer.user
|
||||||
|
email = customer.billing_address.email or user.email
|
||||||
assert (
|
assert (
|
||||||
email
|
email
|
||||||
), f'Cannot send notification about bank payment for subscription {subscription.pk}: no email'
|
), f'Cannot send notification about bank payment for subscription {subscription.pk}: no email'
|
||||||
@ -65,7 +66,7 @@ def send_mail_bank_transfer_required(subscription_id: int):
|
|||||||
assert order, "Can't send a notificaton about bank transfer without an existing order"
|
assert order, "Can't send a notificaton about bank transfer without an existing order"
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'user': subscription.user,
|
'user': user,
|
||||||
'subscription': subscription,
|
'subscription': subscription,
|
||||||
'order': order,
|
'order': order,
|
||||||
**get_template_context(),
|
**get_template_context(),
|
||||||
@ -89,8 +90,9 @@ def send_mail_bank_transfer_required(subscription_id: int):
|
|||||||
def send_mail_subscription_status_changed(subscription_id: int):
|
def send_mail_subscription_status_changed(subscription_id: int):
|
||||||
"""Send out an email notifying about the activated subscription."""
|
"""Send out an email notifying about the activated subscription."""
|
||||||
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
|
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
|
||||||
user = subscription.user
|
customer = subscription.customer
|
||||||
email = user.customer.billing_email or user.email
|
user = customer.user
|
||||||
|
email = customer.billing_address.email or user.email
|
||||||
assert email, f'Cannot send notification about subscription {subscription.pk} status: no email'
|
assert email, f'Cannot send notification about subscription {subscription.pk} status: no email'
|
||||||
if is_noreply(email):
|
if is_noreply(email):
|
||||||
raise
|
raise
|
||||||
@ -109,7 +111,7 @@ def send_mail_subscription_status_changed(subscription_id: int):
|
|||||||
verb = 'deactivated'
|
verb = 'deactivated'
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'user': subscription.user,
|
'user': user,
|
||||||
'subscription': subscription,
|
'subscription': subscription,
|
||||||
'verb': verb,
|
'verb': verb,
|
||||||
**get_template_context(),
|
**get_template_context(),
|
||||||
@ -133,9 +135,9 @@ def send_mail_automatic_payment_performed(order_id: int, transaction_id: int):
|
|||||||
"""Send out an email notifying about the soft-failed payment."""
|
"""Send out an email notifying about the soft-failed payment."""
|
||||||
order = looper.models.Order.objects.get(pk=order_id)
|
order = looper.models.Order.objects.get(pk=order_id)
|
||||||
transaction = looper.models.Transaction.objects.get(pk=transaction_id)
|
transaction = looper.models.Transaction.objects.get(pk=transaction_id)
|
||||||
user = order.user
|
customer = order.customer
|
||||||
customer = user.customer
|
user = customer.user
|
||||||
email = customer.billing_email or user.email
|
email = customer.billing_address.email or user.email
|
||||||
logger.debug('Sending %r notification to %s', order.status, email)
|
logger.debug('Sending %r notification to %s', order.status, email)
|
||||||
|
|
||||||
# An Unsubscribe record will prevent this message from being delivered by Mailgun.
|
# An Unsubscribe record will prevent this message from being delivered by Mailgun.
|
||||||
@ -148,7 +150,7 @@ def send_mail_automatic_payment_performed(order_id: int, transaction_id: int):
|
|||||||
receipt_url = absolute_url('subscriptions:receipt', kwargs={'order_id': order.pk})
|
receipt_url = absolute_url('subscriptions:receipt', kwargs={'order_id': order.pk})
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'user': subscription.user,
|
'user': user,
|
||||||
'email': email,
|
'email': email,
|
||||||
'order': order,
|
'order': order,
|
||||||
'subscription': subscription,
|
'subscription': subscription,
|
||||||
@ -185,7 +187,8 @@ def send_mail_managed_subscription_notification(subscription_id: int):
|
|||||||
subscription.pk,
|
subscription.pk,
|
||||||
)
|
)
|
||||||
|
|
||||||
user = subscription.user
|
customer = subscription.customer
|
||||||
|
user = customer.user
|
||||||
admin_url = absolute_url(
|
admin_url = absolute_url(
|
||||||
'admin:looper_subscription_change',
|
'admin:looper_subscription_change',
|
||||||
kwargs={'object_id': subscription.id},
|
kwargs={'object_id': subscription.id},
|
||||||
@ -220,7 +223,8 @@ def send_mail_managed_subscription_notification(subscription_id: int):
|
|||||||
def send_mail_subscription_expired(subscription_id: int):
|
def send_mail_subscription_expired(subscription_id: int):
|
||||||
"""Send out an email notifying about an expired subscription."""
|
"""Send out an email notifying about an expired subscription."""
|
||||||
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
|
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
|
||||||
user = subscription.user
|
customer = subscription.customer
|
||||||
|
user = customer.user
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
subscription.status == 'expired'
|
subscription.status == 'expired'
|
||||||
@ -229,7 +233,7 @@ def send_mail_subscription_expired(subscription_id: int):
|
|||||||
if queries.has_active_subscription(user):
|
if queries.has_active_subscription(user):
|
||||||
logger.error(
|
logger.error(
|
||||||
'Not sending subscription-expired notification: pk=%s has other active subscriptions',
|
'Not sending subscription-expired notification: pk=%s has other active subscriptions',
|
||||||
subscription.user_id,
|
user.pk,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -248,7 +252,7 @@ def send_mail_subscription_expired(subscription_id: int):
|
|||||||
|
|
||||||
logger.debug('Sending subscription-expired notification to %s', email)
|
logger.debug('Sending subscription-expired notification to %s', email)
|
||||||
context = {
|
context = {
|
||||||
'user': subscription.user,
|
'user': user,
|
||||||
'subscription': subscription,
|
'subscription': subscription,
|
||||||
'latest_trainings': get_latest_trainings_and_production_lessons(),
|
'latest_trainings': get_latest_trainings_and_production_lessons(),
|
||||||
'latest_posts': Post.objects.filter(is_published=True)[:5],
|
'latest_posts': Post.objects.filter(is_published=True)[:5],
|
||||||
@ -280,9 +284,9 @@ def send_mail_no_payment_method(order_id: int):
|
|||||||
), 'send_mail_no_payment_method expects automatic subscription'
|
), 'send_mail_no_payment_method expects automatic subscription'
|
||||||
assert 'fail' in order.status, f'Unexpected order pk={order_id} status: {order.status}'
|
assert 'fail' in order.status, f'Unexpected order pk={order_id} status: {order.status}'
|
||||||
|
|
||||||
user = order.user
|
customer = order.customer
|
||||||
customer = user.customer
|
user = customer.user
|
||||||
email = customer.billing_email or user.email
|
email = customer.billing_address.email or user.email
|
||||||
logger.debug('Sending %r notification to %s', order.status, email)
|
logger.debug('Sending %r notification to %s', order.status, email)
|
||||||
|
|
||||||
# An Unsubscribe record will prevent this message from being delivered by Mailgun.
|
# An Unsubscribe record will prevent this message from being delivered by Mailgun.
|
||||||
@ -295,7 +299,7 @@ def send_mail_no_payment_method(order_id: int):
|
|||||||
receipt_url = absolute_url('subscriptions:receipt', kwargs={'order_id': order.pk})
|
receipt_url = absolute_url('subscriptions:receipt', kwargs={'order_id': order.pk})
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'user': subscription.user,
|
'user': user,
|
||||||
'email': email,
|
'email': email,
|
||||||
'order': order,
|
'order': order,
|
||||||
'subscription': subscription,
|
'subscription': subscription,
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
<div class="container flat-page-content">
|
<div class="container flat-page-content">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="jumbotron-body">
|
<div class="jumbotron-body text-center mt-3">
|
||||||
<h1 class="thanks">Thanks for subscribing to Blender Studio!</h1>
|
<h1 class="thanks">Thanks for subscribing to Blender Studio!</h1>
|
||||||
<p>Make sure to follow the instructions below to activate your account.</p>
|
<p>Make sure to follow the instructions below to activate your account.</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
{% extends 'users/settings/base.html' %}
|
{% extends 'users/settings/base.html' %}
|
||||||
{% load common_extras %}
|
{% load common_extras %}
|
||||||
{% load pipeline %}
|
|
||||||
|
|
||||||
{% block settings %}
|
{% block settings %}
|
||||||
<p class="text-muted">Settings: Subscription</p>
|
<p class="text-muted">Settings: Subscription</p>
|
||||||
@ -15,7 +14,3 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
</form>
|
</form>
|
||||||
{% endblock settings %}
|
{% endblock settings %}
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
{% javascript "subscriptions" %}
|
|
||||||
{% endblock scripts %}
|
|
||||||
|
@ -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>
|
|
@ -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>
|
|
@ -3,7 +3,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
{# Owned subscriptions #}
|
{# Owned subscriptions #}
|
||||||
{% for subscription in user.subscription_set.all %}
|
{% for subscription in user.customer.subscription_set.all %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fw-bold">{% if subscription.team %}Team {% endif %}Subscription #{{ subscription.pk }}</td>
|
<td class="fw-bold">{% if subscription.team %}Team {% endif %}Subscription #{{ subscription.pk }}</td>
|
||||||
<td>{% include "subscriptions/components/pretty_status.html" %}</td>
|
<td>{% include "subscriptions/components/pretty_status.html" %}</td>
|
||||||
|
@ -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>
|
|
@ -43,7 +43,12 @@
|
|||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col text-end">
|
<div class="col text-end">
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<button class="btn btn-primary w-100" id="submit-button" type="submit">{{ button_text|default:"Continue" }}</button>
|
{% with gw=form.gateway.field.queryset.first %}
|
||||||
|
<button class="btn btn-primary w-100" id="submit-button" type="submit"
|
||||||
|
{% if gw %}name="gateway" value="{{ gw.name }}"{% endif %}>
|
||||||
|
{{ button_text|default:"Continue" }}
|
||||||
|
</button>
|
||||||
|
{% endwith %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="btn btn-primary w-100 x-sign-in" href="{% url 'oauth:login' %}">Sign in with Blender ID</a>
|
<a class="btn btn-primary w-100 x-sign-in" href="{% url 'oauth:login' %}">Sign in with Blender ID</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
{% endblock header_logo %}
|
{% endblock header_logo %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<p>Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},</p>
|
<p>Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %},</p>
|
||||||
{% block content %}{% endblock content %}
|
{% block content %}{% endblock content %}
|
||||||
<p>Manage subscription in your billing settings: <a href="{{ billing_url }}">{{ billing_url }}</a>.</p>
|
<p>Manage subscription in your billing settings: <a href="{{ billing_url }}">{{ billing_url }}</a>.</p>
|
||||||
<p>
|
<p>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},
|
Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %},
|
||||||
{% block content %}{% endblock content %}
|
{% block content %}{% endblock content %}
|
||||||
Manage subscription in your billing settings: {{ billing_url }}.
|
Manage subscription in your billing settings: {{ billing_url }}.
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{% extends "subscriptions/emails/base.html" %}
|
{% extends "subscriptions/emails/base.html" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<p>
|
<p>
|
||||||
{{ user.customer.full_name|default:user.email }} has a {% include "subscriptions/components/info.html" %} that just passed its next payment date.
|
{{ user.customer.billing_address.full_name|default:user.email }} has a {% include "subscriptions/components/info.html" %} that just passed its next payment date.
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
{{ user.customer.full_name|default:user.email }} has a {% include "subscriptions/components/info.txt" %} that just passed its next payment date.
|
{% firstof user.customer.billing_address.full_name user.full_name user.email %} has a {% include "subscriptions/components/info.txt" %} that just passed its next payment date.
|
||||||
|
|
||||||
See {{ admin_url }} in the Blender Studio admin.
|
See {{ admin_url }} in the Blender Studio admin.
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
{% endblock header_logo %}
|
{% endblock header_logo %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<p>Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},</p>
|
<p>Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %},</p>
|
||||||
<p>Your Blender Studio subscription #{{subscription.pk}} has expired a while back. We miss you -- and you are missing some exciting content on Blender Studio as well.</p>
|
<p>Your Blender Studio subscription #{{subscription.pk}} has expired a while back. We miss you -- and you are missing some exciting content on Blender Studio as well.</p>
|
||||||
{% if latest_posts or latest_trainings %}
|
{% if latest_posts or latest_trainings %}
|
||||||
<p>Just recently, we've published:</p>
|
<p>Just recently, we've published:</p>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},
|
Dear {% firstof user.customer.billing_address.full_name user.full_name user.email %},
|
||||||
|
|
||||||
Your Blender Studio subscription #{{subscription.pk}} has expired a while back. We miss you -- and you are missing some exciting content on Blender Studio as well.{% if latest_posts or latest_trainings %} Just recently, we've published:
|
Your Blender Studio subscription #{{subscription.pk}} has expired a while back. We miss you -- and you are missing some exciting content on Blender Studio as well.{% if latest_posts or latest_trainings %} Just recently, we've published:
|
||||||
{% for post in latest_posts|slice:":2" %}
|
{% for post in latest_posts|slice:":2" %}
|
||||||
|
@ -36,7 +36,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
<p {% if message.tags %} class="alert alert-sm alert-success alert-{{ message.tags }}" {% endif %}>
|
<p {% if message.tags %} class="alert alert-sm alert-{{ message.tags }}" {% endif %}>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</p>
|
</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -53,6 +53,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'subscriptions/components/total.html' with button_text="Continue to Payment" %}
|
{% include 'subscriptions/components/total.html' with button_text="Continue to Payment" %}
|
||||||
|
|
||||||
|
{% with gw_bank=form.gateway.field.queryset.last %}
|
||||||
|
{% if current_plan_variation.collection_method == "manual" and gw_bank.name == "bank" %}
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col text-end">
|
||||||
|
<button class="btn" type="submit" name="gateway" value="bank">Pay via Bank Transfer</button>
|
||||||
|
<span data-tooltip title="{{ gw_bank.form_description|striptags }}" class="text-left"><i class="i-info text-info"></i></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</form>
|
</form>
|
||||||
|
@ -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 %}
|
|
@ -93,10 +93,10 @@
|
|||||||
|
|
||||||
{% if not subscription.is_cancelled %}
|
{% if not subscription.is_cancelled %}
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<a class="small"
|
<form method="POST" action="{% url 'subscriptions:payment-method-change' subscription_id=subscription.id %}">
|
||||||
href="{% url 'subscriptions:payment-method-change' subscription_id=subscription.id %}">
|
{% csrf_token %}
|
||||||
Change
|
<button type="submit" class="small">Change</button>
|
||||||
</a>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -5,7 +5,6 @@ from django import template
|
|||||||
|
|
||||||
from looper.models import PlanVariation, Subscription
|
from looper.models import PlanVariation, Subscription
|
||||||
import looper.money
|
import looper.money
|
||||||
import looper.taxes
|
|
||||||
|
|
||||||
import subscriptions.queries
|
import subscriptions.queries
|
||||||
|
|
||||||
|
158
subscriptions/tests/_responses/stripe_get_cs_eur.yaml
Normal file
158
subscriptions/tests/_responses/stripe_get_cs_eur.yaml
Normal 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
|
67
subscriptions/tests/_responses/stripe_get_cs_setup.yaml
Normal file
67
subscriptions/tests/_responses/stripe_get_cs_setup.yaml
Normal 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
|
132
subscriptions/tests/_responses/stripe_get_cs_usd.yaml
Normal file
132
subscriptions/tests/_responses/stripe_get_cs_usd.yaml
Normal 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
|
59
subscriptions/tests/_responses/stripe_new_cs_eur.yaml
Normal file
59
subscriptions/tests/_responses/stripe_new_cs_eur.yaml
Normal 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
|
51
subscriptions/tests/_responses/stripe_new_cs_setup.yaml
Normal file
51
subscriptions/tests/_responses/stripe_new_cs_setup.yaml
Normal 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
|
58
subscriptions/tests/_responses/stripe_new_cs_usd.yaml
Normal file
58
subscriptions/tests/_responses/stripe_new_cs_usd.yaml
Normal 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
|
9
subscriptions/tests/_responses/vies_valid.yaml
Normal file
9
subscriptions/tests/_responses/vies_valid.yaml
Normal 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
|
106
subscriptions/tests/_responses/vies_wsdl.yaml
Normal file
106
subscriptions/tests/_responses/vies_wsdl.yaml
Normal 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
|
@ -1,6 +1,6 @@
|
|||||||
|
from typing import Tuple
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.db.models import signals
|
from django.db.models import signals
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
@ -8,10 +8,12 @@ from django.urls import reverse
|
|||||||
import factory
|
import factory
|
||||||
import responses
|
import responses
|
||||||
|
|
||||||
from common.tests.factories.subscriptions import create_customer_with_billing_address
|
from looper.tests.factories import create_customer_with_billing_address
|
||||||
|
import looper.models
|
||||||
|
|
||||||
import users.tests.util as util
|
import users.tests.util as util
|
||||||
|
|
||||||
User = get_user_model()
|
responses_dir = 'subscriptions/tests/_responses/'
|
||||||
|
|
||||||
|
|
||||||
def _write_mail(mail, index=0):
|
def _write_mail(mail, index=0):
|
||||||
@ -24,16 +26,41 @@ def _write_mail(mail, index=0):
|
|||||||
f.write(str(content))
|
f.write(str(content))
|
||||||
|
|
||||||
|
|
||||||
|
def responses_from_file(file_name: str, rsps=responses, order_id=None):
|
||||||
|
"""Add a response mock from file, override `order_id` metadata with a given one."""
|
||||||
|
rsps._add_from_file(f'{responses_dir}{file_name}')
|
||||||
|
# Replace metadata's "order_id" hardcoded in the response YAML with current order ID,
|
||||||
|
# because it differs depending on whether this test is run alone or with all the tests.
|
||||||
|
for _ in rsps.registered():
|
||||||
|
if '%5D=payment_intent' in _.url:
|
||||||
|
assert '\"order_id\": \"1' in _.body
|
||||||
|
_.body = _.body.replace('\"order_id\": \"1', f'\"order_id\": \"{order_id}')
|
||||||
|
|
||||||
|
|
||||||
class BaseSubscriptionTestCase(TestCase):
|
class BaseSubscriptionTestCase(TestCase):
|
||||||
|
fixtures = ['gateways']
|
||||||
|
|
||||||
|
def _get_url_for(self, **filter_params) -> Tuple[str, looper.models.PlanVariation]:
|
||||||
|
plan_variation = looper.models.PlanVariation.objects.active().get(**filter_params)
|
||||||
|
return (
|
||||||
|
reverse(
|
||||||
|
'subscriptions:join-billing-details',
|
||||||
|
kwargs={'plan_variation_id': plan_variation.pk},
|
||||||
|
),
|
||||||
|
plan_variation,
|
||||||
|
)
|
||||||
|
|
||||||
@factory.django.mute_signals(signals.pre_save, signals.post_save)
|
@factory.django.mute_signals(signals.pre_save, signals.post_save)
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
# Allow requests to Braintree Sandbox
|
# Allow requests to Braintree Sandbox
|
||||||
responses.add_passthru('https://api.sandbox.braintreegateway.com:443/')
|
responses.add_passthru('https://api.sandbox.braintreegateway.com:443/')
|
||||||
|
|
||||||
# Create the admin user used for logging
|
# Create the admin user used for logging
|
||||||
self.admin_user = util.create_admin_log_user()
|
self.admin_user = util.create_admin_log_user()
|
||||||
|
|
||||||
self.user = create_customer_with_billing_address(
|
self.customer = create_customer_with_billing_address(
|
||||||
full_name='Алексей Н.',
|
full_name='Алексей Н.',
|
||||||
company='Testcompany B.V.',
|
company='Testcompany B.V.',
|
||||||
street_address='Billing street 1',
|
street_address='Billing street 1',
|
||||||
@ -43,11 +70,18 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
region='North Holland',
|
region='North Holland',
|
||||||
country='NL',
|
country='NL',
|
||||||
vat_number='NL-KVK-41202535',
|
vat_number='NL-KVK-41202535',
|
||||||
billing_email='billing@example.com',
|
email='billing@example.com',
|
||||||
)
|
)
|
||||||
self.customer = self.user.customer
|
self.user = self.customer.user
|
||||||
self.billing_address = self.customer.billing_address
|
self.billing_address = self.customer.billing_address
|
||||||
|
|
||||||
|
responses._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
super().tearDown()
|
||||||
|
responses.stop()
|
||||||
|
responses.reset()
|
||||||
|
|
||||||
def _mock_vies_response(self, is_valid=True, is_broken=False):
|
def _mock_vies_response(self, is_valid=True, is_broken=False):
|
||||||
path = os.path.abspath(__file__)
|
path = os.path.abspath(__file__)
|
||||||
dir_path = os.path.join(os.path.dirname(path), 'vies')
|
dir_path = os.path.join(os.path.dirname(path), 'vies')
|
||||||
@ -86,12 +120,13 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
self._assert_continue_to_payment_displayed(response)
|
self._assert_continue_to_payment_displayed(response)
|
||||||
self.assertContains(response, 'id_street_address')
|
self.assertContains(response, 'id_street_address')
|
||||||
self.assertContains(response, 'id_full_name')
|
self.assertContains(response, 'id_full_name')
|
||||||
|
self.assertContains(response, 'name="gateway" value="stripe"')
|
||||||
|
|
||||||
def _assert_payment_form_displayed(self, response):
|
def _assert_pay_via_bank_not_displayed(self, response):
|
||||||
self.assertNotContains(response, 'Pricing has been updated')
|
self.assertNotContains(response, 'name="gateway" value="bank"')
|
||||||
self.assertNotContains(response, 'Continue to Payment')
|
|
||||||
self.assertContains(response, 'payment method')
|
def _assert_pay_via_bank_displayed(self, response):
|
||||||
self.assertContains(response, 'Confirm and Pay')
|
self.assertContains(response, 'name="gateway" value="bank"')
|
||||||
|
|
||||||
def _assert_pricing_has_been_updated(self, response):
|
def _assert_pricing_has_been_updated(self, response):
|
||||||
self.assertContains(response, 'Pricing has been updated')
|
self.assertContains(response, 'Pricing has been updated')
|
||||||
@ -319,7 +354,7 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
self.assertContains(response, '<h2 class="h3">Bank details:</h2>', html=True)
|
self.assertContains(response, '<h2 class="h3">Bank details:</h2>', html=True)
|
||||||
self.assertContains(response, 'on hold')
|
self.assertContains(response, 'on hold')
|
||||||
self.assertContains(response, 'NL07 INGB 0008 4489 82')
|
self.assertContains(response, 'NL07 INGB 0008 4489 82')
|
||||||
subscription = response.wsgi_request.user.subscription_set.first()
|
subscription = response.wsgi_request.user.customer.subscription_set.first()
|
||||||
self.assertContains(
|
self.assertContains(
|
||||||
response, f'Blender Studio order-{subscription.latest_order().display_number}'
|
response, f'Blender Studio order-{subscription.latest_order().display_number}'
|
||||||
)
|
)
|
||||||
@ -341,11 +376,11 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
self.assertEqual(len(mail.outbox), 0)
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
def _assert_bank_transfer_email_is_sent(self, subscription):
|
def _assert_bank_transfer_email_is_sent(self, subscription):
|
||||||
user = subscription.user
|
customer = subscription.customer
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
_write_mail(mail)
|
_write_mail(mail)
|
||||||
email = mail.outbox[0]
|
email = mail.outbox[0]
|
||||||
self.assertEqual(email.to, [user.customer.billing_email])
|
self.assertEqual(email.to, [customer.billing_address.email])
|
||||||
# TODO(anna): set the correct reply_to
|
# TODO(anna): set the correct reply_to
|
||||||
self.assertEqual(email.reply_to, [])
|
self.assertEqual(email.reply_to, [])
|
||||||
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
||||||
@ -384,11 +419,11 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
self.assertIn('Recurring total: €\xa026.45', email_body.replace(' ', ' '))
|
self.assertIn('Recurring total: €\xa026.45', email_body.replace(' ', ' '))
|
||||||
|
|
||||||
def _assert_subscription_activated_email_is_sent(self, subscription):
|
def _assert_subscription_activated_email_is_sent(self, subscription):
|
||||||
user = subscription.user
|
customer = subscription.customer
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
_write_mail(mail)
|
_write_mail(mail)
|
||||||
email = mail.outbox[0]
|
email = mail.outbox[0]
|
||||||
self.assertEqual(email.to, [user.customer.billing_email])
|
self.assertEqual(email.to, [customer.billing_address.email])
|
||||||
# TODO(anna): set the correct reply_to
|
# TODO(anna): set the correct reply_to
|
||||||
self.assertEqual(email.reply_to, [])
|
self.assertEqual(email.reply_to, [])
|
||||||
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
||||||
@ -397,17 +432,17 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
self.assertEqual(email.alternatives[0][1], 'text/html')
|
self.assertEqual(email.alternatives[0][1], 'text/html')
|
||||||
for email_body in (email.body, email.alternatives[0][0]):
|
for email_body in (email.body, email.alternatives[0][0]):
|
||||||
self.assertIn('activated', email_body)
|
self.assertIn('activated', email_body)
|
||||||
self.assertIn(f'Dear {user.customer.full_name},', email_body)
|
self.assertIn(f'Dear {customer.billing_address.full_name},', email_body)
|
||||||
self.assertIn(reverse('user-settings-billing'), email_body)
|
self.assertIn(reverse('user-settings-billing'), email_body)
|
||||||
self.assertIn('Automatic renewal subscription', email_body)
|
self.assertIn('Automatic renewal subscription', email_body)
|
||||||
self.assertIn('Blender Studio Team', email_body)
|
self.assertIn('Blender Studio Team', email_body)
|
||||||
|
|
||||||
def _assert_team_subscription_activated_email_is_sent(self, subscription):
|
def _assert_team_subscription_activated_email_is_sent(self, subscription):
|
||||||
user = subscription.user
|
customer = subscription.customer
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
_write_mail(mail)
|
_write_mail(mail)
|
||||||
email = mail.outbox[0]
|
email = mail.outbox[0]
|
||||||
self.assertEqual(email.to, [user.customer.billing_email])
|
self.assertEqual(email.to, [customer.billing_address.email])
|
||||||
# TODO(anna): set the correct reply_to
|
# TODO(anna): set the correct reply_to
|
||||||
self.assertEqual(email.reply_to, [])
|
self.assertEqual(email.reply_to, [])
|
||||||
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
||||||
@ -416,17 +451,17 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
self.assertEqual(email.alternatives[0][1], 'text/html')
|
self.assertEqual(email.alternatives[0][1], 'text/html')
|
||||||
for email_body in (email.body, email.alternatives[0][0]):
|
for email_body in (email.body, email.alternatives[0][0]):
|
||||||
self.assertIn('activated', email_body)
|
self.assertIn('activated', email_body)
|
||||||
self.assertIn(f'Dear {user.customer.full_name},', email_body)
|
self.assertIn(f'Dear {customer.billing_address.full_name},', email_body)
|
||||||
self.assertIn(reverse('user-settings-billing'), email_body)
|
self.assertIn(reverse('user-settings-billing'), email_body)
|
||||||
self.assertIn('Automatic renewal, 15 seats subscription', email_body)
|
self.assertIn('Automatic renewal, 15 seats subscription', email_body)
|
||||||
self.assertIn('Blender Studio Team', email_body)
|
self.assertIn('Blender Studio Team', email_body)
|
||||||
|
|
||||||
def _assert_subscription_deactivated_email_is_sent(self, subscription):
|
def _assert_subscription_deactivated_email_is_sent(self, subscription):
|
||||||
user = subscription.user
|
customer = subscription.customer
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
_write_mail(mail)
|
_write_mail(mail)
|
||||||
email = mail.outbox[0]
|
email = mail.outbox[0]
|
||||||
self.assertEqual(email.to, [user.customer.billing_email])
|
self.assertEqual(email.to, [customer.billing_address.email])
|
||||||
# TODO(anna): set the correct reply_to
|
# TODO(anna): set the correct reply_to
|
||||||
self.assertEqual(email.reply_to, [])
|
self.assertEqual(email.reply_to, [])
|
||||||
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
||||||
@ -440,11 +475,12 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
self.assertIn('Blender Studio Team', email_body)
|
self.assertIn('Blender Studio Team', email_body)
|
||||||
|
|
||||||
def _assert_payment_soft_failed_email_is_sent(self, subscription):
|
def _assert_payment_soft_failed_email_is_sent(self, subscription):
|
||||||
user = subscription.user
|
customer = subscription.customer
|
||||||
|
user = customer.user
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
_write_mail(mail)
|
_write_mail(mail)
|
||||||
email = mail.outbox[0]
|
email = mail.outbox[0]
|
||||||
self.assertEqual(email.to, [user.customer.billing_email])
|
self.assertEqual(email.to, [user.customer.billing_address.email])
|
||||||
# TODO(anna): set the correct reply_to
|
# TODO(anna): set the correct reply_to
|
||||||
self.assertEqual(email.reply_to, [])
|
self.assertEqual(email.reply_to, [])
|
||||||
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
||||||
@ -454,7 +490,7 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(email.alternatives[0][1], 'text/html')
|
self.assertEqual(email.alternatives[0][1], 'text/html')
|
||||||
for email_body in (email.body, email.alternatives[0][0]):
|
for email_body in (email.body, email.alternatives[0][0]):
|
||||||
self.assertIn(f'Dear {user.customer.full_name},', email_body)
|
self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
|
||||||
self.assertIn('Automatic payment', email_body)
|
self.assertIn('Automatic payment', email_body)
|
||||||
self.assertIn('failed', email_body)
|
self.assertIn('failed', email_body)
|
||||||
self.assertIn('try again', email_body)
|
self.assertIn('try again', email_body)
|
||||||
@ -470,11 +506,12 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
self.assertIn('Blender Studio Team', email_body)
|
self.assertIn('Blender Studio Team', email_body)
|
||||||
|
|
||||||
def _assert_payment_failed_email_is_sent(self, subscription):
|
def _assert_payment_failed_email_is_sent(self, subscription):
|
||||||
user = subscription.user
|
customer = subscription.customer
|
||||||
|
user = customer.user
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
_write_mail(mail)
|
_write_mail(mail)
|
||||||
email = mail.outbox[0]
|
email = mail.outbox[0]
|
||||||
self.assertEqual(email.to, [user.customer.billing_email])
|
self.assertEqual(email.to, [user.customer.billing_address.email])
|
||||||
# TODO(anna): set the correct reply_to
|
# TODO(anna): set the correct reply_to
|
||||||
self.assertEqual(email.reply_to, [])
|
self.assertEqual(email.reply_to, [])
|
||||||
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
||||||
@ -482,7 +519,7 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
self.assertEqual(email.subject, 'Blender Studio Subscription: payment failed')
|
self.assertEqual(email.subject, 'Blender Studio Subscription: payment failed')
|
||||||
self.assertEqual(email.alternatives[0][1], 'text/html')
|
self.assertEqual(email.alternatives[0][1], 'text/html')
|
||||||
for email_body in (email.body, email.alternatives[0][0]):
|
for email_body in (email.body, email.alternatives[0][0]):
|
||||||
self.assertIn(f'Dear {user.customer.full_name},', email_body)
|
self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
|
||||||
self.assertIn('Automatic payment', email_body)
|
self.assertIn('Automatic payment', email_body)
|
||||||
self.assertIn('failed', email_body)
|
self.assertIn('failed', email_body)
|
||||||
self.assertIn('3 times', email_body)
|
self.assertIn('3 times', email_body)
|
||||||
@ -497,11 +534,12 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
self.assertIn('Blender Studio Team', email_body)
|
self.assertIn('Blender Studio Team', email_body)
|
||||||
|
|
||||||
def _assert_payment_paid_email_is_sent(self, subscription):
|
def _assert_payment_paid_email_is_sent(self, subscription):
|
||||||
user = subscription.user
|
customer = subscription.customer
|
||||||
|
user = customer.user
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
_write_mail(mail)
|
_write_mail(mail)
|
||||||
email = mail.outbox[0]
|
email = mail.outbox[0]
|
||||||
self.assertEqual(email.to, [user.customer.billing_email])
|
self.assertEqual(email.to, [user.customer.billing_address.email])
|
||||||
# TODO(anna): set the correct reply_to
|
# TODO(anna): set the correct reply_to
|
||||||
self.assertEqual(email.reply_to, [])
|
self.assertEqual(email.reply_to, [])
|
||||||
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
|
||||||
@ -509,7 +547,7 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
self.assertEqual(email.subject, 'Blender Studio Subscription: payment received')
|
self.assertEqual(email.subject, 'Blender Studio Subscription: payment received')
|
||||||
self.assertEqual(email.alternatives[0][1], 'text/html')
|
self.assertEqual(email.alternatives[0][1], 'text/html')
|
||||||
for email_body in (email.body, email.alternatives[0][0]):
|
for email_body in (email.body, email.alternatives[0][0]):
|
||||||
self.assertIn(f'Dear {user.customer.full_name},', email_body)
|
self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
|
||||||
self.assertIn('Automatic monthly payment', email_body)
|
self.assertIn('Automatic monthly payment', email_body)
|
||||||
self.assertIn('successful', email_body)
|
self.assertIn('successful', email_body)
|
||||||
self.assertIn('$\xa011.10', email_body)
|
self.assertIn('$\xa011.10', email_body)
|
||||||
@ -523,7 +561,8 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
self.assertIn('Blender Studio Team', email_body)
|
self.assertIn('Blender Studio Team', email_body)
|
||||||
|
|
||||||
def _assert_managed_subscription_notification_email_is_sent(self, subscription):
|
def _assert_managed_subscription_notification_email_is_sent(self, subscription):
|
||||||
user = subscription.user
|
customer = subscription.customer
|
||||||
|
user = customer.user
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
_write_mail(mail)
|
_write_mail(mail)
|
||||||
email = mail.outbox[0]
|
email = mail.outbox[0]
|
||||||
@ -533,7 +572,7 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
self.assertEqual(email.subject, 'Blender Studio managed subscription needs attention')
|
self.assertEqual(email.subject, 'Blender Studio managed subscription needs attention')
|
||||||
self.assertEqual(email.alternatives[0][1], 'text/html')
|
self.assertEqual(email.alternatives[0][1], 'text/html')
|
||||||
for email_body in (email.body, email.alternatives[0][0]):
|
for email_body in (email.body, email.alternatives[0][0]):
|
||||||
self.assertIn(f'{user.customer.full_name} has', email_body)
|
self.assertIn(f'{user.customer.billing_address.full_name} has', email_body)
|
||||||
self.assertIn('its next payment date', email_body)
|
self.assertIn('its next payment date', email_body)
|
||||||
self.assertIn('$\xa011.10', email_body)
|
self.assertIn('$\xa011.10', email_body)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
@ -542,16 +581,17 @@ class BaseSubscriptionTestCase(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _assert_subscription_expired_email_is_sent(self, subscription):
|
def _assert_subscription_expired_email_is_sent(self, subscription):
|
||||||
user = subscription.user
|
customer = subscription.customer
|
||||||
|
user = customer.user
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
_write_mail(mail)
|
_write_mail(mail)
|
||||||
email = mail.outbox[0]
|
email = mail.outbox[0]
|
||||||
self.assertEqual(email.to, [subscription.user.email])
|
self.assertEqual(email.to, [user.email])
|
||||||
self.assertEqual(email.from_email, 'webmaster@localhost')
|
self.assertEqual(email.from_email, 'webmaster@localhost')
|
||||||
self.assertEqual(email.subject, 'We miss you at Blender Studio')
|
self.assertEqual(email.subject, 'We miss you at Blender Studio')
|
||||||
self.assertEqual(email.alternatives[0][1], 'text/html')
|
self.assertEqual(email.alternatives[0][1], 'text/html')
|
||||||
for email_body in (email.body, email.alternatives[0][0]):
|
for email_body in (email.body, email.alternatives[0][0]):
|
||||||
self.assertIn(f'Dear {user.customer.full_name}', email_body)
|
self.assertIn(f'Dear {user.customer.billing_address.full_name}', email_body)
|
||||||
self.assertIn(f'#{subscription.pk}', email_body)
|
self.assertIn(f'#{subscription.pk}', email_body)
|
||||||
self.assertIn('has expired', email_body)
|
self.assertIn('has expired', email_body)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
|
@ -11,27 +11,26 @@ from looper import admin_log
|
|||||||
from looper.clock import Clock
|
from looper.clock import Clock
|
||||||
from looper.models import Gateway, Subscription
|
from looper.models import Gateway, Subscription
|
||||||
from looper.money import Money
|
from looper.money import Money
|
||||||
|
from looper.tests.factories import SubscriptionFactory, create_customer_with_billing_address
|
||||||
|
|
||||||
from common.tests.factories.subscriptions import (
|
|
||||||
SubscriptionFactory,
|
|
||||||
create_customer_with_billing_address,
|
|
||||||
)
|
|
||||||
from subscriptions.tests.base import BaseSubscriptionTestCase
|
from subscriptions.tests.base import BaseSubscriptionTestCase
|
||||||
import subscriptions.tasks
|
import subscriptions.tasks
|
||||||
import users.tasks
|
import users.tasks
|
||||||
import users.tests.util as util
|
import users.tests.util as util
|
||||||
|
from common.tests.factories.users import OAuthUserInfoFactory
|
||||||
|
|
||||||
|
|
||||||
class TestClock(BaseSubscriptionTestCase):
|
class TestClockBraintree(BaseSubscriptionTestCase):
|
||||||
def _create_subscription_due_now(self) -> Subscription:
|
def _create_subscription_due_now(self) -> Subscription:
|
||||||
user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
|
customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
|
||||||
|
OAuthUserInfoFactory(user=customer.user, oauth_user_id=554433)
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
with mock.patch('django.utils.timezone.now') as mock_now:
|
with mock.patch('django.utils.timezone.now') as mock_now:
|
||||||
mock_now.return_value = now + relativedelta(months=-1)
|
mock_now.return_value = now + relativedelta(months=-1)
|
||||||
# print('fake now:', mock_now.return_value)
|
# print('fake now:', mock_now.return_value)
|
||||||
subscription = SubscriptionFactory(
|
subscription = SubscriptionFactory(
|
||||||
user=user,
|
customer=customer,
|
||||||
payment_method__user_id=user.pk,
|
payment_method__customer_id=customer.pk,
|
||||||
payment_method__recognisable_name='Test payment method',
|
payment_method__recognisable_name='Test payment method',
|
||||||
payment_method__gateway=Gateway.objects.get(name='braintree'),
|
payment_method__gateway=Gateway.objects.get(name='braintree'),
|
||||||
currency='USD',
|
currency='USD',
|
||||||
@ -52,6 +51,9 @@ class TestClock(BaseSubscriptionTestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
# Allow requests to Braintree Sandbox
|
||||||
|
responses.add_passthru('https://api.sandbox.braintreegateway.com:443/')
|
||||||
|
|
||||||
self.subscription = self._create_subscription_due_now()
|
self.subscription = self._create_subscription_due_now()
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
@ -114,7 +116,7 @@ class TestClock(BaseSubscriptionTestCase):
|
|||||||
|
|
||||||
# Tick the clock and check that order and transaction were created
|
# Tick the clock and check that order and transaction were created
|
||||||
util.mock_blender_id_badger_badger_response(
|
util.mock_blender_id_badger_badger_response(
|
||||||
'revoke', 'cloud_subscriber', self.subscription.user.oauth_info.oauth_user_id
|
'revoke', 'cloud_subscriber', self.subscription.customer.user.oauth_info.oauth_user_id
|
||||||
)
|
)
|
||||||
Clock().tick()
|
Clock().tick()
|
||||||
|
|
||||||
@ -167,7 +169,7 @@ class TestClock(BaseSubscriptionTestCase):
|
|||||||
|
|
||||||
# Create another active subscription for the same user
|
# Create another active subscription for the same user
|
||||||
SubscriptionFactory(
|
SubscriptionFactory(
|
||||||
user=self.subscription.user,
|
customer=self.subscription.customer,
|
||||||
payment_method=self.subscription.payment_method,
|
payment_method=self.subscription.payment_method,
|
||||||
currency='USD',
|
currency='USD',
|
||||||
price=Money('USD', 1110),
|
price=Money('USD', 1110),
|
||||||
@ -190,10 +192,12 @@ class TestClock(BaseSubscriptionTestCase):
|
|||||||
)
|
)
|
||||||
def test_automated_payment_paid_email_is_sent(self):
|
def test_automated_payment_paid_email_is_sent(self):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
self.assertEqual(self.subscription.collection_method, 'automatic')
|
||||||
|
|
||||||
# Tick the clock and check that subscription renews, order and transaction were created
|
# Tick the clock and check that subscription renews, order and transaction were created
|
||||||
with patch(
|
with patch(
|
||||||
'looper.gateways.BraintreeGateway.transact_sale', return_value='mock-transaction-id'
|
'looper.gateways.BraintreeGateway.transact_sale',
|
||||||
|
return_value={'transaction_id': 'mock-transaction-id'},
|
||||||
):
|
):
|
||||||
Clock().tick()
|
Clock().tick()
|
||||||
|
|
||||||
@ -254,9 +258,9 @@ class TestClock(BaseSubscriptionTestCase):
|
|||||||
class TestClockExpiredSubscription(BaseSubscriptionTestCase):
|
class TestClockExpiredSubscription(BaseSubscriptionTestCase):
|
||||||
def test_subscription_on_hold_not_long_enough(self):
|
def test_subscription_on_hold_not_long_enough(self):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
|
customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
|
||||||
self.subscription = SubscriptionFactory(
|
self.subscription = SubscriptionFactory(
|
||||||
user=user,
|
customer=customer,
|
||||||
status='on-hold',
|
status='on-hold',
|
||||||
# payment date has passed, but not long enough ago
|
# payment date has passed, but not long enough ago
|
||||||
next_payment=now - timedelta(weeks=4),
|
next_payment=now - timedelta(weeks=4),
|
||||||
@ -283,15 +287,16 @@ class TestClockExpiredSubscription(BaseSubscriptionTestCase):
|
|||||||
@responses.activate
|
@responses.activate
|
||||||
def test_subscription_on_hold_too_long_status_changed_to_expired_email_sent(self):
|
def test_subscription_on_hold_too_long_status_changed_to_expired_email_sent(self):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
|
customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
|
||||||
|
OAuthUserInfoFactory(user=customer.user, oauth_user_id=223344)
|
||||||
self.subscription = SubscriptionFactory(
|
self.subscription = SubscriptionFactory(
|
||||||
user=user,
|
customer=customer,
|
||||||
status='on-hold',
|
status='on-hold',
|
||||||
# payment date has passed a long long time ago
|
# payment date has passed a long long time ago
|
||||||
next_payment=now - timedelta(weeks=4 * 10),
|
next_payment=now - timedelta(weeks=4 * 10),
|
||||||
)
|
)
|
||||||
util.mock_blender_id_badger_badger_response(
|
util.mock_blender_id_badger_badger_response(
|
||||||
'revoke', 'cloud_subscriber', user.oauth_info.oauth_user_id
|
'revoke', 'cloud_subscriber', customer.user.oauth_info.oauth_user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
Clock().tick()
|
Clock().tick()
|
||||||
|
@ -21,7 +21,6 @@ class TestBillingAddressForm(BaseSubscriptionTestCase):
|
|||||||
def test_instance_loads_both_address_and_customer_data(self):
|
def test_instance_loads_both_address_and_customer_data(self):
|
||||||
form = BillingAddressForm(instance=self.billing_address)
|
form = BillingAddressForm(instance=self.billing_address)
|
||||||
|
|
||||||
# N.B.: email is loaded from Customer.billing_email
|
|
||||||
self.assertEqual(form['email'].value(), 'billing@example.com')
|
self.assertEqual(form['email'].value(), 'billing@example.com')
|
||||||
self.assertEqual(form['company'].value(), 'Testcompany B.V.')
|
self.assertEqual(form['company'].value(), 'Testcompany B.V.')
|
||||||
self.assertEqual(form['country'].value(), 'NL')
|
self.assertEqual(form['country'].value(), 'NL')
|
||||||
@ -215,15 +214,12 @@ class TestBillingAddressForm(BaseSubscriptionTestCase):
|
|||||||
class TestPaymentForm(BaseSubscriptionTestCase):
|
class TestPaymentForm(BaseSubscriptionTestCase):
|
||||||
required_payment_form_data = {
|
required_payment_form_data = {
|
||||||
'gateway': 'bank',
|
'gateway': 'bank',
|
||||||
'payment_method_nonce': 'fake-nonce',
|
|
||||||
'plan_variation_id': 1,
|
'plan_variation_id': 1,
|
||||||
'price': '9.90',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_instance_loads_both_address_and_customer_data(self):
|
def test_instance_loads_both_address_and_customer_data(self):
|
||||||
form = PaymentForm(instance=self.billing_address)
|
form = PaymentForm(instance=self.billing_address)
|
||||||
|
|
||||||
# N.B.: email is loaded from Customer.billing_email
|
|
||||||
self.assertEqual(form['email'].value(), 'billing@example.com')
|
self.assertEqual(form['email'].value(), 'billing@example.com')
|
||||||
self.assertEqual(form['company'].value(), 'Testcompany B.V.')
|
self.assertEqual(form['company'].value(), 'Testcompany B.V.')
|
||||||
self.assertEqual(form['country'].value(), 'NL')
|
self.assertEqual(form['country'].value(), 'NL')
|
||||||
@ -246,8 +242,6 @@ class TestPaymentForm(BaseSubscriptionTestCase):
|
|||||||
'email': ['This field is required.'],
|
'email': ['This field is required.'],
|
||||||
'full_name': ['This field is required.'],
|
'full_name': ['This field is required.'],
|
||||||
'gateway': ['This field is required.'],
|
'gateway': ['This field is required.'],
|
||||||
'payment_method_nonce': ['This field is required.'],
|
|
||||||
'price': ['This field is required.'],
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from looper.tests.factories import SubscriptionFactory
|
||||||
|
|
||||||
from common.tests.factories.users import UserFactory
|
from common.tests.factories.users import UserFactory
|
||||||
from common.tests.factories.subscriptions import SubscriptionFactory, TeamFactory
|
from common.tests.factories.subscriptions import TeamFactory
|
||||||
|
|
||||||
import looper.models
|
import looper.models
|
||||||
|
|
||||||
@ -25,12 +27,12 @@ class TestHasActiveSubscription(TestCase):
|
|||||||
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
|
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(has_active_subscription(subscription.user))
|
self.assertTrue(has_active_subscription(subscription.customer.user))
|
||||||
|
|
||||||
def test_false_when_subscription_inactive(self):
|
def test_false_when_subscription_inactive(self):
|
||||||
subscription = SubscriptionFactory(plan_id=1)
|
subscription = SubscriptionFactory(plan_id=1)
|
||||||
|
|
||||||
self.assertFalse(has_active_subscription(subscription.user))
|
self.assertFalse(has_active_subscription(subscription.customer.user))
|
||||||
|
|
||||||
def test_false_when_team_subscription_inactive(self):
|
def test_false_when_team_subscription_inactive(self):
|
||||||
team = TeamFactory(subscription__plan_id=1)
|
team = TeamFactory(subscription__plan_id=1)
|
||||||
@ -60,17 +62,17 @@ class TestHasNotYetCancelledSubscription(TestCase):
|
|||||||
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
|
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(has_not_yet_cancelled_subscription(subscription.user))
|
self.assertTrue(has_not_yet_cancelled_subscription(subscription.customer.user))
|
||||||
|
|
||||||
def test_false_when_subscription_cancelled(self):
|
def test_false_when_subscription_cancelled(self):
|
||||||
subscription = SubscriptionFactory(plan_id=1, status='cancelled')
|
subscription = SubscriptionFactory(plan_id=1, status='cancelled')
|
||||||
|
|
||||||
self.assertFalse(has_not_yet_cancelled_subscription(subscription.user))
|
self.assertFalse(has_not_yet_cancelled_subscription(subscription.customer.user))
|
||||||
|
|
||||||
def test_true_when_subscription_inactive(self):
|
def test_true_when_subscription_inactive(self):
|
||||||
subscription = SubscriptionFactory(plan_id=1)
|
subscription = SubscriptionFactory(plan_id=1)
|
||||||
|
|
||||||
self.assertTrue(has_not_yet_cancelled_subscription(subscription.user))
|
self.assertTrue(has_not_yet_cancelled_subscription(subscription.customer.user))
|
||||||
|
|
||||||
def test_false_when_team_subscription_inactive(self):
|
def test_false_when_team_subscription_inactive(self):
|
||||||
team = TeamFactory(subscription__plan_id=1)
|
team = TeamFactory(subscription__plan_id=1)
|
||||||
@ -103,7 +105,7 @@ class TestHasNotYetCancelledSubscription(TestCase):
|
|||||||
)
|
)
|
||||||
team.team_users.create(user=UserFactory())
|
team.team_users.create(user=UserFactory())
|
||||||
SubscriptionFactory(
|
SubscriptionFactory(
|
||||||
user=team.team_users.first().user,
|
customer=team.team_users.first().user.customer,
|
||||||
plan_id=1,
|
plan_id=1,
|
||||||
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
|
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
|
||||||
)
|
)
|
||||||
@ -117,7 +119,7 @@ class TestHasNotYetCancelledSubscription(TestCase):
|
|||||||
)
|
)
|
||||||
team.team_users.create(user=UserFactory())
|
team.team_users.create(user=UserFactory())
|
||||||
SubscriptionFactory(
|
SubscriptionFactory(
|
||||||
user=team.team_users.first().user,
|
customer=team.team_users.first().user.customer,
|
||||||
plan_id=1,
|
plan_id=1,
|
||||||
status='cancelled',
|
status='cancelled',
|
||||||
)
|
)
|
||||||
@ -137,12 +139,12 @@ class TestHasSubscription(TestCase):
|
|||||||
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
|
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(has_subscription(subscription.user))
|
self.assertTrue(has_subscription(subscription.customer.user))
|
||||||
|
|
||||||
def test_true_when_subscription_inactive(self):
|
def test_true_when_subscription_inactive(self):
|
||||||
subscription = SubscriptionFactory(plan_id=1)
|
subscription = SubscriptionFactory(plan_id=1)
|
||||||
|
|
||||||
self.assertTrue(has_subscription(subscription.user))
|
self.assertTrue(has_subscription(subscription.customer.user))
|
||||||
|
|
||||||
def test_true_when_team_subscription_inactive(self):
|
def test_true_when_team_subscription_inactive(self):
|
||||||
team = TeamFactory(subscription__plan_id=1)
|
team = TeamFactory(subscription__plan_id=1)
|
||||||
@ -166,12 +168,12 @@ class TestHasSubscription(TestCase):
|
|||||||
is_legacy=True,
|
is_legacy=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(has_subscription(subscription.user))
|
self.assertTrue(has_subscription(subscription.customer.user))
|
||||||
|
|
||||||
def test_true_when_subscription_inactive_and_is_legacy(self):
|
def test_true_when_subscription_inactive_and_is_legacy(self):
|
||||||
subscription = SubscriptionFactory(plan_id=1, is_legacy=True)
|
subscription = SubscriptionFactory(plan_id=1, is_legacy=True)
|
||||||
|
|
||||||
self.assertTrue(has_subscription(subscription.user))
|
self.assertTrue(has_subscription(subscription.customer.user))
|
||||||
|
|
||||||
def test_true_when_team_subscription_inactive_and_is_legacy(self):
|
def test_true_when_team_subscription_inactive_and_is_legacy(self):
|
||||||
team = TeamFactory(subscription__plan_id=1, subscription__is_legacy=True)
|
team = TeamFactory(subscription__plan_id=1, subscription__is_legacy=True)
|
||||||
@ -192,12 +194,12 @@ class TestHasNonLegacySubscription(TestCase):
|
|||||||
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
|
status=list(looper.models.Subscription._ACTIVE_STATUSES)[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(has_non_legacy_subscription(subscription.user))
|
self.assertTrue(has_non_legacy_subscription(subscription.customer.user))
|
||||||
|
|
||||||
def test_true_when_subscription_inactive_and_not_is_legacy(self):
|
def test_true_when_subscription_inactive_and_not_is_legacy(self):
|
||||||
subscription = SubscriptionFactory(plan_id=1)
|
subscription = SubscriptionFactory(plan_id=1)
|
||||||
|
|
||||||
self.assertTrue(has_non_legacy_subscription(subscription.user))
|
self.assertTrue(has_non_legacy_subscription(subscription.customer.user))
|
||||||
|
|
||||||
def test_true_when_team_subscription_inactive_and_not_is_legacy(self):
|
def test_true_when_team_subscription_inactive_and_not_is_legacy(self):
|
||||||
team = TeamFactory(subscription__plan_id=1)
|
team = TeamFactory(subscription__plan_id=1)
|
||||||
@ -208,7 +210,7 @@ class TestHasNonLegacySubscription(TestCase):
|
|||||||
def test_false_when_subscription_inactive_and_is_legacy(self):
|
def test_false_when_subscription_inactive_and_is_legacy(self):
|
||||||
subscription = SubscriptionFactory(plan_id=1, is_legacy=True)
|
subscription = SubscriptionFactory(plan_id=1, is_legacy=True)
|
||||||
|
|
||||||
self.assertFalse(has_non_legacy_subscription(subscription.user))
|
self.assertFalse(has_non_legacy_subscription(subscription.customer.user))
|
||||||
|
|
||||||
def test_false_when_subscription_active_and_is_legacy(self):
|
def test_false_when_subscription_active_and_is_legacy(self):
|
||||||
subscription = SubscriptionFactory(
|
subscription = SubscriptionFactory(
|
||||||
@ -217,7 +219,7 @@ class TestHasNonLegacySubscription(TestCase):
|
|||||||
is_legacy=True,
|
is_legacy=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertFalse(has_non_legacy_subscription(subscription.user))
|
self.assertFalse(has_non_legacy_subscription(subscription.customer.user))
|
||||||
|
|
||||||
def test_false_when_team_subscription_inactive_and_is_legacy(self):
|
def test_false_when_team_subscription_inactive_and_is_legacy(self):
|
||||||
team = TeamFactory(subscription__plan_id=1, subscription__is_legacy=True)
|
team = TeamFactory(subscription__plan_id=1, subscription__is_legacy=True)
|
||||||
|
@ -2,7 +2,7 @@ from django.urls import path, re_path
|
|||||||
|
|
||||||
from looper.views import settings as looper_settings
|
from looper.views import settings as looper_settings
|
||||||
|
|
||||||
from subscriptions.views.join import BillingDetailsView, ConfirmAndPayView
|
from subscriptions.views.join import JoinView
|
||||||
from subscriptions.views.select_plan_variation import (
|
from subscriptions.views.select_plan_variation import (
|
||||||
SelectPlanVariationView,
|
SelectPlanVariationView,
|
||||||
SelectTeamPlanVariationView,
|
SelectTeamPlanVariationView,
|
||||||
@ -26,14 +26,9 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
'join/plan-variation/<int:plan_variation_id>/billing/',
|
'join/plan-variation/<int:plan_variation_id>/billing/',
|
||||||
BillingDetailsView.as_view(),
|
JoinView.as_view(),
|
||||||
name='join-billing-details',
|
name='join-billing-details',
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
'join/plan-variation/<int:plan_variation_id>/confirm/',
|
|
||||||
ConfirmAndPayView.as_view(),
|
|
||||||
name='join-confirm-and-pay',
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
'subscription/<int:subscription_id>/manage/',
|
'subscription/<int:subscription_id>/manage/',
|
||||||
settings.ManageSubscriptionView.as_view(),
|
settings.ManageSubscriptionView.as_view(),
|
||||||
@ -49,15 +44,18 @@ urlpatterns = [
|
|||||||
settings.PaymentMethodChangeView.as_view(),
|
settings.PaymentMethodChangeView.as_view(),
|
||||||
name='payment-method-change',
|
name='payment-method-change',
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
'subscription/<int:subscription_id>/payment-method/change/<stripe_session_id>/',
|
||||||
|
settings.PaymentMethodChangeDoneView.as_view(),
|
||||||
|
name='payment-method-change-done',
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
'subscription/order/<int:order_id>/pay/',
|
'subscription/order/<int:order_id>/pay/',
|
||||||
settings.PayExistingOrderView.as_view(),
|
settings.PayExistingOrderView.as_view(),
|
||||||
name='pay-existing-order',
|
name='pay-existing-order',
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
'settings/billing-address/',
|
'settings/billing-address/', settings.BillingAddressView.as_view(), name='billing-address'
|
||||||
settings.BillingAddressView.as_view(),
|
|
||||||
name='billing-address',
|
|
||||||
),
|
),
|
||||||
path('settings/receipts/', looper_settings.settings_receipts, name='receipts'),
|
path('settings/receipts/', looper_settings.settings_receipts, name='receipts'),
|
||||||
path(
|
path(
|
||||||
|
@ -1,23 +1,21 @@
|
|||||||
"""Views handling subscription management."""
|
"""Views handling subscription management."""
|
||||||
from decimal import Decimal
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.shortcuts import redirect, get_object_or_404
|
from django.shortcuts import redirect, get_object_or_404
|
||||||
|
from django.urls import reverse
|
||||||
from django.views.generic import FormView
|
from django.views.generic import FormView
|
||||||
|
|
||||||
from looper.middleware import COUNTRY_CODE_SESSION_KEY
|
|
||||||
from looper.views.checkout import AbstractPaymentView, CheckoutView
|
|
||||||
import looper.gateways
|
import looper.gateways
|
||||||
import looper.middleware
|
import looper.middleware
|
||||||
import looper.models
|
import looper.models
|
||||||
import looper.money
|
import looper.money
|
||||||
import looper.taxes
|
import looper.taxes
|
||||||
|
from looper.views.checkout_stripe import CheckoutStripeView
|
||||||
|
|
||||||
from subscriptions.forms import BillingAddressForm, PaymentForm, AutomaticPaymentForm
|
from subscriptions.forms import PaymentForm
|
||||||
from subscriptions.middleware import preferred_currency_for_country_code
|
from subscriptions.middleware import preferred_currency_for_country_code
|
||||||
from subscriptions.queries import should_redirect_to_billing
|
from subscriptions.queries import should_redirect_to_billing
|
||||||
from subscriptions.signals import subscription_created_needs_payment
|
from subscriptions.signals import subscription_created_needs_payment
|
||||||
@ -27,111 +25,145 @@ logger = logging.getLogger(__name__)
|
|||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
class _JoinMixin:
|
class JoinView(LoginRequiredMixin, FormView):
|
||||||
customer: looper.models.Customer
|
"""Fill in billing details and initiate Stripe checkout session."""
|
||||||
|
|
||||||
# FIXME(anna): this view uses some functionality of AbstractPaymentView,
|
# FIXME(anna): this view uses some functionality of CheckoutStripeView,
|
||||||
# but cannot directly inherit from them, since JoinView supports creating only one subscription.
|
# but cannot directly inherit it, since JoinView supports creating only one subscription.
|
||||||
get_currency = AbstractPaymentView.get_currency
|
_fetch_or_create_order = CheckoutStripeView._fetch_or_create_order
|
||||||
get_client_token = AbstractPaymentView.get_client_token
|
|
||||||
client_token_session_key = AbstractPaymentView.client_token_session_key
|
|
||||||
erase_client_token = AbstractPaymentView.erase_client_token
|
|
||||||
|
|
||||||
@property
|
template_name = 'subscriptions/join/billing_address.html'
|
||||||
def session_key_prefix(self) -> str:
|
form_class = PaymentForm
|
||||||
"""Separate client tokens by currency code."""
|
|
||||||
currency = self.get_currency()
|
|
||||||
return f'PAYMENT_GATEWAY_CLIENT_TOKEN_{currency}'
|
|
||||||
|
|
||||||
def _get_existing_subscription(self):
|
def _get_existing_subscription(self):
|
||||||
# Exclude cancelled subscriptions because they cannot transition to active
|
# Exclude cancelled subscriptions because they cannot transition to active
|
||||||
existing_subscriptions = self.request.user.subscription_set.exclude(
|
existing_subscriptions = self.request.user.customer.subscription_set.exclude(
|
||||||
status__in=looper.models.Subscription._CANCELLED_STATUSES
|
status__in=looper.models.Subscription._CANCELLED_STATUSES
|
||||||
)
|
)
|
||||||
return existing_subscriptions.first()
|
return existing_subscriptions.first()
|
||||||
|
|
||||||
|
def _set_preferred_currency_and_redirect(self):
|
||||||
|
# If no country is set in the existing address, use GeoIP's
|
||||||
|
geoip_country = self.request.session.get(looper.middleware.COUNTRY_CODE_SESSION_KEY)
|
||||||
|
if geoip_country and (not self.customer or not self.customer.billing_address.country):
|
||||||
|
country = geoip_country
|
||||||
|
else:
|
||||||
|
country = self.customer.billing_address.country
|
||||||
|
currency = preferred_currency_for_country_code(country)
|
||||||
|
if self.plan_variation.currency != currency:
|
||||||
|
# If variation's currency doesn't match, redirect to another plan variation
|
||||||
|
plan_variation = self.plan_variation.in_other_currency(currency)
|
||||||
|
self.request.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY] = currency
|
||||||
|
self.request.session.modified = True
|
||||||
|
return redirect(
|
||||||
|
'subscriptions:join-billing-details', plan_variation_id=plan_variation.pk
|
||||||
|
)
|
||||||
|
return None # nothing to do, no need to redirect
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
"""Set customer for authenticated user, same as AbstractPaymentView does."""
|
"""Redirect to login or to billing, or prepare plan variation."""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return self.handle_no_permission()
|
||||||
|
|
||||||
|
if should_redirect_to_billing(request.user):
|
||||||
|
return redirect('user-settings-billing')
|
||||||
|
|
||||||
plan_variation_id = kwargs['plan_variation_id']
|
plan_variation_id = kwargs['plan_variation_id']
|
||||||
self.plan_variation = get_object_or_404(
|
self.plan_variation = get_object_or_404(
|
||||||
looper.models.PlanVariation,
|
looper.models.PlanVariation,
|
||||||
pk=plan_variation_id,
|
pk=plan_variation_id,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
currency=self.get_currency(),
|
|
||||||
)
|
)
|
||||||
if not getattr(self, 'gateway', None):
|
|
||||||
self.gateway = looper.models.Gateway.default()
|
self.user = request.user
|
||||||
self.user = self.request.user
|
|
||||||
self.customer = None
|
|
||||||
self.subscription = None
|
|
||||||
if self.user.is_authenticated:
|
|
||||||
self.customer = self.user.customer
|
self.customer = self.user.customer
|
||||||
self.subscription = self._get_existing_subscription()
|
self.subscription = self._get_existing_subscription()
|
||||||
|
|
||||||
|
response_redirect = self._set_preferred_currency_and_redirect()
|
||||||
|
if response_redirect:
|
||||||
|
return response_redirect
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_form_kwargs(self) -> dict:
|
def get_form_kwargs(self, *args, **kwargs):
|
||||||
"""Pass extra parameters to the form."""
|
"""Pass request to the form."""
|
||||||
form_kwargs = super().get_form_kwargs()
|
form_kwargs = super().get_form_kwargs(*args, **kwargs)
|
||||||
if self.user.is_authenticated:
|
form_kwargs.update(
|
||||||
return {
|
{
|
||||||
**form_kwargs,
|
'request': self.request,
|
||||||
|
'plan_variation': self.plan_variation,
|
||||||
'instance': self.customer.billing_address,
|
'instance': self.customer.billing_address,
|
||||||
}
|
}
|
||||||
|
)
|
||||||
return form_kwargs
|
return form_kwargs
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
"""Redirect to the Store if subscriptions are not enabled."""
|
|
||||||
if should_redirect_to_billing(request.user):
|
|
||||||
return redirect('user-settings-billing')
|
|
||||||
|
|
||||||
return super().get(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
"""Redirect anonymous users to login."""
|
|
||||||
if request.user.is_anonymous:
|
|
||||||
return redirect('{}?next={}'.format(settings.LOGIN_URL, request.path))
|
|
||||||
|
|
||||||
if request.user.is_authenticated:
|
|
||||||
if self.subscription and self.subscription.status in self.subscription._ACTIVE_STATUSES:
|
|
||||||
return redirect('user-settings-billing')
|
|
||||||
|
|
||||||
return super().post(request, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class BillingDetailsView(_JoinMixin, LoginRequiredMixin, FormView):
|
|
||||||
"""Display billing details form and save them as billing Address and Customer."""
|
|
||||||
|
|
||||||
template_name = 'subscriptions/join/billing_address.html'
|
|
||||||
form_class = BillingAddressForm
|
|
||||||
|
|
||||||
customer: looper.models.Customer
|
|
||||||
|
|
||||||
def get_initial(self) -> dict:
|
|
||||||
"""Prefill default payment gateway, country and selected plan options."""
|
|
||||||
initial = super().get_initial()
|
|
||||||
# Only preset country when it's not already selected by the customer
|
|
||||||
geoip_country = self.request.session.get(COUNTRY_CODE_SESSION_KEY)
|
|
||||||
if geoip_country and (not self.customer or not self.customer.billing_address.country):
|
|
||||||
initial['country'] = geoip_country
|
|
||||||
|
|
||||||
# Only set initial values if they aren't already saved to the billing address.
|
|
||||||
# Initial values always override form data, which leads to confusing issues with views.
|
|
||||||
if not (self.customer and self.customer.billing_address.full_name):
|
|
||||||
# Fall back to user's full name, if no full name set already in the billing address:
|
|
||||||
if self.request.user.full_name:
|
|
||||||
initial['full_name'] = self.request.user.full_name
|
|
||||||
return initial
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs) -> dict:
|
def get_context_data(self, **kwargs) -> dict:
|
||||||
"""Add an extra form and gateway's client token."""
|
"""Add existing subscription to the view and the context."""
|
||||||
return {
|
return {
|
||||||
**super().get_context_data(**kwargs),
|
**super().get_context_data(**kwargs),
|
||||||
'current_plan_variation': self.plan_variation,
|
'current_plan_variation': self.plan_variation,
|
||||||
'subscription': self.subscription,
|
'subscription': self.subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _get_or_create_subscription(
|
||||||
|
self, gateway: looper.models.Gateway
|
||||||
|
) -> looper.models.Subscription:
|
||||||
|
subscription = self.subscription
|
||||||
|
is_new = False
|
||||||
|
if not subscription:
|
||||||
|
subscription = looper.models.Subscription(customer=self.customer)
|
||||||
|
is_new = True
|
||||||
|
logger_args = [self.customer.pk, gateway]
|
||||||
|
logger.debug('Creating a new subscription for customer pk=%s, %s', *logger_args)
|
||||||
|
collection_method = self.plan_variation.collection_method
|
||||||
|
if collection_method not in gateway.provider.supported_collection_methods:
|
||||||
|
# FIXME(anna): this breaks plan selector because collection method
|
||||||
|
# might not match the one selected by the customer.
|
||||||
|
collection_method = next(iter(gateway.provider.supported_collection_methods))
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
subscription.plan = self.plan_variation.plan
|
||||||
|
subscription.customer = self.customer
|
||||||
|
# Currency must be set before the price, in case it was changed
|
||||||
|
subscription.currency = self.plan_variation.currency
|
||||||
|
subscription.price = self.plan_variation.price
|
||||||
|
subscription.interval_unit = self.plan_variation.interval_unit
|
||||||
|
subscription.interval_length = self.plan_variation.interval_length
|
||||||
|
subscription.collection_method = collection_method
|
||||||
|
subscription.save()
|
||||||
|
|
||||||
|
if gateway.name == 'bank':
|
||||||
|
payment_method = self.customer.payment_method_add(None, gateway)
|
||||||
|
if subscription.payment_method_id != payment_method.pk:
|
||||||
|
logger.info(
|
||||||
|
'Switching subscription pk=%d from payment method pk=%d to pk=%d',
|
||||||
|
*[subscription.pk, subscription.payment_method_id, payment_method.pk],
|
||||||
|
)
|
||||||
|
subscription.switch_payment_method(payment_method)
|
||||||
|
|
||||||
|
# Configure the team if this is a team plan
|
||||||
|
if hasattr(subscription.plan, 'team_properties'):
|
||||||
|
team_properties = subscription.plan.team_properties
|
||||||
|
team, team_is_new = subscriptions.models.Team.objects.get_or_create(
|
||||||
|
subscription=subscription,
|
||||||
|
seats=team_properties.seats,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
'%s a team for subscription pk=%r, seats: %s',
|
||||||
|
team_is_new and 'Created' or 'Updated',
|
||||||
|
subscription.pk,
|
||||||
|
team.seats and team.seats or 'unlimited',
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug('%s subscription pk=%r', is_new and 'Created' or 'Updated', subscription.pk)
|
||||||
|
return subscription
|
||||||
|
|
||||||
|
def form_invalid(self, form, *args, **kwargs):
|
||||||
|
"""Temporarily log all validation errors."""
|
||||||
|
logger.exception('Validation error in JoinView: %s, %s', form.errors, form.data)
|
||||||
|
return super().form_invalid(form, *args, **kwargs)
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
"""Save the billing details and pass the data to the payment form."""
|
"""Save the billing details and redirect to Stripe's checkout."""
|
||||||
product_type = self.plan_variation.plan.product.type
|
product_type = self.plan_variation.plan.product.type
|
||||||
# Get the tax the same way the template does,
|
# Get the tax the same way the template does,
|
||||||
# to detect if it was affected by changes to the billing details
|
# to detect if it was affected by changes to the billing details
|
||||||
@ -144,17 +176,10 @@ class BillingDetailsView(_JoinMixin, LoginRequiredMixin, FormView):
|
|||||||
form.save()
|
form.save()
|
||||||
|
|
||||||
msg = 'Pricing has been updated to reflect changes to your billing details'
|
msg = 'Pricing has been updated to reflect changes to your billing details'
|
||||||
new_country = self.customer.billing_address.country
|
response_redirect = self._set_preferred_currency_and_redirect()
|
||||||
new_currency = preferred_currency_for_country_code(new_country)
|
if response_redirect:
|
||||||
# Compare currency before and after the billing address is updated
|
|
||||||
if self.plan_variation.currency != new_currency:
|
|
||||||
# If currency has changed, find a matching plan variation for this new currency
|
|
||||||
plan_variation = self.plan_variation.in_other_currency(new_currency)
|
|
||||||
self.request.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY] = new_currency
|
|
||||||
messages.add_message(self.request, messages.INFO, msg)
|
messages.add_message(self.request, messages.INFO, msg)
|
||||||
return redirect(
|
return response_redirect
|
||||||
'subscriptions:join-billing-details', plan_variation_id=plan_variation.pk
|
|
||||||
)
|
|
||||||
|
|
||||||
# Compare tax before and after the billing address is updated
|
# Compare tax before and after the billing address is updated
|
||||||
new_tax = self.customer.get_tax(product_type=product_type)
|
new_tax = self.customer.get_tax(product_type=product_type)
|
||||||
@ -164,149 +189,55 @@ class BillingDetailsView(_JoinMixin, LoginRequiredMixin, FormView):
|
|||||||
messages.add_message(self.request, messages.INFO, msg)
|
messages.add_message(self.request, messages.INFO, msg)
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
return redirect(
|
gateway = form.cleaned_data['gateway']
|
||||||
'subscriptions:join-confirm-and-pay', plan_variation_id=self.plan_variation.pk
|
price_cents = new_taxable.price.cents
|
||||||
)
|
subscription = self._get_or_create_subscription(gateway)
|
||||||
|
|
||||||
|
|
||||||
class ConfirmAndPayView(_JoinMixin, LoginRequiredMixin, FormView):
|
|
||||||
"""Display the payment form and handle the payment flow."""
|
|
||||||
|
|
||||||
raise_exception = True
|
|
||||||
template_name = 'subscriptions/join/payment_method.html'
|
|
||||||
form_class = PaymentForm
|
|
||||||
|
|
||||||
log = logger
|
|
||||||
gateway: looper.models.Gateway
|
|
||||||
|
|
||||||
# FIXME(anna): this view uses some functionality of AbstractPaymentView/CheckoutView,
|
|
||||||
# but cannot directly inherit from them.
|
|
||||||
gateway_from_form = AbstractPaymentView.gateway_from_form
|
|
||||||
|
|
||||||
_check_customer_ip_address = AbstractPaymentView._check_customer_ip_address
|
|
||||||
_check_payment_method_nonce = CheckoutView._check_payment_method_nonce
|
|
||||||
_check_recaptcha = CheckoutView._check_recaptcha
|
|
||||||
|
|
||||||
_charge_if_supported = CheckoutView._charge_if_supported
|
|
||||||
_fetch_or_create_order = CheckoutView._fetch_or_create_order
|
|
||||||
|
|
||||||
def get_form_class(self):
|
|
||||||
"""Override the payment form based on the selected plan variation, before validation."""
|
|
||||||
if self.plan_variation.collection_method == 'automatic':
|
|
||||||
return AutomaticPaymentForm
|
|
||||||
return PaymentForm
|
|
||||||
|
|
||||||
def get_initial(self) -> dict:
|
|
||||||
"""Prefill default payment gateway, country and selected plan options."""
|
|
||||||
product_type = self.plan_variation.plan.product.type
|
|
||||||
customer_tax = self.customer.get_tax(product_type=product_type)
|
|
||||||
taxable = looper.taxes.Taxable(self.plan_variation.price, *customer_tax)
|
|
||||||
return {
|
|
||||||
**super().get_initial(),
|
|
||||||
'price': taxable.price.decimals_string,
|
|
||||||
'gateway': self.gateway.name,
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs) -> dict:
|
|
||||||
"""Add an extra form and gateway's client token."""
|
|
||||||
currency = self.get_currency()
|
|
||||||
ctx = {
|
|
||||||
**super().get_context_data(**kwargs),
|
|
||||||
'current_plan_variation': self.plan_variation,
|
|
||||||
'client_token': self.get_client_token(currency) if self.customer else None,
|
|
||||||
'subscription': self.subscription,
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
def _get_or_create_subscription(
|
|
||||||
self, gateway: looper.models.Gateway, payment_method: looper.models.PaymentMethod
|
|
||||||
) -> looper.models.Subscription:
|
|
||||||
subscription = self._get_existing_subscription()
|
|
||||||
is_new = False
|
|
||||||
if not subscription:
|
|
||||||
subscription = looper.models.Subscription()
|
|
||||||
is_new = True
|
|
||||||
self.log.debug('Creating an new subscription for %s, %s', gateway, payment_method)
|
|
||||||
collection_method = self.plan_variation.collection_method
|
|
||||||
supported = set(gateway.provider.supported_collection_methods)
|
|
||||||
if collection_method not in supported:
|
|
||||||
# FIXME(anna): this breaks plan selector because collection method
|
|
||||||
# might not match the one selected by the customer.
|
|
||||||
collection_method = supported.pop()
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
subscription.plan = self.plan_variation.plan
|
|
||||||
subscription.user = self.user
|
|
||||||
subscription.payment_method = payment_method
|
|
||||||
# Currency must be set before the price, in case it was changed
|
|
||||||
subscription.currency = self.plan_variation.currency
|
|
||||||
subscription.price = self.plan_variation.price
|
|
||||||
subscription.interval_unit = self.plan_variation.interval_unit
|
|
||||||
subscription.interval_length = self.plan_variation.interval_length
|
|
||||||
subscription.collection_method = collection_method
|
|
||||||
subscription.save()
|
|
||||||
|
|
||||||
# Configure the team if this is a team plan
|
|
||||||
if hasattr(subscription.plan, 'team_properties'):
|
|
||||||
team_properties = subscription.plan.team_properties
|
|
||||||
team, team_is_new = subscriptions.models.Team.objects.get_or_create(
|
|
||||||
subscription=subscription,
|
|
||||||
seats=team_properties.seats,
|
|
||||||
)
|
|
||||||
self.log.info(
|
|
||||||
'%s a team for subscription pk=%r, seats: %s',
|
|
||||||
team_is_new and 'Created' or 'Updated',
|
|
||||||
subscription.pk,
|
|
||||||
team.seats and team.seats or 'unlimited',
|
|
||||||
)
|
|
||||||
|
|
||||||
self.log.debug('%s subscription pk=%r', is_new and 'Created' or 'Updated', subscription.pk)
|
|
||||||
return subscription
|
|
||||||
|
|
||||||
def form_invalid(self, form):
|
|
||||||
"""Temporarily log all validation errors."""
|
|
||||||
logger.exception('Validation error in ConfirmAndPayView: %s', form.errors)
|
|
||||||
return super().form_invalid(form)
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
|
||||||
"""Handle valid form data.
|
|
||||||
|
|
||||||
Confirm and Pay view doesn't update the billing address,
|
|
||||||
only displays it for use by payment flow and validates it on submit.
|
|
||||||
The billing address is assumed to be saved at the previous step.
|
|
||||||
"""
|
|
||||||
assert self.request.method == 'POST'
|
|
||||||
|
|
||||||
response = self._check_recaptcha(form)
|
|
||||||
if response:
|
|
||||||
return response
|
|
||||||
|
|
||||||
response = self._check_customer_ip_address(form)
|
|
||||||
if response:
|
|
||||||
return response
|
|
||||||
|
|
||||||
gateway = self.gateway_from_form(form)
|
|
||||||
payment_method = self._check_payment_method_nonce(form, gateway)
|
|
||||||
if payment_method is None:
|
|
||||||
return self.form_invalid(form)
|
|
||||||
|
|
||||||
price_cents = int(Decimal(form.cleaned_data['price']) * 100)
|
|
||||||
subscription = self._get_or_create_subscription(gateway, payment_method)
|
|
||||||
# Update the tax info stored on the subscription
|
# Update the tax info stored on the subscription
|
||||||
subscription.update_tax()
|
subscription.update_tax()
|
||||||
|
|
||||||
order = self._fetch_or_create_order(form, subscription)
|
order = self._fetch_or_create_order(form, subscription)
|
||||||
# Update the order to take into account latest changes
|
# Update the order to take into account latest changes
|
||||||
|
if order.payment_method_id != subscription.payment_method_id:
|
||||||
|
order.switch_payment_method(subscription.payment_method)
|
||||||
order.update()
|
order.update()
|
||||||
# Make sure we are charging what we've displayed
|
# Make sure we are charging what we've displayed
|
||||||
price = looper.money.Money(order.price.currency, price_cents)
|
price = looper.money.Money(order.price.currency, price_cents)
|
||||||
if order.price != price:
|
if order.price != price:
|
||||||
form.add_error('', 'Payment failed: please reload the page and try again')
|
logger.error("Order price %s doesn't match form price %s", order.price, price)
|
||||||
|
msg = 'Please reload the page and try again'
|
||||||
|
messages.warning(self.request, msg)
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
if not gateway.provider.supports_transactions:
|
if not gateway.provider.supports_transactions:
|
||||||
|
logger.info(
|
||||||
|
'Not creating transaction for order pk=%r because gateway %r does '
|
||||||
|
'not support it',
|
||||||
|
order.pk,
|
||||||
|
gateway.name,
|
||||||
|
)
|
||||||
|
url = reverse(
|
||||||
|
'looper:transactionless_checkout_done',
|
||||||
|
kwargs={'pk': order.pk, 'gateway_name': gateway.name},
|
||||||
|
)
|
||||||
# Trigger an email with instructions about manual payment:
|
# Trigger an email with instructions about manual payment:
|
||||||
subscription_created_needs_payment.send(sender=subscription)
|
subscription_created_needs_payment.send(sender=subscription)
|
||||||
|
return redirect(url)
|
||||||
|
|
||||||
response = self._charge_if_supported(form, gateway, order)
|
success_url = self.request.build_absolute_uri(
|
||||||
return response
|
reverse(
|
||||||
|
'looper:stripe_success',
|
||||||
|
kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# we have to do it to avoid uri-encoding of curly braces,
|
||||||
|
# otherwise stripe doesn't do the template substitution
|
||||||
|
success_url = success_url.replace('CHECKOUT_SESSION_ID', '{CHECKOUT_SESSION_ID}', 1)
|
||||||
|
cancel_url = self.request.build_absolute_uri(self.request.get_full_path())
|
||||||
|
|
||||||
|
session = looper.stripe_utils.create_stripe_checkout_session_for_order(
|
||||||
|
order,
|
||||||
|
success_url,
|
||||||
|
cancel_url,
|
||||||
|
payment_intent_metadata={'order_id': order.pk},
|
||||||
|
)
|
||||||
|
return redirect(session.url)
|
||||||
|
@ -4,7 +4,6 @@ import logging
|
|||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.forms.utils import ErrorList
|
from django.forms.utils import ErrorList
|
||||||
from django.http import HttpResponse
|
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
from looper.models import Subscription
|
from looper.models import Subscription
|
||||||
@ -35,7 +34,9 @@ class SingleSubscriptionMixin(LoginRequiredMixin):
|
|||||||
|
|
||||||
def get_subscription(self) -> Subscription:
|
def get_subscription(self) -> Subscription:
|
||||||
"""Retrieve Subscription object."""
|
"""Retrieve Subscription object."""
|
||||||
return get_object_or_404(self.request.user.subscription_set, pk=self.subscription_id)
|
return get_object_or_404(
|
||||||
|
self.request.user.customer.subscription_set, pk=self.subscription_id
|
||||||
|
)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs) -> dict:
|
def get_context_data(self, **kwargs) -> dict:
|
||||||
"""Add Subscription to the template context."""
|
"""Add Subscription to the template context."""
|
||||||
@ -45,29 +46,6 @@ class SingleSubscriptionMixin(LoginRequiredMixin):
|
|||||||
'subscription': subscription,
|
'subscription': subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
|
||||||
"""Allow the view to do things that rely on auth state before dispatch.
|
|
||||||
|
|
||||||
The AnonymousUser instance doesn't have a 'subscriptions' property,
|
|
||||||
but login checking only happens in the super().dispatch() call.
|
|
||||||
"""
|
|
||||||
if not hasattr(request.user, 'subscription_set'):
|
|
||||||
return self.handle_no_permission()
|
|
||||||
response = self.pre_dispatch(request, *args, **kwargs)
|
|
||||||
if response:
|
|
||||||
return response
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def pre_dispatch(self, request, *args, **kwargs) -> Optional[HttpResponse]:
|
|
||||||
"""Called between a permission check and calling super().dispatch().
|
|
||||||
|
|
||||||
This allows you to get the current subscription, or otherwise do things
|
|
||||||
that require the user to be logged in.
|
|
||||||
|
|
||||||
:return: None to continue handling this request, or a
|
|
||||||
HttpResponse to stop processing early.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class BootstrapErrorListMixin:
|
class BootstrapErrorListMixin:
|
||||||
"""Override get_form method changing error_class of the form."""
|
"""Override get_form method changing error_class of the form."""
|
||||||
|
@ -5,7 +5,7 @@ import logging
|
|||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.views.generic import FormView
|
from django.views.generic import FormView
|
||||||
|
|
||||||
from looper.views.checkout import AbstractPaymentView
|
from looper.views.checkout_stripe import CheckoutStripeView
|
||||||
import looper.gateways
|
import looper.gateways
|
||||||
import looper.middleware
|
import looper.middleware
|
||||||
import looper.models
|
import looper.models
|
||||||
@ -20,7 +20,7 @@ logger.setLevel(logging.DEBUG)
|
|||||||
|
|
||||||
|
|
||||||
class _PlanSelectorMixin:
|
class _PlanSelectorMixin:
|
||||||
get_currency = AbstractPaymentView.get_currency
|
get_currency = CheckoutStripeView.get_currency
|
||||||
select_team_plans = False
|
select_team_plans = False
|
||||||
plan_variation = None
|
plan_variation = None
|
||||||
plan = None
|
plan = None
|
||||||
|
@ -1,11 +1,7 @@
|
|||||||
"""Views handling subscription management."""
|
"""Views handling subscription management."""
|
||||||
from typing import Optional
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.http import HttpResponseForbidden
|
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
from django.urls import reverse_lazy, reverse
|
from django.urls import reverse_lazy, reverse
|
||||||
from django.views.generic import UpdateView, FormView
|
from django.views.generic import UpdateView, FormView
|
||||||
|
|
||||||
@ -15,8 +11,6 @@ import looper.views.settings
|
|||||||
from subscriptions.forms import (
|
from subscriptions.forms import (
|
||||||
BillingAddressForm,
|
BillingAddressForm,
|
||||||
CancelSubscriptionForm,
|
CancelSubscriptionForm,
|
||||||
ChangePaymentMethodForm,
|
|
||||||
PayExistingOrderForm,
|
|
||||||
TeamForm,
|
TeamForm,
|
||||||
)
|
)
|
||||||
from subscriptions.views.mixins import SingleSubscriptionMixin, BootstrapErrorListMixin
|
from subscriptions.views.mixins import SingleSubscriptionMixin, BootstrapErrorListMixin
|
||||||
@ -26,24 +20,13 @@ logger = logging.getLogger(__name__)
|
|||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
class BillingAddressView(LoginRequiredMixin, UpdateView):
|
class BillingAddressView(looper.views.settings.BillingAddressView):
|
||||||
"""Combine looper's Customer and Address into a billing address."""
|
"""Override form class and success URL of looper's view."""
|
||||||
|
|
||||||
template_name = 'settings/billing_address.html'
|
template_name = 'settings/billing_address.html'
|
||||||
model = looper.models.Address
|
|
||||||
form_class = BillingAddressForm
|
form_class = BillingAddressForm
|
||||||
success_url = reverse_lazy('subscriptions:billing-address')
|
success_url = reverse_lazy('subscriptions:billing-address')
|
||||||
|
|
||||||
def _get_customer_object(self) -> Optional[looper.models.Customer]:
|
|
||||||
if self.request.user.is_anonymous:
|
|
||||||
return None
|
|
||||||
return self.request.user.customer
|
|
||||||
|
|
||||||
def get_object(self, queryset=None) -> Optional[looper.models.Address]:
|
|
||||||
"""Get billing address."""
|
|
||||||
customer = self._get_customer_object()
|
|
||||||
return customer.billing_address if customer else None
|
|
||||||
|
|
||||||
|
|
||||||
class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
|
class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
|
||||||
"""Confirm and cancel a subscription."""
|
"""Confirm and cancel a subscription."""
|
||||||
@ -68,78 +51,40 @@ class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
|
|||||||
|
|
||||||
|
|
||||||
class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView):
|
class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView):
|
||||||
"""Use the Braintree drop-in UI to switch a subscription's payment method."""
|
"""Override cancel and success URLs."""
|
||||||
|
|
||||||
template_name = 'subscriptions/payment_method_change.html'
|
success_url = 'subscriptions:payment-method-change-done'
|
||||||
form_class = ChangePaymentMethodForm
|
|
||||||
success_url = reverse_lazy('user-settings-billing')
|
|
||||||
|
|
||||||
subscription: looper.models.Subscription
|
def get_cancel_url(self):
|
||||||
|
"""Return to this subscription's manage page."""
|
||||||
def get_initial(self) -> dict:
|
return reverse(
|
||||||
"""Modify initial form data."""
|
'subscriptions:manage',
|
||||||
initial = super().get_initial()
|
kwargs={'subscription_id': self.kwargs['subscription_id']},
|
||||||
initial['next_url_after_done'] = self.success_url
|
)
|
||||||
|
|
||||||
# Looper's view uses customer full_name, we don't
|
|
||||||
initial.pop('full_name', None)
|
|
||||||
|
|
||||||
# Only set initial values if they aren't already saved to the billing address.
|
|
||||||
# Initial values always override form data, which leads to confusing issues with views.
|
|
||||||
if not (self.customer and self.customer.billing_address.full_name):
|
|
||||||
# Fall back to user's full name, if no full name set already in the billing address:
|
|
||||||
if self.request.user.full_name:
|
|
||||||
initial['full_name'] = self.request.user.full_name
|
|
||||||
return initial
|
|
||||||
|
|
||||||
def form_invalid(self, form):
|
|
||||||
"""Temporarily log all validation errors."""
|
|
||||||
logger.exception('Validation error in ChangePaymentMethodForm: %s', form.errors)
|
|
||||||
return super().form_invalid(form)
|
|
||||||
|
|
||||||
|
|
||||||
class PayExistingOrderView(looper.views.checkout.CheckoutExistingOrderView):
|
class PaymentMethodChangeDoneView(looper.views.settings.PaymentMethodChangeDoneView):
|
||||||
|
"""Change payment method in response to a successful payment setup."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_url(self):
|
||||||
|
"""Return to this subscription's manage page."""
|
||||||
|
return reverse(
|
||||||
|
'subscriptions:manage',
|
||||||
|
kwargs={'subscription_id': self.kwargs['subscription_id']},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PayExistingOrderView(looper.views.checkout_stripe.CheckoutExistingOrderView):
|
||||||
"""Override looper's view with our forms."""
|
"""Override looper's view with our forms."""
|
||||||
|
|
||||||
# Redirect to LOGIN_URL instead of raising an exception
|
# Redirect to LOGIN_URL instead of raising an exception
|
||||||
raise_exception = False
|
raise_exception = False
|
||||||
template_name = 'subscriptions/pay_existing_order.html'
|
|
||||||
form_class = PayExistingOrderForm
|
|
||||||
success_url = reverse_lazy('user-settings-billing')
|
|
||||||
|
|
||||||
def get_initial(self) -> dict:
|
def get_cancel_url(self):
|
||||||
"""Prefill the payment amount and missing form data, if any."""
|
"""Return to this subscription's manage page."""
|
||||||
initial = {
|
order = self.get_object()
|
||||||
'price': self.order.price.decimals_string,
|
return reverse('subscriptions:manage', kwargs={'subscription_id': order.subscription_id})
|
||||||
'email': self.customer.billing_email,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Only set initial values if they aren't already saved to the billing address.
|
|
||||||
# Initial values always override form data, which leads to confusing issues with views.
|
|
||||||
if not (self.customer and self.customer.billing_address.full_name):
|
|
||||||
# Fall back to user's full name, if no full name set already in the billing address:
|
|
||||||
if self.request.user.full_name:
|
|
||||||
initial['full_name'] = self.request.user.full_name
|
|
||||||
return initial
|
|
||||||
|
|
||||||
def form_invalid(self, form):
|
|
||||||
"""Temporarily log all validation errors."""
|
|
||||||
logger.exception('Validation error in PayExistingOrderView: %s', form.errors)
|
|
||||||
return super().form_invalid(form)
|
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
|
||||||
"""Return 403 unless current session and the order belong to the same user.
|
|
||||||
|
|
||||||
Looper renders a template instead, we just want to display the standard 403 page
|
|
||||||
or redirect to login, like LoginRequiredMixin does with raise_exception=False.
|
|
||||||
"""
|
|
||||||
self.order = get_object_or_404(looper.models.Order, pk=kwargs['order_id'])
|
|
||||||
if request.user.is_authenticated and self.order.user_id != request.user.id:
|
|
||||||
return HttpResponseForbidden()
|
|
||||||
self.plan = self.order.subscription.plan
|
|
||||||
return super(looper.views.checkout.CheckoutExistingOrderView, self).dispatch(
|
|
||||||
request, *args, **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ManageSubscriptionView(
|
class ManageSubscriptionView(
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from django.views.generic.base import TemplateView
|
from django.views.generic.base import TemplateView
|
||||||
|
|
||||||
from looper.views.checkout import AbstractPaymentView
|
from looper.views.checkout_stripe import CheckoutStripeView
|
||||||
import looper.models
|
import looper.models
|
||||||
|
|
||||||
import subscriptions.models
|
import subscriptions.models
|
||||||
@ -11,7 +11,7 @@ import subscriptions.models
|
|||||||
class TeamsLanding(TemplateView):
|
class TeamsLanding(TemplateView):
|
||||||
"""Display a selection of team plans and existing sponsors."""
|
"""Display a selection of team plans and existing sponsors."""
|
||||||
|
|
||||||
get_currency = AbstractPaymentView.get_currency
|
get_currency = CheckoutStripeView.get_currency
|
||||||
template_name = 'subscriptions/teams_landing.html'
|
template_name = 'subscriptions/teams_landing.html'
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -1,24 +1,27 @@
|
|||||||
from typing import Tuple
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
import responses
|
import responses
|
||||||
|
|
||||||
|
# from responses import _recorder
|
||||||
|
|
||||||
from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4, SINGAPORE_IPV4
|
from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4, SINGAPORE_IPV4
|
||||||
from looper.money import Money
|
from looper.money import Money
|
||||||
import looper.models
|
import looper.models
|
||||||
|
|
||||||
from common.tests.factories.subscriptions import create_customer_with_billing_address
|
from looper.tests.factories import create_customer_with_billing_address
|
||||||
from common.tests.factories.users import UserFactory
|
from common.tests.factories.users import UserFactory, OAuthUserInfoFactory
|
||||||
from subscriptions.tests.base import BaseSubscriptionTestCase
|
from subscriptions.tests.base import BaseSubscriptionTestCase, responses_from_file
|
||||||
import subscriptions.tasks
|
import subscriptions.tasks
|
||||||
import users.tasks
|
import users.tasks
|
||||||
import users.tests.util as util
|
import users.tests.util as util
|
||||||
|
|
||||||
|
responses_dir = 'subscriptions/tests/_responses/'
|
||||||
required_address_data = {
|
required_address_data = {
|
||||||
'country': 'NL',
|
'country': 'NL',
|
||||||
'email': 'my.billing.email@example.com',
|
'email': 'my.billing.email@example.com',
|
||||||
@ -36,15 +39,27 @@ full_billing_address_data = {
|
|||||||
# **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database.
|
# **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database.
|
||||||
|
|
||||||
|
|
||||||
def _get_default_variation(currency='USD'):
|
|
||||||
return looper.models.Plan.objects.first().variation_for_currency(currency)
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time('2023-05-19 11:41:11')
|
@freeze_time('2023-05-19 11:41:11')
|
||||||
class TestGETBillingDetailsView(BaseSubscriptionTestCase):
|
class TestGETJoinView(BaseSubscriptionTestCase):
|
||||||
url_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1})
|
url_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1})
|
||||||
url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2})
|
url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2})
|
||||||
|
|
||||||
|
def test_pay_via_bank_transfer_button_is_shown_for_manual_plan_variation(self):
|
||||||
|
customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
|
||||||
|
user = customer.user
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
url, selected_variation = self._get_url_for(
|
||||||
|
currency='EUR',
|
||||||
|
interval_length=1,
|
||||||
|
interval_unit='month',
|
||||||
|
plan__name='Manual renewal',
|
||||||
|
)
|
||||||
|
response = self.client.get(url, REMOTE_ADDR=EURO_IPV4)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self._assert_pay_via_bank_displayed(response)
|
||||||
|
|
||||||
def test_get_prefills_full_name_and_billing_email_from_user(self):
|
def test_get_prefills_full_name_and_billing_email_from_user(self):
|
||||||
user = UserFactory(full_name="Jane До", email='jane.doe@example.com')
|
user = UserFactory(full_name="Jane До", email='jane.doe@example.com')
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
@ -53,6 +68,7 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self._assert_total_default_variation_selected_tax_21_eur(response)
|
self._assert_total_default_variation_selected_tax_21_eur(response)
|
||||||
|
self._assert_pay_via_bank_not_displayed(response)
|
||||||
self.assertContains(
|
self.assertContains(
|
||||||
response,
|
response,
|
||||||
'<input type="text" name="full_name" value="Jane До" maxlength="255" placeholder="Your Full Name" class="form-control" required id="id_full_name">',
|
'<input type="text" name="full_name" value="Jane До" maxlength="255" placeholder="Your Full Name" class="form-control" required id="id_full_name">',
|
||||||
@ -65,7 +81,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_get_displays_total_and_billing_details_to_logged_in_nl(self):
|
def test_get_displays_total_and_billing_details_to_logged_in_nl(self):
|
||||||
user = create_customer_with_billing_address(vat_number='', country='NL')
|
customer = create_customer_with_billing_address(vat_number='', country='NL')
|
||||||
|
user = customer.user
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|
||||||
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
|
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
|
||||||
@ -76,7 +93,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
|
|||||||
self._assert_total_default_variation_selected_tax_21_eur(response)
|
self._assert_total_default_variation_selected_tax_21_eur(response)
|
||||||
|
|
||||||
def test_get_displays_total_and_billing_details_to_logged_in_de(self):
|
def test_get_displays_total_and_billing_details_to_logged_in_de(self):
|
||||||
user = create_customer_with_billing_address(vat_number='', country='DE')
|
customer = create_customer_with_billing_address(vat_number='', country='DE')
|
||||||
|
user = customer.user
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|
||||||
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
|
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
|
||||||
@ -86,9 +104,10 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
|
|||||||
self._assert_total_default_variation_selected_tax_19_eur(response)
|
self._assert_total_default_variation_selected_tax_19_eur(response)
|
||||||
|
|
||||||
def test_get_displays_total_and_billing_details_to_logged_in_us(self):
|
def test_get_displays_total_and_billing_details_to_logged_in_us(self):
|
||||||
user = create_customer_with_billing_address(
|
customer = create_customer_with_billing_address(
|
||||||
vat_number='', country='US', region='NY', postal_code='12001'
|
vat_number='', country='US', region='NY', postal_code='12001'
|
||||||
)
|
)
|
||||||
|
user = customer.user
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|
||||||
response = self.client.get(self.url_usd)
|
response = self.client.get(self.url_usd)
|
||||||
@ -99,16 +118,23 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
|
|||||||
self._assert_total_default_variation_selected_usd(response)
|
self._assert_total_default_variation_selected_usd(response)
|
||||||
|
|
||||||
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
|
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
|
||||||
def test_get_detects_country_us_sets_preferred_currency_usd_invalid_variation(self):
|
def test_get_detects_country_us_sets_preferred_currency_usd_and_redirects(self):
|
||||||
user = create_customer_with_billing_address()
|
customer = create_customer_with_billing_address()
|
||||||
|
user = customer.user
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
self.assertTrue(looper.middleware.PREFERRED_CURRENCY_SESSION_KEY not in self.client.session)
|
||||||
|
|
||||||
response = self.client.get(self.url, REMOTE_ADDR=USA_IPV4)
|
response = self.client.get(self.url, REMOTE_ADDR=USA_IPV4)
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response['Location'], self.url_usd)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.session[looper.middleware.PREFERRED_CURRENCY_SESSION_KEY], 'USD'
|
||||||
|
)
|
||||||
|
|
||||||
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
|
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
|
||||||
def test_get_detects_country_us_sets_preferred_currency_usd(self):
|
def test_get_detects_country_us_sets_preferred_currency_usd(self):
|
||||||
user = create_customer_with_billing_address()
|
customer = create_customer_with_billing_address()
|
||||||
|
user = customer.user
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|
||||||
response = self.client.get(self.url_usd, REMOTE_ADDR=USA_IPV4)
|
response = self.client.get(self.url_usd, REMOTE_ADDR=USA_IPV4)
|
||||||
@ -125,7 +151,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
|
|||||||
|
|
||||||
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
|
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
|
||||||
def test_get_detects_country_sg_sets_preferred_currency_eur(self):
|
def test_get_detects_country_sg_sets_preferred_currency_eur(self):
|
||||||
user = create_customer_with_billing_address()
|
customer = create_customer_with_billing_address()
|
||||||
|
user = customer.user
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|
||||||
response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4)
|
response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4)
|
||||||
@ -142,7 +169,8 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
|
|||||||
|
|
||||||
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
|
@unittest.skipUnless(os.path.exists(settings.GEOIP2_DB), 'GeoIP database file is required')
|
||||||
def test_get_detects_country_nl_sets_preferred_currency_eur_displays_correct_vat(self):
|
def test_get_detects_country_nl_sets_preferred_currency_eur_displays_correct_vat(self):
|
||||||
user = create_customer_with_billing_address()
|
customer = create_customer_with_billing_address()
|
||||||
|
user = customer.user
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|
||||||
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
|
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
|
||||||
@ -156,14 +184,42 @@ class TestGETBillingDetailsView(BaseSubscriptionTestCase):
|
|||||||
)
|
)
|
||||||
self._assert_total_default_variation_selected_tax_21_eur(response)
|
self._assert_total_default_variation_selected_tax_21_eur(response)
|
||||||
|
|
||||||
|
def test_plan_variation_matches_detected_currency_eur_non_eea_ip(self):
|
||||||
|
customer = create_customer_with_billing_address()
|
||||||
|
self.client.force_login(customer.user)
|
||||||
|
|
||||||
|
response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
# Check that prices are in EUR and there is no tax
|
||||||
|
self._assert_total_default_variation_selected_no_tax_eur(response)
|
||||||
|
|
||||||
|
def test_billing_address_country_takes_precedence_over_geo_ip(self):
|
||||||
|
customer = create_customer_with_billing_address(country='NL')
|
||||||
|
self.client.force_login(customer.user)
|
||||||
|
|
||||||
|
response = self.client.get(self.url, REMOTE_ADDR=SINGAPORE_IPV4)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self._assert_total_default_variation_selected_tax_21_eur(response)
|
||||||
|
|
||||||
|
|
||||||
@freeze_time('2023-05-19 11:41:11')
|
@freeze_time('2023-05-19 11:41:11')
|
||||||
class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
|
class TestPOSTJoinView(BaseSubscriptionTestCase):
|
||||||
url_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1})
|
url_usd = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 1})
|
||||||
url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2})
|
url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2})
|
||||||
|
|
||||||
|
cs_url = 'https://checkout.stripe.com/c/pay/cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl'
|
||||||
|
cs_id = 'cs_test_a19GMh7hJXYbh9OhEln16y1M8hfrdu0ySIpjRX4HQJqSVvpe9a9UP30bWW'
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
responses._add_from_file(f'{responses_dir}vies_wsdl.yaml')
|
||||||
|
responses._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
|
||||||
|
|
||||||
def test_post_updates_billing_address_and_customer_renders_next_form_de(self):
|
def test_post_updates_billing_address_and_customer_renders_next_form_de(self):
|
||||||
user = create_customer_with_billing_address(vat_number='', country='DE')
|
customer = create_customer_with_billing_address(vat_number='', country='DE')
|
||||||
|
user = customer.user
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|
||||||
selected_variation = (
|
selected_variation = (
|
||||||
@ -174,7 +230,7 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
|
|||||||
)
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
data = full_billing_address_data
|
data = {**full_billing_address_data, 'gateway': 'stripe'}
|
||||||
url = reverse(
|
url = reverse(
|
||||||
'subscriptions:join-billing-details',
|
'subscriptions:join-billing-details',
|
||||||
kwargs={'plan_variation_id': selected_variation.pk},
|
kwargs={'plan_variation_id': selected_variation.pk},
|
||||||
@ -197,51 +253,36 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
|
|||||||
self.assertContains(response, 'Manual ')
|
self.assertContains(response, 'Manual ')
|
||||||
self.assertContains(response, '/ <span class="x-price-period">1 year</span>', html=True)
|
self.assertContains(response, '/ <span class="x-price-period">1 year</span>', html=True)
|
||||||
|
|
||||||
def test_post_has_correct_price_field_value(self):
|
@responses.activate
|
||||||
|
def test_post_redirects_to_stripe_hosted_checkout(self):
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
default_variation = _get_default_variation('EUR')
|
data = {**required_address_data, 'gateway': 'stripe'}
|
||||||
data = required_address_data
|
|
||||||
response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4)
|
response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(
|
self.assertEqual(response['Location'], self.cs_url)
|
||||||
response['Location'],
|
|
||||||
reverse(
|
|
||||||
'subscriptions:join-confirm-and-pay',
|
|
||||||
kwargs={'plan_variation_id': default_variation.pk},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Follow the redirect to avoid "Couldn't retrieve content: Response code was 302 (expected 200)"
|
|
||||||
response = self.client.get(response['Location'])
|
|
||||||
# Check that we are no longer on the billing details page
|
|
||||||
self._assert_payment_form_displayed(response)
|
|
||||||
|
|
||||||
self._assert_total_default_variation_selected_tax_21_eur(response)
|
|
||||||
# The hidden price field must also be set to a matching amount
|
|
||||||
self.assertContains(
|
|
||||||
response,
|
|
||||||
'<input type="hidden" name="price" value="9.90" class="form-control" id="id_price">',
|
|
||||||
html=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
def test_post_updates_billing_address_and_customer_applies_reverse_charged_tax(self):
|
def test_post_updates_billing_address_and_customer_applies_reverse_charged_tax(self):
|
||||||
|
responses._add_from_file(f'{responses_dir}vies_valid.yaml')
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
**required_address_data,
|
**required_address_data,
|
||||||
'vat_number': 'DE 260543043',
|
'gateway': 'stripe',
|
||||||
'country': 'DE',
|
'country': 'DE',
|
||||||
'postal_code': '11111',
|
'postal_code': '11111',
|
||||||
|
'vat_number': 'DE 260543043',
|
||||||
}
|
}
|
||||||
response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4)
|
response = self.client.post(self.url, data, REMOTE_ADDR=EURO_IPV4)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
self.assertEqual(self.user.customer.vat_number, 'DE260543043')
|
|
||||||
address = self.user.customer.billing_address
|
address = self.user.customer.billing_address
|
||||||
|
address.refresh_from_db()
|
||||||
|
self.assertEqual(address.vat_number, 'DE260543043')
|
||||||
self.assertEqual(address.full_name, 'New Full Name')
|
self.assertEqual(address.full_name, 'New Full Name')
|
||||||
self.assertEqual(address.postal_code, '11111')
|
self.assertEqual(address.postal_code, '11111')
|
||||||
self.assertEqual(address.country, 'DE')
|
self.assertEqual(address.country, 'DE')
|
||||||
@ -255,23 +296,14 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
|
|||||||
# Post the same form again
|
# Post the same form again
|
||||||
response = self.client.post(self.url, data)
|
response = self.client.post(self.url, data)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
# Follow the redirect to avoid unexpected assertion errors
|
|
||||||
response = self.client.get(response['Location'])
|
|
||||||
|
|
||||||
# Check that we are no longer on the billing details page
|
self.assertEqual(response['Location'], self.cs_url, response['Location'])
|
||||||
self._assert_payment_form_displayed(response)
|
|
||||||
|
|
||||||
# The hidden price field must also be set to a matching amount
|
|
||||||
self.assertContains(
|
|
||||||
response,
|
|
||||||
'<input type="hidden" name="price" value="8.32" class="form-control" id="id_price">',
|
|
||||||
html=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_post_changing_address_from_with_region_to_without_region_clears_region(self):
|
def test_post_changing_address_from_with_region_to_without_region_clears_region(self):
|
||||||
user = create_customer_with_billing_address(
|
customer = create_customer_with_billing_address(
|
||||||
vat_number='', country='US', region='NY', postal_code='12001'
|
vat_number='', country='US', region='NY', postal_code='12001'
|
||||||
)
|
)
|
||||||
|
user = customer.user
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|
||||||
response = self.client.get(self.url_usd)
|
response = self.client.get(self.url_usd)
|
||||||
@ -285,6 +317,7 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
|
|||||||
# Post an new address that doesn't require a region
|
# Post an new address that doesn't require a region
|
||||||
data = {
|
data = {
|
||||||
**required_address_data,
|
**required_address_data,
|
||||||
|
'gateway': 'stripe',
|
||||||
'country': 'DE',
|
'country': 'DE',
|
||||||
'postal_code': '11111',
|
'postal_code': '11111',
|
||||||
}
|
}
|
||||||
@ -304,94 +337,51 @@ class TestPOSTBillingDetailsView(BaseSubscriptionTestCase):
|
|||||||
self.assertEqual(user.customer.billing_address.country, 'DE')
|
self.assertEqual(user.customer.billing_address.country, 'DE')
|
||||||
self.assertEqual(user.customer.billing_address.postal_code, '11111')
|
self.assertEqual(user.customer.billing_address.postal_code, '11111')
|
||||||
|
|
||||||
|
|
||||||
@freeze_time('2023-05-19 11:41:11')
|
|
||||||
class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
|
||||||
def _get_url_for(self, **filter_params) -> Tuple[str, looper.models.PlanVariation]:
|
|
||||||
plan_variation = looper.models.PlanVariation.objects.active().get(**filter_params)
|
|
||||||
return (
|
|
||||||
reverse(
|
|
||||||
'subscriptions:join-confirm-and-pay',
|
|
||||||
kwargs={'plan_variation_id': plan_variation.pk},
|
|
||||||
),
|
|
||||||
plan_variation,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_plan_variation_does_not_match_detected_currency_usd_euro_ip(self):
|
def test_plan_variation_does_not_match_detected_currency_usd_euro_ip(self):
|
||||||
url, _ = self._get_url_for(currency='USD', price=11900)
|
url, _ = self._get_url_for(currency='USD', price=11900)
|
||||||
user = create_customer_with_billing_address(country='NL')
|
customer = create_customer_with_billing_address(country='NL')
|
||||||
|
user = customer.user
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|
||||||
data = required_address_data
|
data = {**required_address_data, 'gateway': 'stripe'}
|
||||||
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
|
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
expected_url, _ = self._get_url_for(currency='EUR', price=10900)
|
||||||
def test_plan_variation_matches_detected_currency_eur_non_eea_ip(self):
|
self.assertEqual(response['Location'], expected_url)
|
||||||
url, _ = self._get_url_for(currency='EUR', price=990)
|
|
||||||
user = create_customer_with_billing_address()
|
|
||||||
self.client.force_login(user)
|
|
||||||
|
|
||||||
data = required_address_data
|
|
||||||
response = self.client.post(url, data, REMOTE_ADDR=SINGAPORE_IPV4)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
# Check that prices are in EUR and there is no tax
|
|
||||||
self._assert_total_default_variation_selected_no_tax_eur(response)
|
|
||||||
|
|
||||||
def test_billing_address_country_takes_precedence_over_geo_ip(self):
|
def test_billing_address_country_takes_precedence_over_geo_ip(self):
|
||||||
url, _ = self._get_url_for(currency='EUR', price=990)
|
customer = create_customer_with_billing_address(country='GE')
|
||||||
user = create_customer_with_billing_address(country='NL')
|
self.client.force_login(customer.user)
|
||||||
self.client.force_login(user)
|
|
||||||
|
|
||||||
data = required_address_data
|
data = {**required_address_data, 'gateway': 'stripe'}
|
||||||
response = self.client.post(url, data, REMOTE_ADDR=SINGAPORE_IPV4)
|
response = self.client.post(self.url, data, REMOTE_ADDR=SINGAPORE_IPV4)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self._assert_total_default_variation_selected_tax_21_eur(response)
|
self._assert_total_default_variation_selected_tax_21_eur(response)
|
||||||
|
|
||||||
def test_invalid_missing_required_fields(self):
|
def test_invalid_missing_required_fields(self):
|
||||||
url, _ = self._get_url_for(currency='EUR', price=990)
|
customer = create_customer_with_billing_address(country='NL')
|
||||||
user = create_customer_with_billing_address(country='NL')
|
self.client.force_login(customer.user)
|
||||||
self.client.force_login(user)
|
|
||||||
|
|
||||||
data = required_address_data
|
response = self.client.post(self.url, {}, REMOTE_ADDR=EURO_IPV4)
|
||||||
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self._assert_total_default_variation_selected_tax_21_eur(response)
|
self._assert_total_default_variation_selected_tax_21_eur(response)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
response.context['form'].errors,
|
response.context['form'].errors,
|
||||||
{
|
{
|
||||||
|
'country': ['This field is required.'],
|
||||||
|
'email': ['This field is required.'],
|
||||||
|
'full_name': ['This field is required.'],
|
||||||
'gateway': ['This field is required.'],
|
'gateway': ['This field is required.'],
|
||||||
'payment_method_nonce': ['This field is required.'],
|
|
||||||
'price': ['This field is required.'],
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_invalid_price_does_not_match_selected_plan_variation(self):
|
|
||||||
url, selected_variation = self._get_url_for(currency='EUR', price=990)
|
|
||||||
user = create_customer_with_billing_address(country='NL')
|
|
||||||
self.client.force_login(user)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
**required_address_data,
|
|
||||||
'gateway': 'braintree',
|
|
||||||
'payment_method_nonce': 'fake-valid-nonce',
|
|
||||||
'price': '999.09',
|
|
||||||
}
|
|
||||||
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self._assert_total_default_variation_selected_tax_21_eur(response)
|
|
||||||
self.assertEqual(
|
|
||||||
response.context['form'].errors,
|
|
||||||
{'__all__': ['Payment failed: please reload the page and try again']},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_invalid_bank_transfer_cannot_be_selected_for_automatic_payments(self):
|
def test_invalid_bank_transfer_cannot_be_selected_for_automatic_payments(self):
|
||||||
url, selected_variation = self._get_url_for(currency='EUR', price=990)
|
url, selected_variation = self._get_url_for(currency='EUR', price=990)
|
||||||
user = create_customer_with_billing_address(country='NL')
|
customer = create_customer_with_billing_address(country='NL')
|
||||||
|
user = customer.user
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -423,7 +413,9 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
|||||||
)
|
)
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_pay_with_bank_transfer_creates_order_subscription_on_hold(self):
|
def test_pay_with_bank_transfer_creates_order_subscription_on_hold(self):
|
||||||
user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
|
customer = create_customer_with_billing_address(country='NL')
|
||||||
|
user = customer.user
|
||||||
|
OAuthUserInfoFactory(user=user, oauth_user_id=554433)
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
util.mock_blender_id_badger_badger_response(
|
util.mock_blender_id_badger_badger_response(
|
||||||
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
|
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
|
||||||
@ -435,17 +427,12 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
|||||||
interval_unit='month',
|
interval_unit='month',
|
||||||
plan__name='Manual renewal',
|
plan__name='Manual renewal',
|
||||||
)
|
)
|
||||||
data = {
|
data = {**required_address_data, 'full_name': 'Jane Doe', 'gateway': 'bank'}
|
||||||
**required_address_data,
|
|
||||||
'gateway': 'bank',
|
|
||||||
'payment_method_nonce': 'unused',
|
|
||||||
'price': '14.90',
|
|
||||||
}
|
|
||||||
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
|
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
|
||||||
|
|
||||||
self._assert_transactionless_done_page_displayed(response)
|
self._assert_transactionless_done_page_displayed(response)
|
||||||
|
|
||||||
subscription = user.subscription_set.first()
|
subscription = user.customer.subscription_set.first()
|
||||||
self.assertEqual(subscription.status, 'on-hold')
|
self.assertEqual(subscription.status, 'on-hold')
|
||||||
self.assertEqual(subscription.price, Money('EUR', 1490))
|
self.assertEqual(subscription.price, Money('EUR', 1490))
|
||||||
self.assertEqual(subscription.tax, Money('EUR', 259))
|
self.assertEqual(subscription.tax, Money('EUR', 259))
|
||||||
@ -500,9 +487,17 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
|||||||
def test_pay_with_bank_transfer_creates_order_subscription_on_hold_shows_reverse_charged_price(
|
def test_pay_with_bank_transfer_creates_order_subscription_on_hold_shows_reverse_charged_price(
|
||||||
self,
|
self,
|
||||||
):
|
):
|
||||||
user = create_customer_with_billing_address(
|
responses._add_from_file(f'{responses_dir}vies_valid.yaml')
|
||||||
country='ES', full_name='Jane Doe', vat_number='DE260543043'
|
address = {
|
||||||
)
|
**required_address_data,
|
||||||
|
'country': 'ES',
|
||||||
|
'full_name': 'Jane Doe',
|
||||||
|
'postal_code': '11111',
|
||||||
|
'vat_number': 'ES A78374725',
|
||||||
|
}
|
||||||
|
customer = create_customer_with_billing_address(**address)
|
||||||
|
user = customer.user
|
||||||
|
OAuthUserInfoFactory(user=user, oauth_user_id=554433)
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
util.mock_blender_id_badger_badger_response(
|
util.mock_blender_id_badger_badger_response(
|
||||||
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
|
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
|
||||||
@ -514,17 +509,12 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
|||||||
interval_length=3,
|
interval_length=3,
|
||||||
interval_unit='month',
|
interval_unit='month',
|
||||||
)
|
)
|
||||||
data = {
|
data = {'gateway': 'bank', **address}
|
||||||
**required_address_data,
|
|
||||||
'gateway': 'bank',
|
|
||||||
'payment_method_nonce': 'unused',
|
|
||||||
'price': '26.45',
|
|
||||||
}
|
|
||||||
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
|
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
|
||||||
|
|
||||||
self._assert_transactionless_done_page_displayed(response)
|
self._assert_transactionless_done_page_displayed(response)
|
||||||
|
|
||||||
subscription = user.subscription_set.first()
|
subscription = user.customer.subscription_set.first()
|
||||||
self.assertEqual(subscription.status, 'on-hold')
|
self.assertEqual(subscription.status, 'on-hold')
|
||||||
self.assertEqual(subscription.price, Money('EUR', 3200))
|
self.assertEqual(subscription.price, Money('EUR', 3200))
|
||||||
self.assertEqual(subscription.tax, Money('EUR', 0))
|
self.assertEqual(subscription.tax, Money('EUR', 0))
|
||||||
@ -533,6 +523,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
|||||||
self.assertEqual(subscription.collection_method, selected_variation.collection_method)
|
self.assertEqual(subscription.collection_method, selected_variation.collection_method)
|
||||||
self.assertEqual(subscription.collection_method, 'manual')
|
self.assertEqual(subscription.collection_method, 'manual')
|
||||||
self.assertEqual(subscription.plan, selected_variation.plan)
|
self.assertEqual(subscription.plan, selected_variation.plan)
|
||||||
|
self.assertEqual(str(subscription.payment_method), 'Bank Transfer')
|
||||||
|
self.assertIsNone(subscription.payment_method.token)
|
||||||
|
|
||||||
order = subscription.latest_order()
|
order = subscription.latest_order()
|
||||||
self.assertEqual(order.status, 'created')
|
self.assertEqual(order.status, 'created')
|
||||||
@ -545,6 +537,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
|||||||
self.assertIsNotNone(order.display_number)
|
self.assertIsNotNone(order.display_number)
|
||||||
self.assertIsNotNone(order.vat_number)
|
self.assertIsNotNone(order.vat_number)
|
||||||
self.assertNotEqual(order.display_number, str(order.pk))
|
self.assertNotEqual(order.display_number, str(order.pk))
|
||||||
|
self.assertEqual(str(order.payment_method), 'Bank Transfer')
|
||||||
|
self.assertIsNone(order.payment_method.token)
|
||||||
|
|
||||||
self._assert_bank_transfer_email_is_sent(subscription)
|
self._assert_bank_transfer_email_is_sent(subscription)
|
||||||
self._assert_bank_transfer_email_is_sent_tax_21_eur_reverse_charged(subscription)
|
self._assert_bank_transfer_email_is_sent_tax_21_eur_reverse_charged(subscription)
|
||||||
@ -553,6 +547,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
|||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse('subscriptions:manage', kwargs={'subscription_id': subscription.pk})
|
reverse('subscriptions:manage', kwargs={'subscription_id': subscription.pk})
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertNotIn('32.00', response)
|
self.assertNotIn('32.00', response)
|
||||||
self.assertNotIn('21%', response)
|
self.assertNotIn('21%', response)
|
||||||
self.assertNotIn('Inc.', response)
|
self.assertNotIn('Inc.', response)
|
||||||
@ -581,34 +576,44 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
|||||||
'users.signals.tasks.grant_blender_id_role',
|
'users.signals.tasks.grant_blender_id_role',
|
||||||
new=users.tasks.grant_blender_id_role.task_function,
|
new=users.tasks.grant_blender_id_role.task_function,
|
||||||
)
|
)
|
||||||
@responses.activate
|
|
||||||
def test_pay_with_credit_card_creates_order_subscription_active(self):
|
def test_pay_with_credit_card_creates_order_subscription_active(self):
|
||||||
url, selected_variation = self._get_url_for(currency='EUR', price=990)
|
url, selected_variation = self._get_url_for(currency='EUR', price=990)
|
||||||
user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
|
customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
|
||||||
|
user = customer.user
|
||||||
|
OAuthUserInfoFactory(user=user, oauth_user_id=554433)
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
util.mock_blender_id_badger_badger_response(
|
|
||||||
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
|
|
||||||
)
|
|
||||||
util.mock_blender_id_badger_badger_response(
|
|
||||||
'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
data = {
|
data = {**required_address_data, 'gateway': 'stripe'}
|
||||||
**required_address_data,
|
with responses.RequestsMock() as rsps:
|
||||||
'gateway': 'braintree',
|
rsps._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
|
||||||
# fake-three-d-secure-visa-full-authentication-nonce
|
|
||||||
# causes the following error:
|
|
||||||
# Merchant account must match the 3D Secure authorization merchant account: code 91584
|
|
||||||
# TODO(anna): figure out if this is due to our settings or a quirk of the sandbox
|
|
||||||
'payment_method_nonce': 'fake-valid-nonce',
|
|
||||||
'price': '9.90',
|
|
||||||
}
|
|
||||||
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
|
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response['Location'], self.cs_url, response['Location'])
|
||||||
|
|
||||||
|
# **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
|
||||||
|
# Pretend that checkout session was completed and we've returned to the success page with its ID:
|
||||||
|
subscription = user.customer.subscription_set.first()
|
||||||
|
order = subscription.latest_order()
|
||||||
|
url = reverse(
|
||||||
|
'looper:stripe_success',
|
||||||
|
kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
|
||||||
|
)
|
||||||
|
url = url.replace('CHECKOUT_SESSION_ID', self.cs_id)
|
||||||
|
with responses.RequestsMock() as rsps:
|
||||||
|
responses_from_file('stripe_get_cs_eur.yaml', order_id=order.pk, rsps=rsps)
|
||||||
|
util.mock_blender_id_badger_badger_response(
|
||||||
|
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id, rsps
|
||||||
|
)
|
||||||
|
util.mock_blender_id_badger_badger_response(
|
||||||
|
'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id, rsps
|
||||||
|
)
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
self._assert_done_page_displayed(response)
|
self._assert_done_page_displayed(response)
|
||||||
|
|
||||||
subscription = user.subscription_set.first()
|
subscription.refresh_from_db()
|
||||||
order = subscription.latest_order()
|
order.refresh_from_db()
|
||||||
self.assertEqual(subscription.status, 'active')
|
self.assertEqual(subscription.status, 'active')
|
||||||
self.assertEqual(subscription.price, Money('EUR', 990))
|
self.assertEqual(subscription.price, Money('EUR', 990))
|
||||||
self.assertEqual(subscription.collection_method, selected_variation.collection_method)
|
self.assertEqual(subscription.collection_method, selected_variation.collection_method)
|
||||||
@ -629,33 +634,47 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
|||||||
'users.signals.tasks.grant_blender_id_role',
|
'users.signals.tasks.grant_blender_id_role',
|
||||||
new=users.tasks.grant_blender_id_role.task_function,
|
new=users.tasks.grant_blender_id_role.task_function,
|
||||||
)
|
)
|
||||||
@responses.activate
|
|
||||||
def test_pay_with_credit_card_creates_order_subscription_active_team(self):
|
def test_pay_with_credit_card_creates_order_subscription_active_team(self):
|
||||||
url, selected_variation = self._get_url_for(
|
url, selected_variation = self._get_url_for(
|
||||||
currency='EUR',
|
currency='EUR',
|
||||||
price=9000,
|
price=9000,
|
||||||
plan__name='Automatic renewal, 15 seats',
|
plan__name='Automatic renewal, 15 seats',
|
||||||
)
|
)
|
||||||
user = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
|
customer = create_customer_with_billing_address(country='NL', full_name='Jane Doe')
|
||||||
|
user = customer.user
|
||||||
|
OAuthUserInfoFactory(user=user, oauth_user_id=554433)
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
util.mock_blender_id_badger_badger_response(
|
|
||||||
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id
|
|
||||||
)
|
|
||||||
util.mock_blender_id_badger_badger_response(
|
|
||||||
'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
data = {
|
data = {**required_address_data, 'gateway': 'stripe'}
|
||||||
**required_address_data,
|
with responses.RequestsMock() as rsps:
|
||||||
'gateway': 'braintree',
|
rsps._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
|
||||||
'payment_method_nonce': 'fake-valid-nonce',
|
|
||||||
'price': '90.00',
|
|
||||||
}
|
|
||||||
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
|
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response['Location'], self.cs_url, response['Location'])
|
||||||
|
|
||||||
|
# **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
|
||||||
|
# Pretend that checkout session was completed and we've returned to the success page with its ID:
|
||||||
|
subscription = user.customer.subscription_set.first()
|
||||||
|
order = subscription.latest_order()
|
||||||
|
url = reverse(
|
||||||
|
'looper:stripe_success',
|
||||||
|
kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
|
||||||
|
)
|
||||||
|
url = url.replace('CHECKOUT_SESSION_ID', self.cs_id)
|
||||||
|
with responses.RequestsMock() as rsps:
|
||||||
|
responses_from_file('stripe_get_cs_eur.yaml', order_id=order.pk, rsps=rsps)
|
||||||
|
util.mock_blender_id_badger_badger_response(
|
||||||
|
'grant', 'cloud_has_subscription', user.oauth_info.oauth_user_id, rsps
|
||||||
|
)
|
||||||
|
util.mock_blender_id_badger_badger_response(
|
||||||
|
'grant', 'cloud_subscriber', user.oauth_info.oauth_user_id, rsps
|
||||||
|
)
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
self._assert_done_page_displayed(response)
|
self._assert_done_page_displayed(response)
|
||||||
|
|
||||||
subscription = user.subscription_set.first()
|
subscription = customer.subscription_set.first()
|
||||||
order = subscription.latest_order()
|
order = subscription.latest_order()
|
||||||
self.assertEqual(subscription.status, 'active')
|
self.assertEqual(subscription.status, 'active')
|
||||||
self.assertEqual(subscription.price, Money('EUR', 9000))
|
self.assertEqual(subscription.price, Money('EUR', 9000))
|
||||||
@ -670,7 +689,8 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
|||||||
self._assert_team_subscription_activated_email_is_sent(subscription)
|
self._assert_team_subscription_activated_email_is_sent(subscription)
|
||||||
|
|
||||||
def test_pay_with_credit_card_creates_order_subscription_active_business_de(self):
|
def test_pay_with_credit_card_creates_order_subscription_active_business_de(self):
|
||||||
user = create_customer_with_billing_address(country='DE', vat_number='DE260543043')
|
customer = create_customer_with_billing_address(country='DE', vat_number='DE260543043')
|
||||||
|
user = customer.user
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|
||||||
url, selected_variation = self._get_url_for(
|
url, selected_variation = self._get_url_for(
|
||||||
@ -681,19 +701,46 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
|||||||
)
|
)
|
||||||
data = {
|
data = {
|
||||||
**required_address_data,
|
**required_address_data,
|
||||||
'vat_number': 'DE 260543043',
|
'gateway': 'stripe',
|
||||||
'country': 'DE',
|
'country': 'DE',
|
||||||
'postal_code': '11111',
|
'postal_code': '11111',
|
||||||
'gateway': 'braintree',
|
'vat_number': 'DE 260543043',
|
||||||
'payment_method_nonce': 'fake-valid-nonce',
|
|
||||||
# VAT is subtracted from the plan variation price:
|
|
||||||
'price': '12.52',
|
|
||||||
}
|
}
|
||||||
response = self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
|
# @_recorder.record(file_path=f'{responses_dir}stripe_new_cs_eur.yaml')
|
||||||
|
def _continue_to_payment(): # noqa: E306
|
||||||
|
return self.client.post(url, data, REMOTE_ADDR=EURO_IPV4)
|
||||||
|
|
||||||
|
with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
|
||||||
|
# request to wsdl doesn't happen on subsequent calls to the validator,
|
||||||
|
# hence assert_all_requests_are_fired = False.
|
||||||
|
rsps._add_from_file(f'{responses_dir}vies_wsdl.yaml')
|
||||||
|
rsps._add_from_file(f'{responses_dir}vies_valid.yaml')
|
||||||
|
rsps._add_from_file(f'{responses_dir}stripe_new_cs_eur.yaml')
|
||||||
|
response = _continue_to_payment()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response['Location'], self.cs_url, response['Location'])
|
||||||
|
|
||||||
|
# **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
|
||||||
|
# Pretend that checkout session was completed and we've returned to the success page with its ID:
|
||||||
|
subscription = user.customer.subscription_set.first()
|
||||||
|
order = subscription.latest_order()
|
||||||
|
url = reverse(
|
||||||
|
'looper:stripe_success',
|
||||||
|
kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
|
||||||
|
)
|
||||||
|
url = url.replace('CHECKOUT_SESSION_ID', self.cs_id)
|
||||||
|
# @_recorder.record(file_path=f'{responses_dir}stripe_get_cs_eur.yaml')
|
||||||
|
def _back_to_success_url(): # noqa: E306
|
||||||
|
return self.client.get(url)
|
||||||
|
|
||||||
|
with responses.RequestsMock() as rsps:
|
||||||
|
responses_from_file('stripe_get_cs_eur.yaml', order_id=order.pk, rsps=rsps)
|
||||||
|
response = _back_to_success_url()
|
||||||
|
|
||||||
self._assert_done_page_displayed(response)
|
self._assert_done_page_displayed(response)
|
||||||
|
|
||||||
subscription = user.subscription_set.first()
|
subscription.refresh_from_db()
|
||||||
self.assertEqual(subscription.status, 'active')
|
self.assertEqual(subscription.status, 'active')
|
||||||
self.assertEqual(subscription.price, Money('EUR', 1490))
|
self.assertEqual(subscription.price, Money('EUR', 1490))
|
||||||
self.assertEqual(subscription.tax, Money('EUR', 0))
|
self.assertEqual(subscription.tax, Money('EUR', 0))
|
||||||
@ -716,15 +763,17 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
|
|||||||
self.assertIsNotNone(order.number)
|
self.assertIsNotNone(order.number)
|
||||||
|
|
||||||
|
|
||||||
class TestJoinConfirmAndPayLoggedInUserOnlyView(BaseSubscriptionTestCase):
|
class TestJoinViewLoggedInUserOnly(TestCase):
|
||||||
url = reverse('subscriptions:join-confirm-and-pay', kwargs={'plan_variation_id': 8})
|
url = reverse('subscriptions:join-billing-details', kwargs={'plan_variation_id': 2})
|
||||||
|
|
||||||
def test_get_anonymous_403(self):
|
def test_get_anonymous_redirects_to_login_with_next(self):
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response['Location'], '/oauth/login?next=/join/plan-variation/2/billing/')
|
||||||
|
|
||||||
def test_join_post_anonymous_403(self):
|
def test_post_anonymous_redirects_to_login_with_next(self):
|
||||||
response = self.client.post(self.url)
|
response = self.client.post(self.url)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response['Location'], '/oauth/login?next=/join/plan-variation/2/billing/')
|
||||||
|
@ -10,11 +10,9 @@ from freezegun import freeze_time
|
|||||||
from looper.tests.factories import PaymentMethodFactory, OrderFactory
|
from looper.tests.factories import PaymentMethodFactory, OrderFactory
|
||||||
import looper.taxes
|
import looper.taxes
|
||||||
|
|
||||||
from common.tests.factories.subscriptions import (
|
from common.tests.factories.subscriptions import TeamFactory
|
||||||
TeamFactory,
|
|
||||||
create_customer_with_billing_address,
|
|
||||||
)
|
|
||||||
from common.tests.factories.users import UserFactory
|
from common.tests.factories.users import UserFactory
|
||||||
|
from looper.tests.factories import create_customer_with_billing_address
|
||||||
|
|
||||||
expected_text_tmpl = '''Invoice
|
expected_text_tmpl = '''Invoice
|
||||||
Blender Studio B.V.
|
Blender Studio B.V.
|
||||||
@ -63,16 +61,16 @@ class TestReceiptPDFView(TestCase):
|
|||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
super().setUpClass()
|
super().setUpClass()
|
||||||
|
|
||||||
user = create_customer_with_billing_address(email='mail1@example.com')
|
customer = create_customer_with_billing_address(email='mail1@example.com')
|
||||||
cls.payment_method = PaymentMethodFactory(user=user)
|
cls.payment_method = PaymentMethodFactory(customer=customer)
|
||||||
cls.paid_order = OrderFactory(
|
cls.paid_order = OrderFactory(
|
||||||
user=user,
|
customer=customer,
|
||||||
price=990,
|
price=990,
|
||||||
status='paid',
|
status='paid',
|
||||||
tax_country='NL',
|
tax_country='NL',
|
||||||
payment_method=cls.payment_method,
|
payment_method=cls.payment_method,
|
||||||
|
subscription__customer=customer,
|
||||||
subscription__payment_method=cls.payment_method,
|
subscription__payment_method=cls.payment_method,
|
||||||
subscription__user=user,
|
|
||||||
subscription__plan__product__name='Blender Studio Subscription',
|
subscription__plan__product__name='Blender Studio Subscription',
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -84,15 +82,15 @@ class TestReceiptPDFView(TestCase):
|
|||||||
|
|
||||||
def test_get_pdf_unpaid_order_not_found(self):
|
def test_get_pdf_unpaid_order_not_found(self):
|
||||||
unpaid_order = OrderFactory(
|
unpaid_order = OrderFactory(
|
||||||
user=self.payment_method.user,
|
customer=self.payment_method.customer,
|
||||||
price=990,
|
price=990,
|
||||||
tax_country='NL',
|
tax_country='NL',
|
||||||
payment_method=self.payment_method,
|
payment_method=self.payment_method,
|
||||||
|
subscription__customer=self.payment_method.customer,
|
||||||
subscription__payment_method=self.payment_method,
|
subscription__payment_method=self.payment_method,
|
||||||
subscription__user=self.payment_method.user,
|
|
||||||
subscription__plan_id=1,
|
subscription__plan_id=1,
|
||||||
)
|
)
|
||||||
self.client.force_login(unpaid_order.user)
|
self.client.force_login(unpaid_order.customer.user)
|
||||||
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': unpaid_order.pk})
|
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': unpaid_order.pk})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
|
|
||||||
@ -115,7 +113,7 @@ class TestReceiptPDFView(TestCase):
|
|||||||
self.assertEqual(404, response.status_code)
|
self.assertEqual(404, response.status_code)
|
||||||
|
|
||||||
def test_get_pdf_has_logo(self):
|
def test_get_pdf_has_logo(self):
|
||||||
self.client.force_login(self.paid_order.user)
|
self.client.force_login(self.paid_order.customer.user)
|
||||||
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': self.paid_order.pk})
|
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': self.paid_order.pk})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
|
|
||||||
@ -136,7 +134,9 @@ class TestReceiptPDFView(TestCase):
|
|||||||
tax_type=looper.taxes.TaxType.VAT_CHARGE,
|
tax_type=looper.taxes.TaxType.VAT_CHARGE,
|
||||||
tax_rate=Decimal(19),
|
tax_rate=Decimal(19),
|
||||||
)
|
)
|
||||||
|
user = UserFactory()
|
||||||
order = OrderFactory(
|
order = OrderFactory(
|
||||||
|
customer=user.customer,
|
||||||
price=taxable.price,
|
price=taxable.price,
|
||||||
status='paid',
|
status='paid',
|
||||||
tax=taxable.tax,
|
tax=taxable.tax,
|
||||||
@ -144,9 +144,10 @@ class TestReceiptPDFView(TestCase):
|
|||||||
tax_type=taxable.tax_type.value,
|
tax_type=taxable.tax_type.value,
|
||||||
tax_rate=taxable.tax_rate,
|
tax_rate=taxable.tax_rate,
|
||||||
email='billing@example.com',
|
email='billing@example.com',
|
||||||
|
subscription__customer=user.customer,
|
||||||
subscription__plan_id=1,
|
subscription__plan_id=1,
|
||||||
)
|
)
|
||||||
self.client.force_login(order.user)
|
self.client.force_login(order.customer.user)
|
||||||
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
|
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
|
|
||||||
@ -178,7 +179,9 @@ class TestReceiptPDFView(TestCase):
|
|||||||
tax_type=looper.taxes.TaxType.VAT_REVERSE_CHARGE,
|
tax_type=looper.taxes.TaxType.VAT_REVERSE_CHARGE,
|
||||||
tax_rate=Decimal(19),
|
tax_rate=Decimal(19),
|
||||||
)
|
)
|
||||||
|
user = UserFactory()
|
||||||
order = OrderFactory(
|
order = OrderFactory(
|
||||||
|
customer=user.customer,
|
||||||
price=taxable.price,
|
price=taxable.price,
|
||||||
status='paid',
|
status='paid',
|
||||||
tax=taxable.tax,
|
tax=taxable.tax,
|
||||||
@ -187,9 +190,10 @@ class TestReceiptPDFView(TestCase):
|
|||||||
tax_rate=taxable.tax_rate,
|
tax_rate=taxable.tax_rate,
|
||||||
vat_number='DE123456789',
|
vat_number='DE123456789',
|
||||||
email='billing@example.com',
|
email='billing@example.com',
|
||||||
|
subscription__customer=user.customer,
|
||||||
subscription__plan_id=1,
|
subscription__plan_id=1,
|
||||||
)
|
)
|
||||||
self.client.force_login(order.user)
|
self.client.force_login(order.customer.user)
|
||||||
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
|
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
|
|
||||||
@ -225,7 +229,9 @@ class TestReceiptPDFView(TestCase):
|
|||||||
tax_type=looper.taxes.TaxType.VAT_CHARGE,
|
tax_type=looper.taxes.TaxType.VAT_CHARGE,
|
||||||
tax_rate=Decimal(21),
|
tax_rate=Decimal(21),
|
||||||
)
|
)
|
||||||
|
user = UserFactory()
|
||||||
order = OrderFactory(
|
order = OrderFactory(
|
||||||
|
customer=user.customer,
|
||||||
price=taxable.price,
|
price=taxable.price,
|
||||||
status='paid',
|
status='paid',
|
||||||
tax=taxable.tax,
|
tax=taxable.tax,
|
||||||
@ -234,9 +240,10 @@ class TestReceiptPDFView(TestCase):
|
|||||||
tax_rate=taxable.tax_rate,
|
tax_rate=taxable.tax_rate,
|
||||||
vat_number='NL123456789',
|
vat_number='NL123456789',
|
||||||
email='billing@example.com',
|
email='billing@example.com',
|
||||||
|
subscription__customer=user.customer,
|
||||||
subscription__plan_id=1,
|
subscription__plan_id=1,
|
||||||
)
|
)
|
||||||
self.client.force_login(order.user)
|
self.client.force_login(order.customer.user)
|
||||||
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
|
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
|
|
||||||
@ -263,15 +270,18 @@ class TestReceiptPDFView(TestCase):
|
|||||||
|
|
||||||
@freeze_time('2023-02-08T11:12:20+01:00')
|
@freeze_time('2023-02-08T11:12:20+01:00')
|
||||||
def test_get_pdf_total_no_vat(self):
|
def test_get_pdf_total_no_vat(self):
|
||||||
|
user = UserFactory()
|
||||||
order = OrderFactory(
|
order = OrderFactory(
|
||||||
|
customer=user.customer,
|
||||||
price=1000,
|
price=1000,
|
||||||
currency='USD',
|
currency='USD',
|
||||||
status='paid',
|
status='paid',
|
||||||
tax_country='US',
|
tax_country='US',
|
||||||
email='billing@example.com',
|
email='billing@example.com',
|
||||||
|
subscription__customer=user.customer,
|
||||||
subscription__plan_id=1,
|
subscription__plan_id=1,
|
||||||
)
|
)
|
||||||
self.client.force_login(order.user)
|
self.client.force_login(order.customer.user)
|
||||||
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
|
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
|
|
||||||
@ -305,6 +315,7 @@ class TestReceiptPDFView(TestCase):
|
|||||||
subscription__plan_id=1,
|
subscription__plan_id=1,
|
||||||
)
|
)
|
||||||
order = OrderFactory(
|
order = OrderFactory(
|
||||||
|
customer=team.subscription.customer,
|
||||||
price=20000,
|
price=20000,
|
||||||
currency='USD',
|
currency='USD',
|
||||||
status='paid',
|
status='paid',
|
||||||
@ -312,7 +323,7 @@ class TestReceiptPDFView(TestCase):
|
|||||||
email='billing@example.com',
|
email='billing@example.com',
|
||||||
subscription=team.subscription,
|
subscription=team.subscription,
|
||||||
)
|
)
|
||||||
self.client.force_login(order.user)
|
self.client.force_login(order.customer.user)
|
||||||
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
|
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
|
|
||||||
@ -347,6 +358,7 @@ class TestReceiptPDFView(TestCase):
|
|||||||
invoice_reference='PO #9876',
|
invoice_reference='PO #9876',
|
||||||
)
|
)
|
||||||
order = OrderFactory(
|
order = OrderFactory(
|
||||||
|
customer=team.subscription.customer,
|
||||||
price=20000,
|
price=20000,
|
||||||
currency='USD',
|
currency='USD',
|
||||||
status='paid',
|
status='paid',
|
||||||
@ -354,7 +366,7 @@ class TestReceiptPDFView(TestCase):
|
|||||||
email='billing@example.com',
|
email='billing@example.com',
|
||||||
subscription=team.subscription,
|
subscription=team.subscription,
|
||||||
)
|
)
|
||||||
self.client.force_login(order.user)
|
self.client.force_login(order.customer.user)
|
||||||
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
|
url = reverse('subscriptions:receipt-pdf', kwargs={'order_id': order.pk})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ from freezegun import freeze_time
|
|||||||
|
|
||||||
from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4 # , SINGAPORE_IPV4
|
from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4 # , SINGAPORE_IPV4
|
||||||
|
|
||||||
from common.tests.factories.subscriptions import create_customer_with_billing_address
|
from looper.tests.factories import create_customer_with_billing_address
|
||||||
from subscriptions.tests.base import BaseSubscriptionTestCase
|
from subscriptions.tests.base import BaseSubscriptionTestCase
|
||||||
|
|
||||||
# **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database.
|
# **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database.
|
||||||
@ -49,8 +49,8 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase):
|
|||||||
self._assert_default_variation_selected_no_tax_usd(response)
|
self._assert_default_variation_selected_no_tax_usd(response)
|
||||||
|
|
||||||
def test_get_displays_plan_selection_to_logged_in_nl(self):
|
def test_get_displays_plan_selection_to_logged_in_nl(self):
|
||||||
user = create_customer_with_billing_address(vat_number='', country='NL')
|
customer = create_customer_with_billing_address(vat_number='', country='NL')
|
||||||
self.client.force_login(user)
|
self.client.force_login(customer.user)
|
||||||
|
|
||||||
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
|
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
|
||||||
|
|
||||||
@ -60,8 +60,8 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase):
|
|||||||
self._assert_default_variation_selected_tax_21_eur(response)
|
self._assert_default_variation_selected_tax_21_eur(response)
|
||||||
|
|
||||||
def test_get_displays_plan_selection_to_logged_in_de(self):
|
def test_get_displays_plan_selection_to_logged_in_de(self):
|
||||||
user = create_customer_with_billing_address(vat_number='', country='DE')
|
customer = create_customer_with_billing_address(vat_number='', country='DE')
|
||||||
self.client.force_login(user)
|
self.client.force_login(customer.user)
|
||||||
|
|
||||||
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
|
response = self.client.get(self.url, REMOTE_ADDR=EURO_IPV4)
|
||||||
|
|
||||||
@ -71,10 +71,10 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase):
|
|||||||
self._assert_default_variation_selected_tax_19_eur(response)
|
self._assert_default_variation_selected_tax_19_eur(response)
|
||||||
|
|
||||||
def test_get_displays_plan_selection_to_logged_in_us(self):
|
def test_get_displays_plan_selection_to_logged_in_us(self):
|
||||||
user = create_customer_with_billing_address(
|
customer = create_customer_with_billing_address(
|
||||||
vat_number='', country='US', region='NY', postal_code='12001'
|
vat_number='', country='US', region='NY', postal_code='12001'
|
||||||
)
|
)
|
||||||
self.client.force_login(user)
|
self.client.force_login(customer.user)
|
||||||
|
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
@ -84,10 +84,10 @@ class TestSelectPlanVariationView(BaseSubscriptionTestCase):
|
|||||||
self._assert_plan_selector_no_tax(response)
|
self._assert_plan_selector_no_tax(response)
|
||||||
|
|
||||||
def test_get_team_displays_plan_selection_to_logged_in_us(self):
|
def test_get_team_displays_plan_selection_to_logged_in_us(self):
|
||||||
user = create_customer_with_billing_address(
|
customer = create_customer_with_billing_address(
|
||||||
vat_number='', country='US', region='NY', postal_code='12001'
|
vat_number='', country='US', region='NY', postal_code='12001'
|
||||||
)
|
)
|
||||||
self.client.force_login(user)
|
self.client.force_login(customer.user)
|
||||||
|
|
||||||
response = self.client.get(self.url_team)
|
response = self.client.get(self.url_team)
|
||||||
|
|
||||||
|
@ -4,12 +4,16 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from looper.models import PaymentMethod, PaymentMethodAuthentication, Gateway
|
from looper.models import PaymentMethod, PaymentMethodAuthentication, Gateway
|
||||||
from looper.money import Money
|
from looper.money import Money
|
||||||
|
from looper.tests.factories import SubscriptionFactory
|
||||||
|
import responses
|
||||||
|
|
||||||
|
# from responses import _recorder
|
||||||
|
|
||||||
from common.tests.factories.users import UserFactory
|
from common.tests.factories.users import UserFactory
|
||||||
from common.tests.factories.subscriptions import SubscriptionFactory
|
from subscriptions.tests.base import BaseSubscriptionTestCase, responses_from_file
|
||||||
from subscriptions.tests.base import BaseSubscriptionTestCase
|
|
||||||
import subscriptions.tasks
|
import subscriptions.tasks
|
||||||
|
|
||||||
|
responses_dir = 'subscriptions/tests/_responses/'
|
||||||
required_address_data = {
|
required_address_data = {
|
||||||
'country': 'NL',
|
'country': 'NL',
|
||||||
'email': 'my.billing.email@example.com',
|
'email': 'my.billing.email@example.com',
|
||||||
@ -27,16 +31,17 @@ full_billing_address_data = {
|
|||||||
|
|
||||||
|
|
||||||
class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
|
class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
|
||||||
def test_saves_both_address_and_customer(self):
|
url = reverse('subscriptions:billing-address')
|
||||||
|
|
||||||
|
def test_saves_full_billing_address(self):
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|
||||||
url = reverse('subscriptions:billing-address')
|
response = self.client.post(self.url, full_billing_address_data)
|
||||||
response = self.client.post(url, full_billing_address_data)
|
|
||||||
|
|
||||||
# Check that the redirect on success happened
|
# Check that the redirect on success happened
|
||||||
self.assertEqual(response.status_code, 302, response.content)
|
self.assertEqual(response.status_code, 302, response.content)
|
||||||
self.assertEqual(response['Location'], url)
|
self.assertEqual(response['Location'], self.url)
|
||||||
|
|
||||||
# Check that all address fields were updated
|
# Check that all address fields were updated
|
||||||
customer = user.customer
|
customer = user.customer
|
||||||
@ -51,15 +56,14 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
|
|||||||
self.assertEqual(address.company, 'Test LLC')
|
self.assertEqual(address.company, 'Test LLC')
|
||||||
|
|
||||||
# Check that customer fields were updated as well
|
# Check that customer fields were updated as well
|
||||||
self.assertEqual(customer.vat_number, 'NL818152011B01')
|
self.assertEqual(customer.billing_address.vat_number, 'NL818152011B01')
|
||||||
# N.B.: email is saved as Customer.billing_email
|
self.assertEqual(customer.billing_address.email, 'my.billing.email@example.com')
|
||||||
self.assertEqual(customer.billing_email, 'my.billing.email@example.com')
|
|
||||||
|
|
||||||
def test_invalid_missing_required_fields(self):
|
def test_invalid_missing_required_fields(self):
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|
||||||
response = self.client.post(reverse('subscriptions:billing-address'), {})
|
response = self.client.post(self.url, {})
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'errorlist')
|
self.assertContains(response, 'errorlist')
|
||||||
@ -72,7 +76,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
|
|||||||
data = {
|
data = {
|
||||||
'email': 'new@example.com',
|
'email': 'new@example.com',
|
||||||
}
|
}
|
||||||
response = self.client.post(reverse('subscriptions:billing-address'), data)
|
response = self.client.post(self.url, data)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'errorlist')
|
self.assertContains(response, 'errorlist')
|
||||||
@ -85,7 +89,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
|
|||||||
data = {
|
data = {
|
||||||
'full_name': 'New Full Name',
|
'full_name': 'New Full Name',
|
||||||
}
|
}
|
||||||
response = self.client.post(reverse('subscriptions:billing-address'), data)
|
response = self.client.post(self.url, data)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'errorlist')
|
self.assertContains(response, 'errorlist')
|
||||||
@ -100,11 +104,13 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
|
|||||||
url_name = 'subscriptions:payment-method-change'
|
url_name = 'subscriptions:payment-method-change'
|
||||||
success_url_name = 'user-settings-billing'
|
success_url_name = 'user-settings-billing'
|
||||||
|
|
||||||
|
# @_recorder.record(file_path=f'{responses_dir}stripe_new_cs_setup.yaml')
|
||||||
|
# @_recorder.record(file_path=f'{responses_dir}stripe_get_cs_setup.yaml')
|
||||||
def test_can_change_payment_method_from_bank_to_credit_card_with_sca(self):
|
def test_can_change_payment_method_from_bank_to_credit_card_with_sca(self):
|
||||||
bank = Gateway.objects.get(name='bank')
|
bank = Gateway.objects.get(name='bank')
|
||||||
subscription = SubscriptionFactory(
|
subscription = SubscriptionFactory(
|
||||||
user=self.user,
|
customer=self.user.customer,
|
||||||
payment_method__user_id=self.user.pk,
|
payment_method__customer_id=self.user.customer.pk,
|
||||||
payment_method__gateway=bank,
|
payment_method__gateway=bank,
|
||||||
)
|
)
|
||||||
self.assertEqual(PaymentMethod.objects.count(), 1)
|
self.assertEqual(PaymentMethod.objects.count(), 1)
|
||||||
@ -113,15 +119,24 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
|
|||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
url = reverse(self.url_name, kwargs={'subscription_id': subscription.pk})
|
url = reverse(self.url_name, kwargs={'subscription_id': subscription.pk})
|
||||||
data = {
|
with responses.RequestsMock() as rsps:
|
||||||
**self.shared_payment_form_data,
|
rsps._add_from_file(f'{responses_dir}stripe_new_cs_setup.yaml')
|
||||||
'gateway': 'braintree',
|
response = self.client.post(url)
|
||||||
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
|
|
||||||
}
|
|
||||||
response = self.client.post(url, data=data)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response['Location'], reverse(self.success_url_name))
|
expected_redirect_url = 'https://checkout.stripe.com/c/pay/cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBaZmppcGhrJyknYGtkZ2lgVWlkZmBtamlhYHd2Jz9xd3BgeCUl'
|
||||||
|
self.assertEqual(response['Location'], expected_redirect_url, response['Location'])
|
||||||
|
|
||||||
|
# **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
|
||||||
|
# Pretend that checkout session was completed and we've returned to the success page with its ID:
|
||||||
|
checkout_session_id = 'cs_test_c1QeSt36UcbmnmrXJnEYZpWakr377WPMfbWLeR9d3ZBYJPWXRUJ3TQ0UG9'
|
||||||
|
success_url = url + f'{checkout_session_id}/'
|
||||||
|
with responses.RequestsMock() as rsps:
|
||||||
|
rsps._add_from_file(f'{responses_dir}stripe_get_cs_setup.yaml')
|
||||||
|
response = self.client.get(success_url)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response['Location'], f'/subscription/{subscription.pk}/manage/')
|
||||||
|
|
||||||
# New payment method was created
|
# New payment method was created
|
||||||
self.assertEqual(PaymentMethod.objects.count(), 2)
|
self.assertEqual(PaymentMethod.objects.count(), 2)
|
||||||
|
|
||||||
@ -131,40 +146,9 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
|
|||||||
self.assertNotEqual(subscription.payment_method_id, payment_method.pk)
|
self.assertNotEqual(subscription.payment_method_id, payment_method.pk)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
str(subscription.payment_method),
|
str(subscription.payment_method),
|
||||||
'Visa credit card ending in 0002',
|
'visa credit card ending in 4242',
|
||||||
)
|
)
|
||||||
# SCA was stored
|
# SCA isn't stored for Stripe payment methods
|
||||||
self.assertIsNotNone(PaymentMethodAuthentication.objects.first())
|
|
||||||
|
|
||||||
def test_can_change_payment_method_from_credit_card_to_bank(self):
|
|
||||||
braintree = Gateway.objects.get(name='braintree')
|
|
||||||
subscription = SubscriptionFactory(
|
|
||||||
user=self.user,
|
|
||||||
payment_method__user_id=self.user.pk,
|
|
||||||
payment_method__gateway=braintree,
|
|
||||||
)
|
|
||||||
self.assertEqual(PaymentMethod.objects.count(), 1)
|
|
||||||
payment_method = subscription.payment_method
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
|
|
||||||
url = reverse(self.url_name, kwargs={'subscription_id': subscription.pk})
|
|
||||||
data = {
|
|
||||||
**self.shared_payment_form_data,
|
|
||||||
'gateway': 'bank',
|
|
||||||
'payment_method_nonce': 'unused',
|
|
||||||
}
|
|
||||||
response = self.client.post(url, data=data)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response['Location'], reverse(self.success_url_name))
|
|
||||||
# New payment method was created
|
|
||||||
self.assertEqual(PaymentMethod.objects.count(), 2)
|
|
||||||
|
|
||||||
subscription.refresh_from_db()
|
|
||||||
subscription.payment_method.refresh_from_db()
|
|
||||||
# Subscription's payment method was changed to bank transfer
|
|
||||||
self.assertNotEqual(subscription.payment_method_id, payment_method.pk)
|
|
||||||
self.assertEqual(str(subscription.payment_method), 'Bank Transfer')
|
|
||||||
self.assertIsNone(PaymentMethodAuthentication.objects.first())
|
self.assertIsNone(PaymentMethodAuthentication.objects.first())
|
||||||
|
|
||||||
|
|
||||||
@ -173,8 +157,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
|
|||||||
|
|
||||||
def test_can_cancel_when_on_hold(self):
|
def test_can_cancel_when_on_hold(self):
|
||||||
subscription = SubscriptionFactory(
|
subscription = SubscriptionFactory(
|
||||||
user=self.user,
|
customer=self.user.customer,
|
||||||
payment_method__user_id=self.user.pk,
|
payment_method__customer_id=self.user.customer.pk,
|
||||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
payment_method__gateway=Gateway.objects.get(name='bank'),
|
||||||
status='on-hold',
|
status='on-hold',
|
||||||
)
|
)
|
||||||
@ -198,8 +182,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
|
|||||||
|
|
||||||
def test_can_cancel_when_active(self):
|
def test_can_cancel_when_active(self):
|
||||||
subscription = SubscriptionFactory(
|
subscription = SubscriptionFactory(
|
||||||
user=self.user,
|
customer=self.user.customer,
|
||||||
payment_method__user_id=self.user.pk,
|
payment_method__customer_id=self.user.customer.pk,
|
||||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
payment_method__gateway=Gateway.objects.get(name='bank'),
|
||||||
status='active',
|
status='active',
|
||||||
)
|
)
|
||||||
@ -218,8 +202,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
|
|||||||
|
|
||||||
def test_email_sent_when_pending_cancellation_changes_to_cancelled(self):
|
def test_email_sent_when_pending_cancellation_changes_to_cancelled(self):
|
||||||
subscription = SubscriptionFactory(
|
subscription = SubscriptionFactory(
|
||||||
user=self.user,
|
customer=self.user.customer,
|
||||||
payment_method__user_id=self.user.pk,
|
payment_method__customer_id=self.user.customer.pk,
|
||||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
payment_method__gateway=Gateway.objects.get(name='bank'),
|
||||||
status='pending-cancellation',
|
status='pending-cancellation',
|
||||||
)
|
)
|
||||||
@ -240,12 +224,11 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
|
|||||||
|
|
||||||
class TestPayExistingOrder(BaseSubscriptionTestCase):
|
class TestPayExistingOrder(BaseSubscriptionTestCase):
|
||||||
url_name = 'subscriptions:pay-existing-order'
|
url_name = 'subscriptions:pay-existing-order'
|
||||||
success_url_name = 'user-settings-billing'
|
|
||||||
|
|
||||||
def test_redirect_to_login_when_anonymous(self):
|
def test_redirect_to_login_when_anonymous(self):
|
||||||
subscription = SubscriptionFactory(
|
subscription = SubscriptionFactory(
|
||||||
user=self.user,
|
customer=self.user.customer,
|
||||||
payment_method__user_id=self.user.pk,
|
payment_method__customer_id=self.user.customer.pk,
|
||||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
payment_method__gateway=Gateway.objects.get(name='bank'),
|
||||||
status='on-hold',
|
status='on-hold',
|
||||||
)
|
)
|
||||||
@ -253,19 +236,15 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
|
|||||||
|
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
url = reverse(self.url_name, kwargs={'order_id': order.pk})
|
url = reverse(self.url_name, kwargs={'order_id': order.pk})
|
||||||
data = {
|
response = self.client.get(url)
|
||||||
'gateway': 'braintree',
|
|
||||||
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
|
|
||||||
}
|
|
||||||
response = self.client.post(url, data=data)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response['Location'], f'/oauth/login?next={url}')
|
self.assertEqual(response['Location'], f'/oauth/login?next={url}')
|
||||||
|
|
||||||
def test_cannot_pay_someone_elses_order(self):
|
def test_cannot_pay_someone_elses_order(self):
|
||||||
subscription = SubscriptionFactory(
|
subscription = SubscriptionFactory(
|
||||||
user=self.user,
|
customer=self.user.customer,
|
||||||
payment_method__user_id=self.user.pk,
|
payment_method__customer_id=self.user.customer.pk,
|
||||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
payment_method__gateway=Gateway.objects.get(name='bank'),
|
||||||
status='on-hold',
|
status='on-hold',
|
||||||
)
|
)
|
||||||
@ -274,40 +253,12 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
|
|||||||
|
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
url = reverse(self.url_name, kwargs={'order_id': order.pk})
|
url = reverse(self.url_name, kwargs={'order_id': order.pk})
|
||||||
data = {
|
response = self.client.get(url)
|
||||||
'gateway': 'braintree',
|
|
||||||
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
|
|
||||||
}
|
|
||||||
response = self.client.post(url, data=data)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
def test_invalid_missing_required_form_data(self):
|
|
||||||
subscription = SubscriptionFactory(
|
|
||||||
user=self.user,
|
|
||||||
payment_method__user_id=self.user.pk,
|
|
||||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
|
||||||
status='on-hold',
|
|
||||||
)
|
|
||||||
order = subscription.generate_order()
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
|
|
||||||
url = reverse(self.url_name, kwargs={'order_id': order.pk})
|
|
||||||
response = self.client.post(url, data={})
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(
|
|
||||||
response.context['form'].errors,
|
|
||||||
{
|
|
||||||
# 'full_name': ['This field is required.'],
|
|
||||||
# 'country': ['This field is required.'],
|
|
||||||
# 'email': ['This field is required.'],
|
|
||||||
'payment_method_nonce': ['This field is required.'],
|
|
||||||
'gateway': ['This field is required.'],
|
|
||||||
'price': ['This field is required.'],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# @_recorder.record(file_path=f'{responses_dir}stripe_new_cs_usd.yaml')
|
||||||
|
# @_recorder.record(file_path=f'{responses_dir}stripe_get_cs_usd.yaml')
|
||||||
@patch(
|
@patch(
|
||||||
# Make sure background task is executed as a normal function
|
# Make sure background task is executed as a normal function
|
||||||
'subscriptions.signals.tasks.send_mail_subscription_status_changed',
|
'subscriptions.signals.tasks.send_mail_subscription_status_changed',
|
||||||
@ -315,26 +266,39 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
|
|||||||
)
|
)
|
||||||
def test_can_pay_for_manual_subscription_with_an_order(self):
|
def test_can_pay_for_manual_subscription_with_an_order(self):
|
||||||
subscription = SubscriptionFactory(
|
subscription = SubscriptionFactory(
|
||||||
user=self.user,
|
plan__name='Automatic renewal subscription',
|
||||||
payment_method__user_id=self.user.pk,
|
customer=self.user.customer,
|
||||||
|
payment_method__customer_id=self.user.customer.pk,
|
||||||
payment_method__gateway=Gateway.objects.get(name='bank'),
|
payment_method__gateway=Gateway.objects.get(name='bank'),
|
||||||
currency='USD',
|
currency='USD',
|
||||||
price=Money('USD', 1110),
|
price=Money('USD', 1110),
|
||||||
status='on-hold',
|
status='on-hold',
|
||||||
)
|
)
|
||||||
|
self.assertEqual(subscription.collection_method, 'automatic')
|
||||||
order = subscription.generate_order()
|
order = subscription.generate_order()
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
url = reverse(self.url_name, kwargs={'order_id': order.pk})
|
url = reverse(self.url_name, kwargs={'order_id': order.pk})
|
||||||
data = {
|
with responses.RequestsMock() as rsps:
|
||||||
**required_address_data,
|
rsps._add_from_file(f'{responses_dir}stripe_new_cs_usd.yaml')
|
||||||
'price': order.price,
|
response = self.client.get(url)
|
||||||
'gateway': 'braintree',
|
|
||||||
'payment_method_nonce': 'fake-three-d-secure-visa-full-authentication-nonce',
|
|
||||||
}
|
|
||||||
response = self.client.post(url, data=data)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
expected_redirect_url = 'https://checkout.stripe.com/c/pay/cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w#fidkdWxOYHwnPyd1blpxYHZxWjA0VUpnNzNAMU5EUEcwYW92dWIwclxRMzQ8ZkxsUDRET2dPbTVidnBCNEJTdlBJQTRJYFF2c09BMEFBdlxVT19USGpWSXRSXFJwdm5UQXRpdVw2Rmp%2FZ11NNTU3fHdHUl1JTCcpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl'
|
||||||
|
self.assertEqual(response['Location'], expected_redirect_url)
|
||||||
|
|
||||||
|
# **N.B**: this flow happens in 2 different views separated by a Stripe payment page.
|
||||||
|
# Pretend that checkout session was completed and we've returned to the success page with its ID:
|
||||||
|
checkout_session_id = 'cs_test_a1XUS0akCexOKoMTKKnt9SjK1UjPI9UTrF7LiLzXWYInOcANZzOFLBkA5w'
|
||||||
|
url = reverse(
|
||||||
|
'looper:stripe_success',
|
||||||
|
kwargs={'pk': order.pk, 'stripe_session_id': 'CHECKOUT_SESSION_ID'},
|
||||||
|
)
|
||||||
|
url = url.replace('CHECKOUT_SESSION_ID', checkout_session_id)
|
||||||
|
with responses.RequestsMock() as rsps:
|
||||||
|
responses_from_file('stripe_get_cs_usd.yaml', order_id=order.pk, rsps=rsps)
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
self.assertEqual(order.transaction_set.count(), 1)
|
self.assertEqual(order.transaction_set.count(), 1)
|
||||||
transaction = order.latest_transaction()
|
transaction = order.latest_transaction()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@ -350,7 +314,7 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
|
|||||||
self.assertNotEqual(subscription.payment_method, 'bank')
|
self.assertNotEqual(subscription.payment_method, 'bank')
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
str(subscription.payment_method),
|
str(subscription.payment_method),
|
||||||
'Visa credit card ending in 0002',
|
'PayPal account billing@example.com',
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(subscription.status, 'active')
|
self.assertEqual(subscription.status, 'active')
|
||||||
|
@ -6,6 +6,9 @@ from django.utils.html import format_html
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework.authtoken.admin import TokenAdmin
|
from rest_framework.authtoken.admin import TokenAdmin
|
||||||
|
|
||||||
|
import nested_admin
|
||||||
|
|
||||||
|
from looper.admin import REL_CUSTOMER_SEARCH_FIELDS
|
||||||
import looper.admin
|
import looper.admin
|
||||||
import looper.models
|
import looper.models
|
||||||
|
|
||||||
@ -26,7 +29,7 @@ user_section_progress_link.short_description = 'Training sections progress'
|
|||||||
|
|
||||||
def user_subscriptions_link(obj, title='View subscriptions of this user'):
|
def user_subscriptions_link(obj, title='View subscriptions of this user'):
|
||||||
admin_view = looper.admin._get_admin_url_name(looper.models.Subscription, 'changelist')
|
admin_view = looper.admin._get_admin_url_name(looper.models.Subscription, 'changelist')
|
||||||
link = reverse(admin_view) + f'?user_id={obj.pk}'
|
link = reverse(admin_view) + f'?customer_id={obj.customer.pk}'
|
||||||
return format_html('<a href="{}">{}</a>', link, title)
|
return format_html('<a href="{}">{}</a>', link, title)
|
||||||
|
|
||||||
|
|
||||||
@ -80,7 +83,7 @@ class NumberOfBraintreeCustomerIDsFilter(admin.SimpleListFilter):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(get_user_model())
|
@admin.register(get_user_model())
|
||||||
class UserAdmin(auth_admin.UserAdmin):
|
class UserAdmin(auth_admin.UserAdmin, nested_admin.NestedModelAdmin):
|
||||||
change_form_template = 'loginas/change_form.html'
|
change_form_template = 'loginas/change_form.html'
|
||||||
|
|
||||||
def has_add_permission(self, request):
|
def has_add_permission(self, request):
|
||||||
@ -91,9 +94,9 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
"""Count user subscriptions for subscription debugging purposes."""
|
"""Count user subscriptions for subscription debugging purposes."""
|
||||||
queryset = super().get_queryset(*args, **kwargs)
|
queryset = super().get_queryset(*args, **kwargs)
|
||||||
queryset = queryset.annotate(
|
queryset = queryset.annotate(
|
||||||
subscriptions_count=Count('subscription', distinct=True),
|
subscriptions_count=Count('customer__subscription', distinct=True),
|
||||||
braintreecustomerids_count=Count(
|
braintreecustomerids_count=Count(
|
||||||
'gatewaycustomerid', Q(gateway__name='braintree'), distinct=True
|
'customer__gatewaycustomerid', Q(customer__gateway__name='braintree'), distinct=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return queryset
|
return queryset
|
||||||
@ -147,13 +150,12 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
user_subscriptions_link,
|
user_subscriptions_link,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
)
|
)
|
||||||
inlines = [
|
inlines = (
|
||||||
looper.admin.AddressInline,
|
looper.admin.LinkCustomerTokenInline,
|
||||||
looper.admin.CustomerInline,
|
looper.admin.CustomerInline,
|
||||||
looper.admin.GatewayCustomerIdInline,
|
)
|
||||||
]
|
|
||||||
ordering = ['-date_joined']
|
ordering = ['-date_joined']
|
||||||
search_fields = ['email', 'full_name', 'username']
|
search_fields = ['email', 'full_name', 'username', *REL_CUSTOMER_SEARCH_FIELDS]
|
||||||
|
|
||||||
def deletion_requested(self, obj):
|
def deletion_requested(self, obj):
|
||||||
"""Display yes/no icon status of deletion request."""
|
"""Display yes/no icon status of deletion request."""
|
||||||
|
@ -174,7 +174,7 @@ class User(AbstractUser):
|
|||||||
self.delete_oauth()
|
self.delete_oauth()
|
||||||
|
|
||||||
# If there are no orders, the user account can be deleted
|
# If there are no orders, the user account can be deleted
|
||||||
if self.order_set.count() == 0:
|
if self.customer.order_set.count() == 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'User pk=%s requested deletion and has no orders: deleting the account',
|
'User pk=%s requested deletion and has no orders: deleting the account',
|
||||||
self.pk,
|
self.pk,
|
||||||
@ -199,7 +199,7 @@ class User(AbstractUser):
|
|||||||
logger.warning('Anonymized user pk=%s', self.pk)
|
logger.warning('Anonymized user pk=%s', self.pk)
|
||||||
|
|
||||||
logger.warning('Soft-deleting payment methods records of user pk=%s', self.pk)
|
logger.warning('Soft-deleting payment methods records of user pk=%s', self.pk)
|
||||||
for payment_method in self.paymentmethod_set.all():
|
for payment_method in self.customer.paymentmethod_set.all():
|
||||||
payment_method.recognisable_name = '<deleted>'
|
payment_method.recognisable_name = '<deleted>'
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'Deleting payment method %s of user pk=%s at the payment gateway',
|
'Deleting payment method %s of user pk=%s at the payment gateway',
|
||||||
@ -208,17 +208,14 @@ class User(AbstractUser):
|
|||||||
)
|
)
|
||||||
payment_method.delete()
|
payment_method.delete()
|
||||||
|
|
||||||
logger.warning('Deleting address records of user pk=%s', self.pk)
|
customer_id = self.customer.pk
|
||||||
looper.models.Address.objects.filter(user_id=self.pk).delete()
|
logger.warning('Deleting address records of customer pk=%s', customer_id)
|
||||||
|
looper.models.Address.objects.filter(customer_id=customer_id).delete()
|
||||||
|
|
||||||
logger.warning('Anonymizing Customer record of user pk=%s', self.pk)
|
logger.warning('Deleting gateway customer ID records of customer pk=%s', customer_id)
|
||||||
looper.models.Customer.objects.exclude(user_id=None).filter(user_id=self.pk).update(
|
looper.models.GatewayCustomerId.objects.filter(customer_id=customer_id).delete()
|
||||||
billing_email=f'{username}@example.com',
|
|
||||||
full_name='',
|
|
||||||
)
|
|
||||||
|
|
||||||
looper.models.GatewayCustomerId.objects.filter(user_id=self.pk).delete()
|
|
||||||
|
|
||||||
|
logger.warning('Deleting user pk=%s from teams', self.pk)
|
||||||
subscriptions.models.TeamUsers.objects.filter(user_id=self.pk).delete()
|
subscriptions.models.TeamUsers.objects.filter(user_id=self.pk).delete()
|
||||||
|
|
||||||
logger.warning('Anonymizing comments of user pk=%s', self.pk)
|
logger.warning('Anonymizing comments of user pk=%s', self.pk)
|
||||||
|
@ -107,7 +107,7 @@ def handle_tracking_event_unsubscribe(event_type: str, message_id: str, event: D
|
|||||||
def unsubscribe_from_newsletters(pk: int):
|
def unsubscribe_from_newsletters(pk: int):
|
||||||
"""Remove emails of user with given pk from newsletter lists."""
|
"""Remove emails of user with given pk from newsletter lists."""
|
||||||
user = User.objects.get(pk=pk)
|
user = User.objects.get(pk=pk)
|
||||||
emails = (user.email, user.customer.billing_email)
|
emails = (user.email, user.customer.billing_address.email)
|
||||||
for email in emails:
|
for email in emails:
|
||||||
if not email:
|
if not email:
|
||||||
continue
|
continue
|
||||||
@ -135,7 +135,7 @@ def handle_deletion_request(pk: int) -> bool:
|
|||||||
try:
|
try:
|
||||||
unsubscribe_from_newsletters(pk=pk)
|
unsubscribe_from_newsletters(pk=pk)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning('Error while trying to unsubscribe user pk=%s from newsletters')
|
logger.warning('Error while trying to unsubscribe user pk=%s from newsletters', pk)
|
||||||
|
|
||||||
user.anonymize_or_delete()
|
user.anonymize_or_delete()
|
||||||
return True
|
return True
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
{% extends 'common/base.html' %}
|
{% extends 'common/base.html' %}
|
||||||
|
{% load pipeline %}
|
||||||
|
|
||||||
{% block nav_drawer_inner %}
|
{% block nav_drawer_inner %}
|
||||||
{% include 'users/settings/tabs.html' %}
|
{% include 'users/settings/tabs.html' %}
|
||||||
@ -21,9 +22,13 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-9">
|
<div class="col-md-9">
|
||||||
{% block settings%}
|
{% block settings %}
|
||||||
{% endblock settings %}
|
{% endblock settings %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% javascript "subscriptions" %}
|
||||||
|
{% endblock scripts %}
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block settings %}
|
{% block settings %}
|
||||||
<p class="text-muted">Settings</p>
|
<p class="text-muted">Settings</p>
|
||||||
<h1 class="mb-3">Subscription{% if user.subscription_set.count > 1 %}s{% endif %}</h1>
|
<h1 class="mb-3">Subscription{% if user.customer.subscription_set.count > 1 %}s{% endif %}</h1>
|
||||||
<p class="mb-2 text-muted">If you have any problems with billing, contact the team directly on <a href="mailto:{{ ADMIN_MAIL }}">{{ ADMIN_MAIL }}</a>.</p>
|
<p class="mb-2 text-muted">If you have any problems with billing, contact the team directly on <a href="mailto:{{ ADMIN_MAIL }}">{{ ADMIN_MAIL }}</a>.</p>
|
||||||
<div>
|
<div>
|
||||||
{% if user|has_group:"demo" %}
|
{% if user|has_group:"demo" %}
|
||||||
@ -18,7 +18,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}{# for everyone except demo #}
|
{% endif %}
|
||||||
|
{% if not user|has_group:"demo" or user.customer.subscription_set.count %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% include "subscriptions/components/list.html" %}
|
{% include "subscriptions/components/list.html" %}
|
||||||
|
@ -5,15 +5,17 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from comments.queries import set_comment_like
|
from looper.tests.factories import (
|
||||||
from common.tests.factories.comments import CommentFactory
|
|
||||||
from common.tests.factories.subscriptions import (
|
|
||||||
TeamFactory,
|
|
||||||
PaymentMethodFactory,
|
PaymentMethodFactory,
|
||||||
TransactionFactory,
|
TransactionFactory,
|
||||||
create_customer_with_billing_address,
|
create_customer_with_billing_address,
|
||||||
)
|
)
|
||||||
from common.tests.factories.users import UserFactory
|
import looper.models
|
||||||
|
|
||||||
|
from comments.queries import set_comment_like
|
||||||
|
from common.tests.factories.comments import CommentFactory
|
||||||
|
from common.tests.factories.subscriptions import TeamFactory
|
||||||
|
from common.tests.factories.users import UserFactory, OAuthUserInfoFactory, OAuthUserTokenFactory
|
||||||
import users.tasks as tasks
|
import users.tasks as tasks
|
||||||
import users.tests.util as util
|
import users.tests.util as util
|
||||||
|
|
||||||
@ -49,9 +51,13 @@ class TestTasks(TestCase):
|
|||||||
|
|
||||||
def test_handle_deletion_request(self):
|
def test_handle_deletion_request(self):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
user = create_customer_with_billing_address(
|
customer = create_customer_with_billing_address(
|
||||||
email='mail1@example.com', date_deletion_requested=now - timedelta(days=30)
|
email='mail1@example.com', date_deletion_requested=now - timedelta(days=30)
|
||||||
)
|
)
|
||||||
|
user = customer.user
|
||||||
|
OAuthUserInfoFactory(user=user, oauth_user_id=223344)
|
||||||
|
OAuthUserTokenFactory(user=user)
|
||||||
|
OAuthUserTokenFactory(user=user)
|
||||||
# this user made some comments
|
# this user made some comments
|
||||||
user_comments = [CommentFactory(user=user) for _ in range(2)]
|
user_comments = [CommentFactory(user=user) for _ in range(2)]
|
||||||
# this user liked some comments as well
|
# this user liked some comments as well
|
||||||
@ -102,22 +108,27 @@ class TestTasks(TestCase):
|
|||||||
|
|
||||||
def test_handle_deletion_request_user_has_orders(self):
|
def test_handle_deletion_request_user_has_orders(self):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
user = create_customer_with_billing_address(
|
customer = create_customer_with_billing_address(
|
||||||
email='mail1@example.com', date_deletion_requested=now - timedelta(days=30)
|
email='mail1@example.com', date_deletion_requested=now - timedelta(days=30)
|
||||||
)
|
)
|
||||||
|
user = customer.user
|
||||||
|
OAuthUserInfoFactory(user=user, oauth_user_id=223344)
|
||||||
|
OAuthUserTokenFactory(user=user)
|
||||||
|
OAuthUserTokenFactory(user=user)
|
||||||
# this user has a subscription with an order and a transaction
|
# this user has a subscription with an order and a transaction
|
||||||
payment_method = PaymentMethodFactory(user=user)
|
payment_method = PaymentMethodFactory(customer=customer, token='fake-token')
|
||||||
transaction = TransactionFactory(
|
transaction = TransactionFactory(
|
||||||
user=user,
|
customer=customer,
|
||||||
order__price=990,
|
order__customer=customer,
|
||||||
order__tax_country='NL',
|
|
||||||
order__payment_method=payment_method,
|
order__payment_method=payment_method,
|
||||||
|
order__price=990,
|
||||||
|
order__subscription__customer=customer,
|
||||||
order__subscription__payment_method=payment_method,
|
order__subscription__payment_method=payment_method,
|
||||||
order__subscription__user=user,
|
|
||||||
order__subscription__status='cancelled',
|
order__subscription__status='cancelled',
|
||||||
order__user=user,
|
order__tax_country='NL',
|
||||||
payment_method=payment_method,
|
payment_method=payment_method,
|
||||||
)
|
)
|
||||||
|
billing_address = customer.billing_address
|
||||||
# this user made some comments
|
# this user made some comments
|
||||||
user_comments = [CommentFactory(user=user) for _ in range(2)]
|
user_comments = [CommentFactory(user=user) for _ in range(2)]
|
||||||
# this user liked some comments as well
|
# this user liked some comments as well
|
||||||
@ -142,8 +153,9 @@ class TestTasks(TestCase):
|
|||||||
f'Anonymized user pk={user.pk}',
|
f'Anonymized user pk={user.pk}',
|
||||||
f'Soft-deleting payment methods records of user pk={user.pk}',
|
f'Soft-deleting payment methods records of user pk={user.pk}',
|
||||||
rf'Deleting payment method \d+ of user pk={user.pk} at the payment gateway',
|
rf'Deleting payment method \d+ of user pk={user.pk} at the payment gateway',
|
||||||
f'Deleting address records of user pk={user.pk}',
|
f'Deleting address records of customer pk={customer.pk}',
|
||||||
f'Anonymizing Customer record of user pk={user.pk}',
|
f'Deleting gateway customer ID records of customer pk={customer.pk}',
|
||||||
|
f'Deleting user pk={user.pk} from teams',
|
||||||
f'Anonymizing comments of user pk={user.pk}',
|
f'Anonymizing comments of user pk={user.pk}',
|
||||||
f'Anonymizing likes of user pk={user.pk}',
|
f'Anonymizing likes of user pk={user.pk}',
|
||||||
f'Deleting actions of user pk={user.pk}',
|
f'Deleting actions of user pk={user.pk}',
|
||||||
@ -165,12 +177,11 @@ class TestTasks(TestCase):
|
|||||||
self.assertEqual(user.full_name, '')
|
self.assertEqual(user.full_name, '')
|
||||||
self.assertTrue(user.email.startswith('del'))
|
self.assertTrue(user.email.startswith('del'))
|
||||||
self.assertTrue(user.email.endswith('@example.com'))
|
self.assertTrue(user.email.endswith('@example.com'))
|
||||||
user.customer.refresh_from_db()
|
# billing address was deleted
|
||||||
self.assertTrue(user.customer.billing_email.startswith('del'), user.customer.billing_email)
|
with self.assertRaises(looper.models.Address.DoesNotExist):
|
||||||
self.assertTrue(user.customer.billing_email.endswith('@example.com'), user.customer.billing_email)
|
billing_address.refresh_from_db()
|
||||||
self.assertEqual(user.customer.full_name, '' , user.customer.full_name)
|
customer.refresh_from_db()
|
||||||
self.assertEqual(user.address_set.count(), 0)
|
self.assertEqual(customer.paymentmethod_set.first().recognisable_name, '')
|
||||||
self.assertEqual(user.paymentmethod_set.first().recognisable_name, '')
|
|
||||||
|
|
||||||
# user actions got deleted
|
# user actions got deleted
|
||||||
for action in user_actions:
|
for action in user_actions:
|
||||||
@ -192,22 +203,23 @@ class TestTasks(TestCase):
|
|||||||
|
|
||||||
def test_handle_deletion_request_user_has_not_yet_cancelled_subscription(self):
|
def test_handle_deletion_request_user_has_not_yet_cancelled_subscription(self):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
user = create_customer_with_billing_address(
|
customer = create_customer_with_billing_address(
|
||||||
full_name='Joe Dane',
|
full_name='Joe Dane',
|
||||||
email='mail1@example.com',
|
email='mail1@example.com',
|
||||||
date_deletion_requested=now - timedelta(days=30),
|
date_deletion_requested=now - timedelta(days=30),
|
||||||
)
|
)
|
||||||
|
user = customer.user
|
||||||
# this user has a subscription with an order and a transaction
|
# this user has a subscription with an order and a transaction
|
||||||
payment_method = PaymentMethodFactory(user=user)
|
payment_method = PaymentMethodFactory(customer=customer)
|
||||||
transaction = TransactionFactory(
|
transaction = TransactionFactory(
|
||||||
user=user,
|
customer=customer,
|
||||||
order__price=990,
|
order__customer=customer,
|
||||||
order__tax_country='NL',
|
|
||||||
order__payment_method=payment_method,
|
order__payment_method=payment_method,
|
||||||
|
order__price=990,
|
||||||
|
order__subscription__customer=customer,
|
||||||
order__subscription__payment_method=payment_method,
|
order__subscription__payment_method=payment_method,
|
||||||
order__subscription__user=user,
|
|
||||||
order__subscription__status='on-hold',
|
order__subscription__status='on-hold',
|
||||||
order__user=user,
|
order__tax_country='NL',
|
||||||
payment_method=payment_method,
|
payment_method=payment_method,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -235,7 +247,7 @@ class TestTasks(TestCase):
|
|||||||
def test_handle_deletion_request_user_has_orders_and_is_on_a_team(self):
|
def test_handle_deletion_request_user_has_orders_and_is_on_a_team(self):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
team = TeamFactory(
|
team = TeamFactory(
|
||||||
subscription__user=create_customer_with_billing_address(
|
subscription__customer=create_customer_with_billing_address(
|
||||||
full_name='Joe Manager Dane',
|
full_name='Joe Manager Dane',
|
||||||
email='mail1@example.com',
|
email='mail1@example.com',
|
||||||
)
|
)
|
||||||
@ -246,25 +258,27 @@ class TestTasks(TestCase):
|
|||||||
team.users.add(user_to_be_deleted)
|
team.users.add(user_to_be_deleted)
|
||||||
self.assertEqual(3, team.users.count())
|
self.assertEqual(3, team.users.count())
|
||||||
# this user also has a subscription with an order and a transaction
|
# this user also has a subscription with an order and a transaction
|
||||||
payment_method = PaymentMethodFactory(user=user_to_be_deleted)
|
payment_method = PaymentMethodFactory(
|
||||||
|
customer=user_to_be_deleted.customer, token='fake-token'
|
||||||
|
)
|
||||||
TransactionFactory(
|
TransactionFactory(
|
||||||
user=user_to_be_deleted,
|
customer=user_to_be_deleted.customer,
|
||||||
order__price=990,
|
order__price=990,
|
||||||
order__tax_country='NL',
|
order__tax_country='NL',
|
||||||
|
order__customer=user_to_be_deleted.customer,
|
||||||
order__payment_method=payment_method,
|
order__payment_method=payment_method,
|
||||||
order__subscription__payment_method=payment_method,
|
order__subscription__payment_method=payment_method,
|
||||||
order__subscription__user=user_to_be_deleted,
|
order__subscription__customer=user_to_be_deleted.customer,
|
||||||
order__subscription__status='cancelled',
|
order__subscription__status='cancelled',
|
||||||
order__user=user_to_be_deleted,
|
|
||||||
payment_method=payment_method,
|
payment_method=payment_method,
|
||||||
)
|
)
|
||||||
|
|
||||||
tasks.handle_deletion_request.task_function(pk=user_to_be_deleted.pk)
|
tasks.handle_deletion_request.task_function(pk=user_to_be_deleted.pk)
|
||||||
|
|
||||||
# sanity check: nothing happened to the user owning the team subscription
|
# sanity check: nothing happened to the user owning the team subscription
|
||||||
team.subscription.user.refresh_from_db()
|
team.subscription.customer.user.refresh_from_db()
|
||||||
self.assertEqual('Joe Manager Dane', team.subscription.user.full_name)
|
self.assertEqual('Joe Manager Dane', team.subscription.customer.user.full_name)
|
||||||
self.assertTrue(team.subscription.user.is_active)
|
self.assertTrue(team.subscription.customer.user.is_active)
|
||||||
|
|
||||||
# user wasn't deleted but anonymised
|
# user wasn't deleted but anonymised
|
||||||
user_to_be_deleted.refresh_from_db()
|
user_to_be_deleted.refresh_from_db()
|
||||||
@ -280,7 +294,7 @@ class TestTasks(TestCase):
|
|||||||
def test_handle_deletion_request_user_and_is_on_a_team(self):
|
def test_handle_deletion_request_user_and_is_on_a_team(self):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
team = TeamFactory(
|
team = TeamFactory(
|
||||||
subscription__user=create_customer_with_billing_address(
|
subscription__customer=create_customer_with_billing_address(
|
||||||
full_name='Joe Manager Dane',
|
full_name='Joe Manager Dane',
|
||||||
email='mail1@example.com',
|
email='mail1@example.com',
|
||||||
)
|
)
|
||||||
@ -294,9 +308,9 @@ class TestTasks(TestCase):
|
|||||||
tasks.handle_deletion_request.task_function(pk=user_to_be_deleted.pk)
|
tasks.handle_deletion_request.task_function(pk=user_to_be_deleted.pk)
|
||||||
|
|
||||||
# sanity check: nothing happened to the user owning the team subscription
|
# sanity check: nothing happened to the user owning the team subscription
|
||||||
team.subscription.user.refresh_from_db()
|
team.subscription.customer.user.refresh_from_db()
|
||||||
self.assertEqual('Joe Manager Dane', team.subscription.user.full_name)
|
self.assertEqual('Joe Manager Dane', team.subscription.customer.user.full_name)
|
||||||
self.assertTrue(team.subscription.user.is_active)
|
self.assertTrue(team.subscription.customer.user.is_active)
|
||||||
|
|
||||||
# user was deleted
|
# user was deleted
|
||||||
with self.assertRaises(User.DoesNotExist):
|
with self.assertRaises(User.DoesNotExist):
|
||||||
|
@ -5,8 +5,6 @@ from django.conf import settings
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
import responses
|
import responses
|
||||||
|
|
||||||
from common.tests.factories.users import UserFactory
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
@ -145,7 +143,7 @@ def mock_mailgun_responses() -> None:
|
|||||||
|
|
||||||
def create_admin_log_user() -> User:
|
def create_admin_log_user() -> User:
|
||||||
"""Create the admin user used for logging."""
|
"""Create the admin user used for logging."""
|
||||||
admin_user = UserFactory(id=1, email='admin@blender.studio', is_staff=True, is_superuser=True)
|
admin_user, _ = User.objects.update_or_create(
|
||||||
# Reset ID sequence to avoid clashing with an already used ID 1
|
id=1, defaults={'email': 'admin@blender.studio', 'is_staff': True, 'is_superuser': True}
|
||||||
UserFactory.reset_sequence(100, force=True)
|
)
|
||||||
return admin_user
|
return admin_user
|
||||||
|
Loading…
Reference in New Issue
Block a user