Stripe checkout #104411

Merged
Anna Sirota merged 61 commits from stripe into main 2024-06-17 18:08:41 +02:00
38 changed files with 232 additions and 358 deletions
Showing only changes of commit e27d353cd0 - Show all commits

View File

@ -52,3 +52,7 @@ MAILGUN_WEBHOOK_SECRET=
GOOGLE_RECAPTCHA_SITE_KEY= GOOGLE_RECAPTCHA_SITE_KEY=
GOOGLE_RECAPTCHA_SECRET_KEY= GOOGLE_RECAPTCHA_SECRET_KEY=
GOOGLE_ANALYTICS_TRACKING_ID= GOOGLE_ANALYTICS_TRACKING_ID=
STRIPE_API_PUBLISHABLE_KEY=
STRIPE_API_SECRET_KEY=
STRIPE_ENDPOINT_SECRET=

View File

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

61
poetry.lock generated
View File

@ -504,19 +504,19 @@ test = ["djangorestframework", "graphene-django", "pytest", "pytest-cov", "pytes
[[package]] [[package]]
name = "django-debug-toolbar" name = "django-debug-toolbar"
version = "2.2.1" version = "4.3.0"
description = "A configurable set of panels that display various debug information about the current request/response." description = "A configurable set of panels that display various debug information about the current request/response."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.8"
files = [ files = [
{file = "django-debug-toolbar-2.2.1.tar.gz", hash = "sha256:7aadab5240796ffe8e93cc7dfbe2f87a204054746ff7ff93cd6d4a0c3747c853"}, {file = "django_debug_toolbar-4.3.0-py3-none-any.whl", hash = "sha256:e09b7dcb8417b743234dfc57c95a7c1d1d87a88844abd13b4c5387f807b31bf6"},
{file = "django_debug_toolbar-2.2.1-py3-none-any.whl", hash = "sha256:7feaee934608f5cdd95432154be832fe30fda6c1249018191e2c27bc0b6a965e"}, {file = "django_debug_toolbar-4.3.0.tar.gz", hash = "sha256:0b0dddee5ea29b9cb678593bc0d7a6d76b21d7799cb68e091a2148341a80f3c4"},
] ]
[package.dependencies] [package.dependencies]
Django = ">=1.11" django = ">=3.2.4"
sqlparse = ">=0.2.0" sqlparse = ">=0.2"
[[package]] [[package]]
name = "django-loginas" name = "django-loginas"
@ -532,19 +532,22 @@ files = [
[[package]] [[package]]
name = "django-nested-admin" name = "django-nested-admin"
version = "3.4.0" version = "4.0.2"
description = "Django admin classes that allow for nested inlines" description = "Django admin classes that allow for nested inlines"
category = "main" category = "main"
optional = false optional = false
python-versions = "*" python-versions = ">=3.6"
files = [ files = [
{file = "django-nested-admin-3.4.0.tar.gz", hash = "sha256:fbcf20d75a73dcbcc6285793ff936eff8df4deba5b169e0c1ab765394c562805"}, {file = "django-nested-admin-4.0.2.tar.gz", hash = "sha256:79a5ce80b81c41a0f48f778fb556a3721029df48580bfce60a5962ffa2ef2d47"},
{file = "django_nested_admin-3.4.0-py2.py3-none-any.whl", hash = "sha256:c6852c5ac632f4e698b6beda455006fd464c852459e5e858a6db832cdb23d9e1"}, {file = "django_nested_admin-4.0.2-py3-none-any.whl", hash = "sha256:74d8fc0d0f17862ffcece66d39b8814cb649878b3b6ba8438076145a688ea473"},
] ]
[package.dependencies] [package.dependencies]
python-monkey-business = ">=1.0.0" python-monkey-business = ">=1.0.0"
six = "*"
[package.extras]
dev = ["Pillow", "black", "dj-database-url", "django-selenosis", "flake8", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "selenium"]
test = ["Pillow", "dj-database-url", "django-selenosis", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "selenium"]
[[package]] [[package]]
name = "django-pipeline" name = "django-pipeline"
@ -1297,7 +1300,7 @@ six = ">=1.11.0"
[[package]] [[package]]
name = "looper" name = "looper"
version = "2.1.2" version = "3.2.9"
description = "" description = ""
category = "main" category = "main"
optional = false optional = false
@ -1313,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 = "c5f54b309d0" reference = "56c6d4b"
resolved_reference = "c5f54b309d001912ef29ea5482864f97be8a2773" resolved_reference = "56c6d4b612a4b0cae032a6b5f9dad0ac4c3608ac"
[[package]] [[package]]
name = "lxml" name = "lxml"
@ -2674,6 +2679,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"
@ -2770,14 +2791,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]]
@ -2926,4 +2947,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 = "11610e2657ee40c139b3aa89ccc597309b77d5b1fa8dcc9358cbe06fb3ac5796" content-hash = "8ca93ee62a2b6e396676492b70085f90ca192e5a84d742f032e884c3ef691d41"

View File

@ -14,7 +14,7 @@ libsasscompiler = "^0.1.5"
jsmin = "3.0.0" jsmin = "3.0.0"
sorl-thumbnail = "^12.10.0" sorl-thumbnail = "^12.10.0"
mistune = "2.0.0a4" mistune = "2.0.0a4"
looper = {git = "https://projects.blender.org/infrastructure/looper.git", rev = "c5f54b309d0"} looper = {git = "https://projects.blender.org/infrastructure/looper.git", rev = "56c6d4b"}
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"
@ -32,7 +32,7 @@ requests-oauthlib = "^1.3.0"
django-activity-stream = "^0.9.0" django-activity-stream = "^0.9.0"
django-background-tasks-updated = {git = "https://projects.blender.org/infrastructure/django-background-tasks.git", rev ="2cbe547"} django-background-tasks-updated = {git = "https://projects.blender.org/infrastructure/django-background-tasks.git", rev ="2cbe547"}
django-anymail = {extras = ["mailgun"], version = "8.2"} django-anymail = {extras = ["mailgun"], version = "8.2"}
django-nested-admin = "^3.3.3" django-nested-admin = "^4.0.2"
html5lib = "1.1" html5lib = "1.1"
braintree = "4.17.1" braintree = "4.17.1"
python-stdnum = "^1.16" python-stdnum = "^1.16"
@ -57,7 +57,7 @@ django-stubs = "^1.5"
pre-commit = "2.16.0" pre-commit = "2.16.0"
ipython = "^7.17" ipython = "^7.17"
factory-boy = "^3.0" factory-boy = "^3.0"
django-debug-toolbar = "^2.2" django-debug-toolbar = "^4.2.0"
flake8 = "^3.8.3" flake8 = "^3.8.3"
flake8-docstrings = "^1.5.0" flake8-docstrings = "^1.5.0"
freezegun = "^1.0.0" freezegun = "^1.0.0"

View File

@ -506,6 +506,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'},
},
} }
# Optional Sentry configuration # Optional Sentry configuration
@ -678,5 +684,15 @@ S3DIRECT_DESTINATIONS = {
}, },
} }
# A list of payment method types used with stripe for setting a recurring payment:
# https://stripe.com/docs/api/checkout/sessions/create#create_checkout_session-payment_method_types
STRIPE_OFF_SESSION_PAYMENT_METHOD_TYPES = [
'card',
'link',
'paypal',
]
STRIPE_CHECKOUT_SUBMIT_TYPE = 'donate'
# Maximum number of attempts for failing background tasks # Maximum number of attempts for failing background tasks
MAX_ATTEMPTS = 3 MAX_ATTEMPTS = 3

View File

@ -5,13 +5,11 @@ 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 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,14 +46,6 @@ REQUIRED_FIELDS = {
class BillingAddressForm(forms.ModelForm): class BillingAddressForm(forms.ModelForm):
"""Unify Customer and Address in a single form."""
# Customer.billing_email is exposed as email in the Form
# because Looper scripts and forms already use "email" everywhere.
__customer_fields = {'billing_email': 'email', 'vat_number': 'vat_number'}
# Colliding "full_name" and "company" values are taken from and saved to the Address.
# FIXME(anna): do we need to use company and full_name on the Customer or only Address?
class Meta: class Meta:
model = looper.models.Address model = looper.models.Address
fields = looper.models.Address.PUBLIC_FIELDS fields = looper.models.Address.PUBLIC_FIELDS
@ -81,20 +71,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 +136,33 @@ 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 price = forms.CharField(widget=forms.HiddenInput(), required=True)
fields = looper.models.Address.PUBLIC_FIELDS
def __init__(self, *args, **kwargs): # These are used when a payment fails, so that the next attempt to pay can reuse
"""Disable all the billing details fields. # the already-created subscription and order.
subscription_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
order_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
The billing details are only for display and for use by the payment flow.
"""
super().__init__(*args, **kwargs)
for field_name, field in self.fields.items():
if field_name not in BILLING_DETAILS_PLACEHOLDERS:
continue
field.disabled = True
email = forms.EmailField(required=False, disabled=True) class AutomaticPaymentForm(PaymentForm):
pass
class SelectPlanVariationForm(forms.Form): class SelectPlanVariationForm(forms.Form):
@ -223,53 +189,13 @@ class SelectPlanVariationForm(forms.Form):
) )
class PaymentForm(BillingAddressForm): class PayExistingOrderForm(forms.Form): # TODO
"""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.""" """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) price = forms.CharField(widget=forms.HiddenInput(), required=True)
class ChangePaymentMethodForm(BillingAddressReadonlyForm, looper.forms.ChangePaymentMethodForm): class ChangePaymentMethodForm(forms.Form): # TODO
"""Add full billing address to the change payment form.""" """Add full billing address to the change payment form."""
pass pass

View File

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

View File

@ -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_id=user.id) | 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_id=user.id) | 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_id=user.id) | 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_id=user.id)).exists()

View File

@ -3,12 +3,13 @@ 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
import django.db.models.signals as django_signals import django.db.models.signals as django_signals
from looper.models import Customer, Order from looper.models import Order
import looper.admin_log import looper.admin_log
import looper.signals import looper.signals
@ -30,17 +31,40 @@ 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."""
from looper.models import Customer
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 my_log = logger.getChild('create_customer')
Customer.objects.create( try:
user_id=instance.pk, customer = instance.customer
billing_email=instance.email, except Customer.DoesNotExist:
full_name=instance.full_name, pass
) else:
my_log.debug(
'Newly created User %d already has a Customer %d, not creating new one',
instance.pk,
customer.pk,
)
billing_address = customer.billing_address
my_log.info('Creating new billing address due to creation of user %s', instance.pk)
if not billing_address.pk:
billing_address.email = instance.email
billing_address.save()
return
my_log.info('Creating new Customer due to creation of user %s', instance.pk)
with transaction.atomic():
customer = Customer.objects.create(user=instance)
billing_address = customer.billing_address
billing_address.email = instance.email
billing_address.save()
@receiver(django_signals.pre_save, sender=Order) @receiver(django_signals.pre_save, sender=Order)

View File

@ -45,7 +45,7 @@ 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 user = subscription.user
email = user.customer.billing_email or user.email email = user.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'
@ -90,7 +90,7 @@ 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 user = subscription.user
email = user.customer.billing_email or user.email email = user.customer.billing_address.email or user.email
assert email, f'Cannot send notification about subscription {subscription.pk} status: no email' assert email, f'Cannot send notification about subscription {subscription.pk} status: no email'
if is_noreply(email): if is_noreply(email):
raise raise
@ -135,7 +135,7 @@ def send_mail_automatic_payment_performed(order_id: int, transaction_id: int):
transaction = looper.models.Transaction.objects.get(pk=transaction_id) transaction = looper.models.Transaction.objects.get(pk=transaction_id)
user = order.user user = order.user
customer = user.customer customer = user.customer
email = customer.billing_email or user.email email = customer.billing_address.email or user.email
logger.debug('Sending %r notification to %s', order.status, email) 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.
@ -282,7 +282,7 @@ def send_mail_no_payment_method(order_id: int):
user = order.user user = order.user
customer = user.customer customer = user.customer
email = customer.billing_email or user.email email = customer.billing_address.email or user.email
logger.debug('Sending %r notification to %s', order.status, email) 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.

View File

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

View File

@ -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.customer.billing_address.full_name user.full_name user.email %},</p>
{% block content %}{% endblock content %} {% block content %}{% endblock content %}
<p>Manage subscription in your billing settings: <a href="{{ billing_url }}">{{ billing_url }}</a>.</p> <p>Manage subscription in your billing settings: <a href="{{ billing_url }}">{{ billing_url }}</a>.</p>
<p> <p>

View File

@ -1,4 +1,4 @@
Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %}, Dear {% firstof user.customer.billing_address.full_name user.customer.billing_address.full_name user.full_name user.email %},
{% block content %}{% endblock content %} {% block content %}{% endblock content %}
Manage subscription in your billing settings: {{ billing_url }}. Manage subscription in your billing settings: {{ billing_url }}.

View File

@ -1,7 +1,7 @@
{% extends "subscriptions/emails/base.html" %} {% extends "subscriptions/emails/base.html" %}
{% block body %} {% block body %}
<p> <p>
{{ user.customer.full_name|default:user.email }} has a {% include "subscriptions/components/info.html" %} that just passed its next payment date. {{ user.customer.billing_address.full_name|default:user.email }} has a {% include "subscriptions/components/info.html" %} that just passed its next payment date.
</p> </p>
<p> <p>

View File

@ -1,3 +1,3 @@
{{ user.customer.full_name|default:user.email }} has a {% include "subscriptions/components/info.txt" %} that just passed its next payment date. {{ user.customer.billing_address.full_name|default:user.email }} has a {% include "subscriptions/components/info.txt" %} that just passed its next payment date.
See {{ admin_url }} in the Blender Studio admin. See {{ admin_url }} in the Blender Studio admin.

View File

@ -7,7 +7,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.customer.billing_address.full_name user.full_name user.email %},</p>
<p> <p>
As you may have heard, Blender Studio's subscription system recently got a new shiny update, As you may have heard, Blender Studio's subscription system recently got a new shiny update,
more on that in <a href="https://studio.blender.org/blog/subscription-system-update-2021/">the blog post</a>. more on that in <a href="https://studio.blender.org/blog/subscription-system-update-2021/">the blog post</a>.

View File

@ -1,4 +1,4 @@
{% load subscriptions %}Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %}, {% load subscriptions %}Dear {% firstof user.customer.billing_address.full_name user.customer.billing_address.full_name user.full_name user.email %},
As you may have heard, Blender Studio's subscription system recently got a new shiny update, As you may have heard, Blender Studio's subscription system recently got a new shiny update,
more on that in the blog post https://studio.blender.org/blog/subscription-system-update-2021/ . more on that in the blog post https://studio.blender.org/blog/subscription-system-update-2021/ .

View File

@ -6,7 +6,7 @@
{% endblock header_logo %} {% endblock header_logo %}
{% block body %} {% block body %}
<p>Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},</p> <p>Dear {% firstof user.customer.billing_address.full_name user.customer.billing_address.full_name user.full_name user.email %},</p>
<p>Your Blender Studio subscription #{{subscription.pk}} has expired a while back. We miss you -- and you are missing some exciting content on Blender Studio as well.</p> <p>Your Blender Studio subscription #{{subscription.pk}} has expired a while back. We miss you -- and you are missing some exciting content on Blender Studio as well.</p>
{% if latest_posts or latest_trainings %} {% if latest_posts or latest_trainings %}
<p>Just recently, we've published:</p> <p>Just recently, we've published:</p>

View File

@ -1,4 +1,4 @@
Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %}, Dear {% firstof user.customer.billing_address.full_name user.customer.billing_address.full_name user.full_name user.email %},
Your Blender Studio subscription #{{subscription.pk}} has expired a while back. We miss you -- and you are missing some exciting content on Blender Studio as well.{% if latest_posts or latest_trainings %} Just recently, we've published: Your Blender Studio subscription #{{subscription.pk}} has expired a while back. We miss you -- and you are missing some exciting content on Blender Studio as well.{% if latest_posts or latest_trainings %} Just recently, we've published:
{% for post in latest_posts|slice:":2" %} {% for post in latest_posts|slice:":2" %}

View File

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

View File

@ -8,7 +8,7 @@ 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 users.tests.util as util import users.tests.util as util
User = get_user_model() User = get_user_model()
@ -33,7 +33,7 @@ class BaseSubscriptionTestCase(TestCase):
# 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,9 +43,9 @@ 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
def _mock_vies_response(self, is_valid=True, is_broken=False): def _mock_vies_response(self, is_valid=True, is_broken=False):
@ -319,7 +319,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 +341,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 +384,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 +397,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 +416,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
@ -444,7 +444,7 @@ class BaseSubscriptionTestCase(TestCase):
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 +454,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)
@ -474,7 +474,7 @@ class BaseSubscriptionTestCase(TestCase):
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 +482,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)
@ -501,7 +501,7 @@ class BaseSubscriptionTestCase(TestCase):
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 +509,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)
@ -533,7 +533,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(
@ -551,7 +551,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.subject, 'We miss you at Blender Studio') self.assertEqual(email.subject, 'We miss you at Blender Studio')
self.assertEqual(email.alternatives[0][1], 'text/html') self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]): for email_body in (email.body, email.alternatives[0][0]):
self.assertIn(f'Dear {user.customer.full_name}', email_body) self.assertIn(f'Dear {user.customer.billing_address.full_name}', email_body)
self.assertIn(f'#{subscription.pk}', email_body) self.assertIn(f'#{subscription.pk}', email_body)
self.assertIn('has expired', email_body) self.assertIn('has expired', email_body)
self.assertIn( self.assertIn(

View File

@ -11,11 +11,8 @@ 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
@ -31,7 +28,7 @@ class TestClock(BaseSubscriptionTestCase):
# print('fake now:', mock_now.return_value) # print('fake now:', mock_now.return_value)
subscription = SubscriptionFactory( subscription = SubscriptionFactory(
user=user, user=user,
payment_method__user_id=user.pk, payment_method__customer_id=user.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',

View File

@ -21,7 +21,6 @@ class TestBillingAddressForm(BaseSubscriptionTestCase):
def test_instance_loads_both_address_and_customer_data(self): def test_instance_loads_both_address_and_customer_data(self):
form = BillingAddressForm(instance=self.billing_address) form = BillingAddressForm(instance=self.billing_address)
# N.B.: email is loaded from Customer.billing_email
self.assertEqual(form['email'].value(), 'billing@example.com') self.assertEqual(form['email'].value(), 'billing@example.com')
self.assertEqual(form['company'].value(), 'Testcompany B.V.') self.assertEqual(form['company'].value(), 'Testcompany B.V.')
self.assertEqual(form['country'].value(), 'NL') self.assertEqual(form['country'].value(), 'NL')
@ -223,7 +222,6 @@ class TestPaymentForm(BaseSubscriptionTestCase):
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')

View File

@ -1,7 +1,9 @@
from django.test import TestCase from django.test import TestCase
from looper.tests.factories import SubscriptionFactory
from common.tests.factories.users import UserFactory from common.tests.factories.users import UserFactory
from common.tests.factories.subscriptions import SubscriptionFactory, TeamFactory from common.tests.factories.subscriptions import TeamFactory
import looper.models import looper.models

View File

@ -57,7 +57,7 @@ urlpatterns = [
), ),
path( path(
'settings/billing-address/', 'settings/billing-address/',
settings.BillingAddressView.as_view(), looper_settings.BillingAddressView.as_view(),
name='billing-address', name='billing-address',
), ),
path('settings/receipts/', looper_settings.settings_receipts, name='receipts'), path('settings/receipts/', looper_settings.settings_receipts, name='receipts'),

View File

@ -10,7 +10,7 @@ from django.shortcuts import redirect, get_object_or_404
from django.views.generic import FormView from django.views.generic import FormView
from looper.middleware import COUNTRY_CODE_SESSION_KEY from looper.middleware import COUNTRY_CODE_SESSION_KEY
from looper.views.checkout import AbstractPaymentView, CheckoutView from looper.views.checkout_braintree import AbstractPaymentView, CheckoutView
import looper.gateways import looper.gateways
import looper.middleware import looper.middleware
import looper.models import looper.models
@ -45,7 +45,7 @@ class _JoinMixin:
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()
@ -213,7 +213,6 @@ class ConfirmAndPayView(_JoinMixin, LoginRequiredMixin, FormView):
ctx = { ctx = {
**super().get_context_data(**kwargs), **super().get_context_data(**kwargs),
'current_plan_variation': self.plan_variation, 'current_plan_variation': self.plan_variation,
'client_token': self.get_client_token(currency) if self.customer else None,
'subscription': self.subscription, 'subscription': self.subscription,
} }
return ctx return ctx

View File

@ -35,7 +35,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."""
@ -51,7 +53,7 @@ class SingleSubscriptionMixin(LoginRequiredMixin):
The AnonymousUser instance doesn't have a 'subscriptions' property, The AnonymousUser instance doesn't have a 'subscriptions' property,
but login checking only happens in the super().dispatch() call. but login checking only happens in the super().dispatch() call.
""" """
if not hasattr(request.user, 'subscription_set'): if not hasattr(request.user.customer, 'subscription_set'):
return self.handle_no_permission() return self.handle_no_permission()
response = self.pre_dispatch(request, *args, **kwargs) response = self.pre_dispatch(request, *args, **kwargs)
if response: if response:

View File

@ -5,7 +5,7 @@ import logging
from django.shortcuts import redirect from django.shortcuts import redirect
from django.views.generic import FormView from django.views.generic import FormView
from looper.views.checkout import AbstractPaymentView from looper.views.checkout_braintree import AbstractPaymentView
import looper.gateways import looper.gateways
import looper.middleware import looper.middleware
import looper.models import looper.models

View File

@ -1,8 +1,6 @@
"""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.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
@ -10,10 +8,11 @@ from django.urls import reverse_lazy, reverse
from django.views.generic import UpdateView, FormView from django.views.generic import UpdateView, FormView
import looper.models import looper.models
import looper.views.checkout_braintree
import looper.views.settings import looper.views.settings
import looper.views.settings_braintree
from subscriptions.forms import ( from subscriptions.forms import (
BillingAddressForm,
CancelSubscriptionForm, CancelSubscriptionForm,
ChangePaymentMethodForm, ChangePaymentMethodForm,
PayExistingOrderForm, PayExistingOrderForm,
@ -26,25 +25,6 @@ logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
class BillingAddressView(LoginRequiredMixin, UpdateView):
"""Combine looper's Customer and Address into a billing address."""
template_name = 'settings/billing_address.html'
model = looper.models.Address
form_class = BillingAddressForm
success_url = reverse_lazy('subscriptions:billing-address')
def _get_customer_object(self) -> Optional[looper.models.Customer]:
if self.request.user.is_anonymous:
return None
return self.request.user.customer
def get_object(self, queryset=None) -> Optional[looper.models.Address]:
"""Get billing address."""
customer = self._get_customer_object()
return customer.billing_address if customer else None
class CancelSubscriptionView(SingleSubscriptionMixin, FormView): class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
"""Confirm and cancel a subscription.""" """Confirm and cancel a subscription."""
@ -67,7 +47,7 @@ class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
return super().form_valid(form) return super().form_valid(form)
class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView): class PaymentMethodChangeView(looper.views.settings_braintree.PaymentMethodChangeView):
"""Use the Braintree drop-in UI to switch a subscription's payment method.""" """Use the Braintree drop-in UI to switch a subscription's payment method."""
template_name = 'subscriptions/payment_method_change.html' template_name = 'subscriptions/payment_method_change.html'
@ -98,7 +78,7 @@ class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView):
return super().form_invalid(form) return super().form_invalid(form)
class PayExistingOrderView(looper.views.checkout.CheckoutExistingOrderView): class PayExistingOrderView(looper.views.checkout_braintree.CheckoutExistingOrderView):
"""Override looper's view with our forms.""" """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
@ -111,7 +91,7 @@ class PayExistingOrderView(looper.views.checkout.CheckoutExistingOrderView):
"""Prefill the payment amount and missing form data, if any.""" """Prefill the payment amount and missing form data, if any."""
initial = { initial = {
'price': self.order.price.decimals_string, 'price': self.order.price.decimals_string,
'email': self.customer.billing_email, 'email': self.customer.billing_address.email,
} }
# Only set initial values if they aren't already saved to the billing address. # Only set initial values if they aren't already saved to the billing address.
@ -137,7 +117,7 @@ class PayExistingOrderView(looper.views.checkout.CheckoutExistingOrderView):
if request.user.is_authenticated and self.order.user_id != request.user.id: if request.user.is_authenticated and self.order.user_id != request.user.id:
return HttpResponseForbidden() return HttpResponseForbidden()
self.plan = self.order.subscription.plan self.plan = self.order.subscription.plan
return super(looper.views.checkout.CheckoutExistingOrderView, self).dispatch( return super(looper.views.checkout_braintree.CheckoutExistingOrderView, self).dispatch(
request, *args, **kwargs request, *args, **kwargs
) )

View File

@ -2,7 +2,7 @@
from collections import defaultdict from collections import defaultdict
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from looper.views.checkout import AbstractPaymentView from looper.views.checkout_braintree import AbstractPaymentView
import looper.models import looper.models
import subscriptions.models import subscriptions.models

View File

@ -12,7 +12,7 @@ from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4, SINGAPORE_
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
from subscriptions.tests.base import BaseSubscriptionTestCase from subscriptions.tests.base import BaseSubscriptionTestCase
import subscriptions.tasks import subscriptions.tasks
@ -445,7 +445,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
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))
@ -524,7 +524,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
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))
@ -607,7 +607,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self._assert_done_page_displayed(response) self._assert_done_page_displayed(response)
subscription = user.subscription_set.first() subscription = user.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', 990)) self.assertEqual(subscription.price, Money('EUR', 990))
@ -655,7 +655,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self._assert_done_page_displayed(response) self._assert_done_page_displayed(response)
subscription = user.subscription_set.first() subscription = user.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))
@ -693,7 +693,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self._assert_done_page_displayed(response) self._assert_done_page_displayed(response)
subscription = user.subscription_set.first() subscription = user.customer.subscription_set.first()
self.assertEqual(subscription.status, 'active') self.assertEqual(subscription.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))

View File

@ -10,11 +10,9 @@ from freezegun import freeze_time
from looper.tests.factories import PaymentMethodFactory, OrderFactory from looper.tests.factories import PaymentMethodFactory, OrderFactory
import looper.taxes import looper.taxes
from common.tests.factories.subscriptions import ( from common.tests.factories.subscriptions import TeamFactory
TeamFactory,
create_customer_with_billing_address,
)
from common.tests.factories.users import UserFactory from common.tests.factories.users import UserFactory
from looper.tests.factories import create_customer_with_billing_address
expected_text_tmpl = '''Invoice expected_text_tmpl = '''Invoice
Blender Studio B.V. Blender Studio B.V.

View File

@ -7,7 +7,7 @@ from freezegun import freeze_time
from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4 # , SINGAPORE_IPV4 from looper.tests.test_preferred_currency import EURO_IPV4, USA_IPV4 # , SINGAPORE_IPV4
from common.tests.factories.subscriptions import create_customer_with_billing_address from looper.tests.factories import create_customer_with_billing_address
from subscriptions.tests.base import BaseSubscriptionTestCase from subscriptions.tests.base import BaseSubscriptionTestCase
# **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database. # **N.B.**: test cases below require settings.GEOIP2_DB to point to an existing GeoLite2 database.

View File

@ -4,9 +4,9 @@ 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
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 from subscriptions.tests.base import BaseSubscriptionTestCase
import subscriptions.tasks import subscriptions.tasks
@ -31,7 +31,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
user = UserFactory() user = UserFactory()
self.client.force_login(user) self.client.force_login(user)
url = reverse('subscriptions:billing-address') url = reverse('user-settings-billing')
response = self.client.post(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
@ -51,15 +51,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(reverse('user-settings-billing'), {})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist') self.assertContains(response, 'errorlist')
@ -72,7 +71,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(reverse('user-settings-billing'), data)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist') self.assertContains(response, 'errorlist')
@ -85,7 +84,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(reverse('user-settings-billing'), data)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist') self.assertContains(response, 'errorlist')
@ -103,8 +102,8 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
def test_can_change_payment_method_from_bank_to_credit_card_with_sca(self): 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)
@ -139,8 +138,8 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
def test_can_change_payment_method_from_credit_card_to_bank(self): def test_can_change_payment_method_from_credit_card_to_bank(self):
braintree = Gateway.objects.get(name='braintree') braintree = Gateway.objects.get(name='braintree')
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=braintree, payment_method__gateway=braintree,
) )
self.assertEqual(PaymentMethod.objects.count(), 1) self.assertEqual(PaymentMethod.objects.count(), 1)
@ -173,8 +172,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 +197,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 +217,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',
) )
@ -244,8 +243,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
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',
) )
@ -264,8 +263,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
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',
) )
@ -284,8 +283,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
def test_invalid_missing_required_form_data(self): def test_invalid_missing_required_form_data(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',
) )
@ -315,8 +314,8 @@ 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, 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'),
currency='USD', currency='USD',
price=Money('USD', 1110), price=Money('USD', 1110),

View File

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

View File

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

View File

@ -4,7 +4,7 @@
{% block settings %} {% block settings %}
<p class="text-muted">Settings</p> <p class="text-muted">Settings</p>
<h1 class="mb-3">Subscription{% if user.subscription_set.count > 1 %}s{% endif %}</h1> <h1 class="mb-3">Subscription{% if user.customer.subscription_set.count > 1 %}s{% endif %}</h1>
<p class="mb-2 text-muted">If you have any problems with billing, contact the team directly on <a href="mailto:{{ ADMIN_MAIL }}">{{ ADMIN_MAIL }}</a>.</p> <p class="mb-2 text-muted">If you have any problems with billing, contact the team directly on <a href="mailto:{{ ADMIN_MAIL }}">{{ ADMIN_MAIL }}</a>.</p>
<div> <div>
{% if user|has_group:"demo" %} {% if user|has_group:"demo" %}

View File

@ -5,14 +5,15 @@ 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 comments.queries import set_comment_like
from common.tests.factories.comments import CommentFactory
from common.tests.factories.subscriptions import TeamFactory
from common.tests.factories.users import UserFactory from common.tests.factories.users import UserFactory
import users.tasks as tasks import users.tasks as tasks
import users.tests.util as util import users.tests.util as util