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

61
poetry.lock generated
View File

@ -504,19 +504,19 @@ test = ["djangorestframework", "graphene-django", "pytest", "pytest-cov", "pytes
[[package]]
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."
category = "dev"
optional = false
python-versions = ">=3.5"
python-versions = ">=3.8"
files = [
{file = "django-debug-toolbar-2.2.1.tar.gz", hash = "sha256:7aadab5240796ffe8e93cc7dfbe2f87a204054746ff7ff93cd6d4a0c3747c853"},
{file = "django_debug_toolbar-2.2.1-py3-none-any.whl", hash = "sha256:7feaee934608f5cdd95432154be832fe30fda6c1249018191e2c27bc0b6a965e"},
{file = "django_debug_toolbar-4.3.0-py3-none-any.whl", hash = "sha256:e09b7dcb8417b743234dfc57c95a7c1d1d87a88844abd13b4c5387f807b31bf6"},
{file = "django_debug_toolbar-4.3.0.tar.gz", hash = "sha256:0b0dddee5ea29b9cb678593bc0d7a6d76b21d7799cb68e091a2148341a80f3c4"},
]
[package.dependencies]
Django = ">=1.11"
sqlparse = ">=0.2.0"
django = ">=3.2.4"
sqlparse = ">=0.2"
[[package]]
name = "django-loginas"
@ -532,19 +532,22 @@ files = [
[[package]]
name = "django-nested-admin"
version = "3.4.0"
version = "4.0.2"
description = "Django admin classes that allow for nested inlines"
category = "main"
optional = false
python-versions = "*"
python-versions = ">=3.6"
files = [
{file = "django-nested-admin-3.4.0.tar.gz", hash = "sha256:fbcf20d75a73dcbcc6285793ff936eff8df4deba5b169e0c1ab765394c562805"},
{file = "django_nested_admin-3.4.0-py2.py3-none-any.whl", hash = "sha256:c6852c5ac632f4e698b6beda455006fd464c852459e5e858a6db832cdb23d9e1"},
{file = "django-nested-admin-4.0.2.tar.gz", hash = "sha256:79a5ce80b81c41a0f48f778fb556a3721029df48580bfce60a5962ffa2ef2d47"},
{file = "django_nested_admin-4.0.2-py3-none-any.whl", hash = "sha256:74d8fc0d0f17862ffcece66d39b8814cb649878b3b6ba8438076145a688ea473"},
]
[package.dependencies]
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]]
name = "django-pipeline"
@ -1297,7 +1300,7 @@ six = ">=1.11.0"
[[package]]
name = "looper"
version = "2.1.2"
version = "3.2.9"
description = ""
category = "main"
optional = false
@ -1313,19 +1316,21 @@ braintree = "4.17.1"
colorhash = "^1.0.3"
django = "^2.2.0 || 3.0 || 3.0.* || 3.2.*"
django-countries = "^7.2.1"
django-nested-admin = "^4.0.2"
django-pipeline = "^2.0.6"
geoip2 = "^3.0"
python-dateutil = "^2.7"
python-stdnum = "^1.16"
requests = "^2.22"
stripe = "7.1.0"
xhtml2pdf = "^0.2"
zeep = "4.0.0"
[package.source]
type = "git"
url = "https://projects.blender.org/infrastructure/looper.git"
reference = "c5f54b309d0"
resolved_reference = "c5f54b309d001912ef29ea5482864f97be8a2773"
reference = "56c6d4b"
resolved_reference = "56c6d4b612a4b0cae032a6b5f9dad0ac4c3608ac"
[[package]]
name = "lxml"
@ -2674,6 +2679,22 @@ files = [
{file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"},
]
[[package]]
name = "stripe"
version = "7.1.0"
description = "Python bindings for the Stripe API"
category = "main"
optional = false
python-versions = ">=3.6"
files = [
{file = "stripe-7.1.0-py2.py3-none-any.whl", hash = "sha256:efd1e54825752c41bb311497cb5b6ae745464a57ca63bbe2847984a2409bcb0a"},
{file = "stripe-7.1.0.tar.gz", hash = "sha256:9cc2632230d5742eeb779af2b41c1510e724f498a296dfb40507de98d563f9a2"},
]
[package.dependencies]
requests = {version = ">=2.20", markers = "python_version >= \"3.0\""}
typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""}
[[package]]
name = "tblib"
version = "3.0.0"
@ -2770,14 +2791,14 @@ files = [
[[package]]
name = "typing-extensions"
version = "4.4.0"
description = "Backported and Experimental Type Hints for Python 3.7+"
version = "4.12.1"
description = "Backported and Experimental Type Hints for Python 3.8+"
category = "main"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"},
{file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"},
{file = "typing_extensions-4.12.1-py3-none-any.whl", hash = "sha256:6024b58b69089e5a89c347397254e35f1bf02a907728ec7fee9bf0fe837d203a"},
{file = "typing_extensions-4.12.1.tar.gz", hash = "sha256:915f5e35ff76f56588223f15fdd5938f9a1cf9195c0de25130c627e4d597f6d1"},
]
[[package]]
@ -2926,4 +2947,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata]
lock-version = "2.0"
python-versions = "^3.8"
content-hash = "11610e2657ee40c139b3aa89ccc597309b77d5b1fa8dcc9358cbe06fb3ac5796"
content-hash = "8ca93ee62a2b6e396676492b70085f90ca192e5a84d742f032e884c3ef691d41"

View File

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

View File

@ -506,6 +506,12 @@ GATEWAYS = {
'supported_collection_methods': {'automatic', 'manual'},
},
'bank': {'supported_collection_methods': {'manual'}},
'stripe': {
'api_publishable_key': _get('STRIPE_API_PUBLISHABLE_KEY'),
'api_secret_key': _get('STRIPE_API_SECRET_KEY'),
'endpoint_secret': _get('STRIPE_ENDPOINT_SECRET'),
'supported_collection_methods': {'automatic'},
},
}
# 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
MAX_ATTEMPTS = 3

View File

@ -5,13 +5,11 @@ import logging
from django import forms
from django.core.exceptions import ValidationError
from django.forms.fields import Field
from django.forms.models import model_to_dict
from localflavor.administrative_areas import ADMINISTRATIVE_AREAS
from localflavor.generic.validators import validate_country_postcode
from stdnum.eu import vat
import localflavor.exceptions
import looper.form_fields
import looper.forms
import looper.models
@ -48,14 +46,6 @@ REQUIRED_FIELDS = {
class BillingAddressForm(forms.ModelForm):
"""Unify Customer and Address in a single form."""
# Customer.billing_email is exposed as email in the Form
# because Looper scripts and forms already use "email" everywhere.
__customer_fields = {'billing_email': 'email', 'vat_number': 'vat_number'}
# Colliding "full_name" and "company" values are taken from and saved to the Address.
# FIXME(anna): do we need to use company and full_name on the Customer or only Address?
class Meta:
model = looper.models.Address
fields = looper.models.Address.PUBLIC_FIELDS
@ -81,20 +71,6 @@ class BillingAddressForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
"""Load additional model data from Customer and set form placeholders."""
instance: looper.models.Address = kwargs.get('instance')
if instance:
assert isinstance(instance, looper.models.Address), 'Must be an instance of Address'
customer = instance.user.customer
initial = kwargs.get('initial') or {}
customer_data = model_to_dict(customer, self.__customer_fields.keys(), {})
# Remap the fields, e.g. turning "billing_email" into "email"
customer_form_data = {v: customer_data[k] for k, v in self.__customer_fields.items()}
# Add Customer data into initial,
# making sure that it still overrides the instance data, as it's supposed to
kwargs['initial'] = {
**customer_form_data,
**initial,
}
super().__init__(*args, **kwargs)
# Set placeholder values on all form fields
@ -160,43 +136,33 @@ class BillingAddressForm(forms.ModelForm):
)
def save(self, commit=True):
"""Save Customer data as well."""
"""Save cleared region field."""
# Validation against region choices is already done, because choices are set on __init__,
# however Django won't set the updated blank region value if was omitted from the form.
if self.cleaned_data['region'] == '':
self.instance.region = ''
instance = super().save(commit=commit)
customer = instance.user.customer
for model_field, form_field in self.__customer_fields.items():
setattr(customer, model_field, self.cleaned_data[form_field])
if commit:
customer.save(update_fields=self.__customer_fields)
return instance
class BillingAddressReadonlyForm(forms.ModelForm):
"""Display the billing details in a payment form but neither validate nor update them.
class PaymentForm(BillingAddressForm):
"""Handle PlanVariation ID and payment method details in the second step of the checkout.
Used in PaymentMethodChangeView and PayExistingOrderView.
Billing details are displayed as read-only and cannot be edited,
but are still used by the payment flow.
"""
class Meta:
model = looper.models.Address
fields = looper.models.Address.PUBLIC_FIELDS
gateway = looper.form_fields.GatewayChoiceField()
price = forms.CharField(widget=forms.HiddenInput(), required=True)
def __init__(self, *args, **kwargs):
"""Disable all the billing details fields.
# 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)
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):
@ -223,53 +189,13 @@ 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):
class PayExistingOrderForm(forms.Form): # TODO
"""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):
class ChangePaymentMethodForm(forms.Form): # TODO
"""Add full billing address to the change payment form."""
pass

View File

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

View File

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

View File

@ -3,12 +3,13 @@ from typing import Set
import logging
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q
from django.dispatch import receiver
import alphabetic_timestamp as ats
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.signals
@ -30,17 +31,40 @@ def timebased_order_number():
@receiver(django_signals.post_save, sender=User)
def create_customer(sender, instance: User, created, **kwargs):
def create_customer(sender, instance: User, created, raw, **kwargs):
"""Create Customer on User creation."""
from looper.models import Customer
if raw:
return
if not created:
return
logger.debug("Creating Customer for user %i" % instance.id)
# Assume billing name and email are the same, they should be able to change them later
Customer.objects.create(
user_id=instance.pk,
billing_email=instance.email,
full_name=instance.full_name,
)
my_log = logger.getChild('create_customer')
try:
customer = instance.customer
except Customer.DoesNotExist:
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)

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."""
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
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 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."""
subscription = looper.models.Subscription.objects.get(pk=subscription_id)
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'
if is_noreply(email):
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)
user = order.user
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)
# 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
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)
# An Unsubscribe record will prevent this message from being delivered by Mailgun.

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,3 @@
{{ user.customer.full_name|default:user.email }} has a {% include "subscriptions/components/info.txt" %} that just passed its next payment date.
{{ 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.

View File

@ -7,7 +7,7 @@
{% endblock header_logo %}
{% block body %}
<p>Dear {% firstof user.customer.full_name user.customer.billing_address.full_name user.full_name user.email %},</p>
<p>Dear {% firstof user.customer.billing_address.full_name user.customer.billing_address.full_name user.full_name user.email %},</p>
<p>
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>.

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,
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 %}
{% 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>
{% if latest_posts or latest_trainings %}
<p>Just recently, we've published:</p>

View File

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

View File

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

View File

@ -8,7 +8,7 @@ from django.urls import reverse
import factory
import responses
from common.tests.factories.subscriptions import create_customer_with_billing_address
from looper.tests.factories import create_customer_with_billing_address
import users.tests.util as util
User = get_user_model()
@ -33,7 +33,7 @@ class BaseSubscriptionTestCase(TestCase):
# Create the admin user used for logging
self.admin_user = util.create_admin_log_user()
self.user = create_customer_with_billing_address(
self.customer = create_customer_with_billing_address(
full_name='Алексей Н.',
company='Testcompany B.V.',
street_address='Billing street 1',
@ -43,9 +43,9 @@ class BaseSubscriptionTestCase(TestCase):
region='North Holland',
country='NL',
vat_number='NL-KVK-41202535',
billing_email='billing@example.com',
email='billing@example.com',
)
self.customer = self.user.customer
self.user = self.customer.user
self.billing_address = self.customer.billing_address
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, 'on hold')
self.assertContains(response, 'NL07 INGB 0008 4489 82')
subscription = response.wsgi_request.user.subscription_set.first()
subscription = response.wsgi_request.user.customer.subscription_set.first()
self.assertContains(
response, f'Blender Studio order-{subscription.latest_order().display_number}'
)
@ -341,11 +341,11 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(len(mail.outbox), 0)
def _assert_bank_transfer_email_is_sent(self, subscription):
user = subscription.user
customer = subscription.customer
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [user.customer.billing_email])
self.assertEqual(email.to, [customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -384,11 +384,11 @@ class BaseSubscriptionTestCase(TestCase):
self.assertIn('Recurring total: €\xa026.45', email_body.replace(' ', ' '))
def _assert_subscription_activated_email_is_sent(self, subscription):
user = subscription.user
customer = subscription.customer
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [user.customer.billing_email])
self.assertEqual(email.to, [customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -397,17 +397,17 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn('activated', email_body)
self.assertIn(f'Dear {user.customer.full_name},', email_body)
self.assertIn(f'Dear {customer.billing_address.full_name},', email_body)
self.assertIn(reverse('user-settings-billing'), email_body)
self.assertIn('Automatic renewal subscription', email_body)
self.assertIn('Blender Studio Team', email_body)
def _assert_team_subscription_activated_email_is_sent(self, subscription):
user = subscription.user
customer = subscription.customer
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [user.customer.billing_email])
self.assertEqual(email.to, [customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -416,17 +416,17 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn('activated', email_body)
self.assertIn(f'Dear {user.customer.full_name},', email_body)
self.assertIn(f'Dear {customer.billing_address.full_name},', email_body)
self.assertIn(reverse('user-settings-billing'), email_body)
self.assertIn('Automatic renewal, 15 seats subscription', email_body)
self.assertIn('Blender Studio Team', email_body)
def _assert_subscription_deactivated_email_is_sent(self, subscription):
user = subscription.user
customer = subscription.customer
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [user.customer.billing_email])
self.assertEqual(email.to, [customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -444,7 +444,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [user.customer.billing_email])
self.assertEqual(email.to, [user.customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -454,7 +454,7 @@ class BaseSubscriptionTestCase(TestCase):
)
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn(f'Dear {user.customer.full_name},', email_body)
self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
self.assertIn('Automatic payment', email_body)
self.assertIn('failed', email_body)
self.assertIn('try again', email_body)
@ -474,7 +474,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [user.customer.billing_email])
self.assertEqual(email.to, [user.customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -482,7 +482,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.subject, 'Blender Studio Subscription: payment failed')
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn(f'Dear {user.customer.full_name},', email_body)
self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
self.assertIn('Automatic payment', email_body)
self.assertIn('failed', email_body)
self.assertIn('3 times', email_body)
@ -501,7 +501,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(len(mail.outbox), 1)
_write_mail(mail)
email = mail.outbox[0]
self.assertEqual(email.to, [user.customer.billing_email])
self.assertEqual(email.to, [user.customer.billing_address.email])
# TODO(anna): set the correct reply_to
self.assertEqual(email.reply_to, [])
# TODO(anna): set the correct from_email DEFAULT_FROM_EMAIL
@ -509,7 +509,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.subject, 'Blender Studio Subscription: payment received')
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn(f'Dear {user.customer.full_name},', email_body)
self.assertIn(f'Dear {user.customer.billing_address.full_name},', email_body)
self.assertIn('Automatic monthly payment', email_body)
self.assertIn('successful', email_body)
self.assertIn('$\xa011.10', email_body)
@ -533,7 +533,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.subject, 'Blender Studio managed subscription needs attention')
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn(f'{user.customer.full_name} has', email_body)
self.assertIn(f'{user.customer.billing_address.full_name} has', email_body)
self.assertIn('its next payment date', email_body)
self.assertIn('$\xa011.10', email_body)
self.assertIn(
@ -551,7 +551,7 @@ class BaseSubscriptionTestCase(TestCase):
self.assertEqual(email.subject, 'We miss you at Blender Studio')
self.assertEqual(email.alternatives[0][1], 'text/html')
for email_body in (email.body, email.alternatives[0][0]):
self.assertIn(f'Dear {user.customer.full_name}', email_body)
self.assertIn(f'Dear {user.customer.billing_address.full_name}', email_body)
self.assertIn(f'#{subscription.pk}', email_body)
self.assertIn('has expired', email_body)
self.assertIn(

View File

@ -11,11 +11,8 @@ from looper import admin_log
from looper.clock import Clock
from looper.models import Gateway, Subscription
from looper.money import Money
from looper.tests.factories import SubscriptionFactory, create_customer_with_billing_address
from common.tests.factories.subscriptions import (
SubscriptionFactory,
create_customer_with_billing_address,
)
from subscriptions.tests.base import BaseSubscriptionTestCase
import subscriptions.tasks
import users.tasks
@ -31,7 +28,7 @@ class TestClock(BaseSubscriptionTestCase):
# print('fake now:', mock_now.return_value)
subscription = SubscriptionFactory(
user=user,
payment_method__user_id=user.pk,
payment_method__customer_id=user.customer.pk,
payment_method__recognisable_name='Test payment method',
payment_method__gateway=Gateway.objects.get(name='braintree'),
currency='USD',

View File

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

View File

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

View File

@ -57,7 +57,7 @@ urlpatterns = [
),
path(
'settings/billing-address/',
settings.BillingAddressView.as_view(),
looper_settings.BillingAddressView.as_view(),
name='billing-address',
),
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 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.middleware
import looper.models
@ -45,7 +45,7 @@ class _JoinMixin:
def _get_existing_subscription(self):
# Exclude cancelled subscriptions because they cannot transition to active
existing_subscriptions = self.request.user.subscription_set.exclude(
existing_subscriptions = self.request.user.customer.subscription_set.exclude(
status__in=looper.models.Subscription._CANCELLED_STATUSES
)
return existing_subscriptions.first()
@ -213,7 +213,6 @@ class ConfirmAndPayView(_JoinMixin, LoginRequiredMixin, FormView):
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

View File

@ -35,7 +35,9 @@ class SingleSubscriptionMixin(LoginRequiredMixin):
def get_subscription(self) -> Subscription:
"""Retrieve Subscription object."""
return get_object_or_404(self.request.user.subscription_set, pk=self.subscription_id)
return get_object_or_404(
self.request.user.customer.subscription_set, pk=self.subscription_id
)
def get_context_data(self, **kwargs) -> dict:
"""Add Subscription to the template context."""
@ -51,7 +53,7 @@ class SingleSubscriptionMixin(LoginRequiredMixin):
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'):
if not hasattr(request.user.customer, 'subscription_set'):
return self.handle_no_permission()
response = self.pre_dispatch(request, *args, **kwargs)
if response:

View File

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

View File

@ -1,8 +1,6 @@
"""Views handling subscription management."""
from typing import Optional
import logging
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404
@ -10,10 +8,11 @@ from django.urls import reverse_lazy, reverse
from django.views.generic import UpdateView, FormView
import looper.models
import looper.views.checkout_braintree
import looper.views.settings
import looper.views.settings_braintree
from subscriptions.forms import (
BillingAddressForm,
CancelSubscriptionForm,
ChangePaymentMethodForm,
PayExistingOrderForm,
@ -26,25 +25,6 @@ logger = logging.getLogger(__name__)
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):
"""Confirm and cancel a subscription."""
@ -67,7 +47,7 @@ class CancelSubscriptionView(SingleSubscriptionMixin, FormView):
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."""
template_name = 'subscriptions/payment_method_change.html'
@ -98,7 +78,7 @@ class PaymentMethodChangeView(looper.views.settings.PaymentMethodChangeView):
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."""
# 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."""
initial = {
'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.
@ -137,7 +117,7 @@ class PayExistingOrderView(looper.views.checkout.CheckoutExistingOrderView):
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(
return super(looper.views.checkout_braintree.CheckoutExistingOrderView, self).dispatch(
request, *args, **kwargs
)

View File

@ -2,7 +2,7 @@
from collections import defaultdict
from django.views.generic.base import TemplateView
from looper.views.checkout import AbstractPaymentView
from looper.views.checkout_braintree import AbstractPaymentView
import looper.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
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 subscriptions.tests.base import BaseSubscriptionTestCase
import subscriptions.tasks
@ -445,7 +445,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self._assert_transactionless_done_page_displayed(response)
subscription = user.subscription_set.first()
subscription = user.customer.subscription_set.first()
self.assertEqual(subscription.status, 'on-hold')
self.assertEqual(subscription.price, Money('EUR', 1490))
self.assertEqual(subscription.tax, Money('EUR', 259))
@ -524,7 +524,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self._assert_transactionless_done_page_displayed(response)
subscription = user.subscription_set.first()
subscription = user.customer.subscription_set.first()
self.assertEqual(subscription.status, 'on-hold')
self.assertEqual(subscription.price, Money('EUR', 3200))
self.assertEqual(subscription.tax, Money('EUR', 0))
@ -607,7 +607,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self._assert_done_page_displayed(response)
subscription = user.subscription_set.first()
subscription = user.customer.subscription_set.first()
order = subscription.latest_order()
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.price, Money('EUR', 990))
@ -655,7 +655,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
self._assert_done_page_displayed(response)
subscription = user.subscription_set.first()
subscription = user.customer.subscription_set.first()
order = subscription.latest_order()
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.price, Money('EUR', 9000))
@ -693,7 +693,7 @@ class TestPOSTConfirmAndPayView(BaseSubscriptionTestCase):
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.price, Money('EUR', 1490))
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
import looper.taxes
from common.tests.factories.subscriptions import (
TeamFactory,
create_customer_with_billing_address,
)
from common.tests.factories.subscriptions import TeamFactory
from common.tests.factories.users import UserFactory
from looper.tests.factories import create_customer_with_billing_address
expected_text_tmpl = '''Invoice
Blender Studio B.V.

View File

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

View File

@ -4,9 +4,9 @@ from django.urls import reverse
from looper.models import PaymentMethod, PaymentMethodAuthentication, Gateway
from looper.money import Money
from looper.tests.factories import SubscriptionFactory
from common.tests.factories.users import UserFactory
from common.tests.factories.subscriptions import SubscriptionFactory
from subscriptions.tests.base import BaseSubscriptionTestCase
import subscriptions.tasks
@ -31,7 +31,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
user = UserFactory()
self.client.force_login(user)
url = reverse('subscriptions:billing-address')
url = reverse('user-settings-billing')
response = self.client.post(url, full_billing_address_data)
# Check that the redirect on success happened
@ -51,15 +51,14 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
self.assertEqual(address.company, 'Test LLC')
# Check that customer fields were updated as well
self.assertEqual(customer.vat_number, 'NL818152011B01')
# N.B.: email is saved as Customer.billing_email
self.assertEqual(customer.billing_email, 'my.billing.email@example.com')
self.assertEqual(customer.billing_address.vat_number, 'NL818152011B01')
self.assertEqual(customer.billing_address.email, 'my.billing.email@example.com')
def test_invalid_missing_required_fields(self):
user = UserFactory()
self.client.force_login(user)
response = self.client.post(reverse('subscriptions:billing-address'), {})
response = self.client.post(reverse('user-settings-billing'), {})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'errorlist')
@ -72,7 +71,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
data = {
'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.assertContains(response, 'errorlist')
@ -85,7 +84,7 @@ class TestSubscriptionSettingsBillingAddress(BaseSubscriptionTestCase):
data = {
'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.assertContains(response, 'errorlist')
@ -103,8 +102,8 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
def test_can_change_payment_method_from_bank_to_credit_card_with_sca(self):
bank = Gateway.objects.get(name='bank')
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=bank,
)
self.assertEqual(PaymentMethod.objects.count(), 1)
@ -139,8 +138,8 @@ class TestSubscriptionSettingsChangePaymentMethod(BaseSubscriptionTestCase):
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,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=braintree,
)
self.assertEqual(PaymentMethod.objects.count(), 1)
@ -173,8 +172,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
def test_can_cancel_when_on_hold(self):
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold',
)
@ -198,8 +197,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
def test_can_cancel_when_active(self):
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='active',
)
@ -218,8 +217,8 @@ class TestSubscriptionCancel(BaseSubscriptionTestCase):
def test_email_sent_when_pending_cancellation_changes_to_cancelled(self):
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='pending-cancellation',
)
@ -244,8 +243,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
def test_redirect_to_login_when_anonymous(self):
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold',
)
@ -264,8 +263,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
def test_cannot_pay_someone_elses_order(self):
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold',
)
@ -284,8 +283,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
def test_invalid_missing_required_form_data(self):
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
status='on-hold',
)
@ -315,8 +314,8 @@ class TestPayExistingOrder(BaseSubscriptionTestCase):
)
def test_can_pay_for_manual_subscription_with_an_order(self):
subscription = SubscriptionFactory(
user=self.user,
payment_method__user_id=self.user.pk,
customer=self.user.customer,
payment_method__customer_id=self.user.customer.pk,
payment_method__gateway=Gateway.objects.get(name='bank'),
currency='USD',
price=Money('USD', 1110),

View File

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

View File

@ -107,7 +107,7 @@ def handle_tracking_event_unsubscribe(event_type: str, message_id: str, event: D
def unsubscribe_from_newsletters(pk: int):
"""Remove emails of user with given pk from newsletter lists."""
user = User.objects.get(pk=pk)
emails = (user.email, user.customer.billing_email)
emails = (user.email, user.customer.billing_address.email)
for email in emails:
if not email:
continue

View File

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

View File

@ -5,14 +5,15 @@ from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.utils import timezone
from comments.queries import set_comment_like
from common.tests.factories.comments import CommentFactory
from common.tests.factories.subscriptions import (
TeamFactory,
from looper.tests.factories import (
PaymentMethodFactory,
TransactionFactory,
create_customer_with_billing_address,
)
from 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
import users.tasks as tasks
import users.tests.util as util