Stripe in Blender Studio #93018

Merged
Anna Sirota merged 9 commits from studio-to-stripe into main 2024-06-17 17:39:10 +02:00
18 changed files with 80 additions and 49 deletions

View File

@ -1,32 +1,4 @@
[ [
{
"model": "looper.gateway",
"pk": 1,
"fields": {
"name": "braintree",
"is_default": false,
"frontend_name": "Credit Card or PayPal"
}
},
{
"model": "looper.gateway",
"pk": 2,
"fields": {
"name": "bank",
"is_default": false,
"frontend_name": "Bank Transfer",
"form_description": "<p>This option requires you to manually perform a bank transfer before the membership can be activated. Automatic payment is not possible with bank transfer. Bank details will be given to you when you choose this option.</p>"
}
},
{
"model": "looper.gateway",
"pk": 3,
"fields": {
"name": "stripe",
"is_default": true,
"frontend_name": "Checkout with Stripe"
}
},
{ {
"model": "looper.product", "model": "looper.product",
"pk": 1, "pk": 1,

View File

@ -0,0 +1,30 @@
[
{
"model": "looper.gateway",
"pk": 1,
"fields": {
"name": "braintree",
"is_default": false,
"frontend_name": "Credit Card or PayPal"
}
},
{
"model": "looper.gateway",
"pk": 2,
"fields": {
"name": "bank",
"is_default": false,
"frontend_name": "Bank Transfer",
"form_description": "<p>This option requires you to manually perform a bank transfer before the membership can be activated. Automatic payment is not possible with bank transfer. Bank details will be given to you when you choose this option.</p>"
}
},
{
"model": "looper.gateway",
"pk": 3,
"fields": {
"name": "stripe",
"is_default": true,
"frontend_name": "Checkout with Stripe"
}
}
]

View File

@ -25,10 +25,13 @@ class Migration(migrations.Migration):
name='vat_number', name='vat_number',
field=models.CharField(blank=True, default='', max_length=255, verbose_name='VAT identification number'), field=models.CharField(blank=True, default='', max_length=255, verbose_name='VAT identification number'),
), ),
migrations.RunSQL('SET CONSTRAINTS ALL IMMEDIATE;'),
# Create address records for accounts without any # Create address records for accounts without any
migrations.RunSQL( migrations.RunSQL(
"insert into looper_address (category, user_id, full_name, company, country, tax_exempt, vat_number)" "insert into looper_address (category, user_id, full_name, company, country, tax_exempt, vat_number, "
"select 'billing', cu.user_id, cu.full_name, cu.company, '', false, '' " "street_address, extended_address, locality, postal_code, region)"
"select 'billing', cu.user_id, cu.full_name, cu.company, '', false, '', "
"'', '', '', '', '' "
"from looper_customer as cu left join looper_address as addr using(user_id) " "from looper_customer as cu left join looper_address as addr using(user_id) "
"where addr.user_id is null and cu.user_id is not null", "where addr.user_id is null and cu.user_id is not null",
), ),
@ -44,6 +47,7 @@ class Migration(migrations.Migration):
"full_name = addr.full_name, company = addr.company " "full_name = addr.full_name, company = addr.company "
"from looper_address as addr where cu.user_id = addr.user_id", "from looper_address as addr where cu.user_id = addr.user_id",
), ),
migrations.RunSQL('SET CONSTRAINTS ALL DEFERRED;'),
migrations.AlterField( migrations.AlterField(
model_name='address', model_name='address',
name='email', name='email',

View File

@ -181,8 +181,10 @@ class Taxable:
is_business = False is_business = False
if user and user.is_authenticated: if user and user.is_authenticated:
customer = user.customer customer = user.customer
country_code = customer.billing_address.country or country_code billing_address = customer.billing_address
is_business = bool(customer.vat_number) country_code = billing_address.country or country_code
vat_number = billing_address.vat_number
is_business = bool(vat_number)
tax_type, tax_rate = ProductType(product_type).get_tax( tax_type, tax_rate = ProductType(product_type).get_tax(
buyer_country_code=country_code, is_business=is_business buyer_country_code=country_code, is_business=is_business
) )

View File

@ -27,7 +27,7 @@ class AbstractBaseTestCase(TestCase):
class AbstractLooperTestCase(AbstractBaseTestCase): class AbstractLooperTestCase(AbstractBaseTestCase):
log = log.getChild('AbstractLooperTestCase') log = log.getChild('AbstractLooperTestCase')
fixtures = ['devfund', 'testuser', 'systemuser'] fixtures = ['gateways', 'devfund', 'testuser', 'systemuser']
valid_payload = { valid_payload = {
'gateway': 'stripe', 'gateway': 'stripe',
'email': 'erik@example.com', 'email': 'erik@example.com',
@ -79,7 +79,7 @@ class AbstractLooperTestCase(AbstractBaseTestCase):
self.pay_meth = self.user.customer.payment_method_default self.pay_meth = self.user.customer.payment_method_default
def create_accountless_customer(self): def create_accountless_customer(self):
self.accountless_customer = Customer.objects.create() self.accountless_customer = Customer.objects.create(user=None)
self.accountless_customer.billing_address.save() self.accountless_customer.billing_address.save()
gw = self._get_test_gateway() gw = self._get_test_gateway()
if gw: if gw:

View File

@ -10,6 +10,7 @@ import looper.taxes
User = get_user_model() User = get_user_model()
@factory.django.mute_signals(signals.pre_save, signals.post_save)
class UserFactory(DjangoModelFactory): class UserFactory(DjangoModelFactory):
class Meta: class Meta:
model = User model = User
@ -30,6 +31,8 @@ class CustomerFactory(DjangoModelFactory):
class Meta: class Meta:
model = looper.models.Customer model = looper.models.Customer
user = factory.SubFactory(UserFactory)
class PaymentMethodFactory(DjangoModelFactory): class PaymentMethodFactory(DjangoModelFactory):
class Meta: class Meta:
@ -58,6 +61,7 @@ class SubscriptionFactory(DjangoModelFactory):
class Meta: class Meta:
model = looper.models.Subscription model = looper.models.Subscription
customer = factory.SubFactory(CustomerFactory)
plan = factory.SubFactory(PlanFactory) plan = factory.SubFactory(PlanFactory)
payment_method = factory.SubFactory(PaymentMethodFactory) payment_method = factory.SubFactory(PaymentMethodFactory)

View File

@ -22,7 +22,7 @@ CHECKOUT = 'looper-braintree:checkout'
# Prevent communication with Google's reCAPTCHA API. # Prevent communication with Google's reCAPTCHA API.
@override_settings(GOOGLE_RECAPTCHA_SECRET_KEY='') @override_settings(GOOGLE_RECAPTCHA_SECRET_KEY='')
class AbstractCheckoutTestCase(AbstractLooperTestCase): class AbstractCheckoutTestCase(AbstractLooperTestCase):
fixtures = ['devfund', 'testuser', 'systemuser'] fixtures = ['gateways', 'devfund', 'testuser', 'systemuser']
checkout_url = reverse_lazy(CHECKOUT, kwargs={'plan_id': 2, 'plan_variation_id': 5}) checkout_url = reverse_lazy(CHECKOUT, kwargs={'plan_id': 2, 'plan_variation_id': 5})
def setUp(self): def setUp(self):

View File

@ -15,7 +15,7 @@ User = get_user_model()
class _BaseTestCase(AbstractLooperTestCase): class _BaseTestCase(AbstractLooperTestCase):
fixtures = ['devfund', 'testuser', 'systemuser'] fixtures = ['gateways', 'devfund', 'testuser', 'systemuser']
checkout_url = reverse_lazy('looper:checkout', kwargs={'plan_id': 2, 'plan_variation_id': 5}) checkout_url = reverse_lazy('looper:checkout', kwargs={'plan_id': 2, 'plan_variation_id': 5})
gateway_name = 'stripe' gateway_name = 'stripe'

View File

@ -19,7 +19,7 @@ from looper.tests.factories import (
@mock.patch('looper.gateways.stripe.webhook.WebhookSignature.verify_header', return_value=True) @mock.patch('looper.gateways.stripe.webhook.WebhookSignature.verify_header', return_value=True)
class ClockForStripe(ResponsesMixin, TestCase): class ClockForStripe(ResponsesMixin, TestCase):
fixtures = ['devfund', 'systemuser'] fixtures = ['gateways', 'devfund', 'systemuser']
responses_files = ['clock_stripe.yaml'] responses_files = ['clock_stripe.yaml']
# @_recorder.record(file_path=f'{ResponsesMixin.responses_file_path}/clock_stripe.yaml') # @_recorder.record(file_path=f'{ResponsesMixin.responses_file_path}/clock_stripe.yaml')

View File

@ -116,7 +116,7 @@ class BraintreeErrorTest(AbstractBaseTestCase):
class BankGatewayTest(AbstractBaseTestCase): class BankGatewayTest(AbstractBaseTestCase):
fixtures = ['testuser', 'devfund'] fixtures = ['testuser', 'gateways', 'devfund']
def setUp(self): def setUp(self):
super().setUp() super().setUp()

View File

@ -10,7 +10,7 @@ from . import AbstractLooperTestCase, AbstractBaseTestCase
class MoneyFieldTest(AbstractBaseTestCase): class MoneyFieldTest(AbstractBaseTestCase):
fixtures = ['testuser', 'devfund', 'systemuser'] fixtures = ['testuser', 'gateways', 'devfund', 'systemuser']
def test_create_instance(self): def test_create_instance(self):
pv = models.PlanVariation(plan_id=1, currency='EUR', price=Money('EUR', 155)) pv = models.PlanVariation(plan_id=1, currency='EUR', price=Money('EUR', 155))

View File

@ -15,7 +15,7 @@ User = get_user_model()
class _BaseTestCase(AbstractLooperTestCase): class _BaseTestCase(AbstractLooperTestCase):
fixtures = ['devfund', 'testuser', 'systemuser'] fixtures = ['gateways', 'devfund', 'testuser', 'systemuser']
gateway_name = 'stripe' gateway_name = 'stripe'
subscription = None subscription = None

View File

@ -6,7 +6,7 @@ from ..money import Money
class PlanVariationModelTestCase(django.test.TestCase): class PlanVariationModelTestCase(django.test.TestCase):
fixtures = ['devfund'] fixtures = ['gateways', 'devfund']
def setUp(self): def setUp(self):
product = Product.objects.create(name='Ежедневный Пророк') product = Product.objects.create(name='Ежедневный Пророк')
@ -29,7 +29,7 @@ class PlanVariationModelTestCase(django.test.TestCase):
class PlanVariationTest(django.test.TestCase): class PlanVariationTest(django.test.TestCase):
fixtures = ['devfund'] fixtures = ['gateways', 'devfund']
def test_variation_for_currency(self): def test_variation_for_currency(self):
plan = Plan.objects.get(pk=1) plan = Plan.objects.get(pk=1)

View File

@ -35,7 +35,7 @@ event_payload_cc = {
@mock.patch('looper.gateways.stripe.webhook.WebhookSignature.verify_header', return_value=True) @mock.patch('looper.gateways.stripe.webhook.WebhookSignature.verify_header', return_value=True)
class UpsertOrderFromPaymentIntentAndProduct(ResponsesMixin, TestCase): class UpsertOrderFromPaymentIntentAndProduct(ResponsesMixin, TestCase):
fixtures = ['devfund', 'systemuser'] fixtures = ['gateways', 'devfund', 'systemuser']
responses_files = [ responses_files = [
'payment_intent__card.yaml', 'payment_intent__card.yaml',
'payment_intent__ideal.yaml', 'payment_intent__ideal.yaml',
@ -452,7 +452,7 @@ class UpsertOrderFromPaymentIntentAndProduct(ResponsesMixin, TestCase):
class TestCheckoutPlanVariationAnonymous(ResponsesMixin, TestCase): class TestCheckoutPlanVariationAnonymous(ResponsesMixin, TestCase):
fixtures = ['devfund', 'systemuser'] fixtures = ['gateways', 'devfund', 'systemuser']
responses_files = [ responses_files = [
'checkout_plan_variation_anonymous.yaml', 'checkout_plan_variation_anonymous.yaml',
'checkout_plan_variation_webhook.yaml', 'checkout_plan_variation_webhook.yaml',
@ -535,7 +535,7 @@ class TestCheckoutPlanVariationAnonymous(ResponsesMixin, TestCase):
class TestCheckoutPlanVariationLoggedIn(ResponsesMixin, TestCase): class TestCheckoutPlanVariationLoggedIn(ResponsesMixin, TestCase):
fixtures = ['devfund', 'systemuser'] fixtures = ['gateways', 'devfund', 'systemuser']
responses_files = [ responses_files = [
'checkout_plan_variation_logged_in.yaml', 'checkout_plan_variation_logged_in.yaml',
] ]

View File

@ -147,6 +147,9 @@ class CheckoutExistingOrderView(LoginRequiredMixin, ExpectReadableIPAddressMixin
# should be passed from the app, this url name is defined in example_app # should be passed from the app, this url name is defined in example_app
cancel_url = 'settings_home' cancel_url = 'settings_home'
def get_cancel_url(self):
return reverse(self.cancel_url)
def get_object(self, queryset=None) -> models.Order: def get_object(self, queryset=None) -> models.Order:
order_id: int = self.kwargs['order_id'] order_id: int = self.kwargs['order_id']
order_q = models.Order.objects.filter(customer=self.request.user.customer) order_q = models.Order.objects.filter(customer=self.request.user.customer)
@ -171,7 +174,7 @@ class CheckoutExistingOrderView(LoginRequiredMixin, ExpectReadableIPAddressMixin
# we have to do it to avoid uri-encoding of curly braces, # we have to do it to avoid uri-encoding of curly braces,
# otherwise stripe doesn't do the template substitution # otherwise stripe doesn't do the template substitution
success_url = success_url.replace('CHECKOUT_SESSION_ID', '{CHECKOUT_SESSION_ID}', 1) success_url = success_url.replace('CHECKOUT_SESSION_ID', '{CHECKOUT_SESSION_ID}', 1)
cancel_url = self.request.build_absolute_uri(reverse(self.cancel_url)) cancel_url = self.request.build_absolute_uri(self.get_cancel_url())
session = stripe_utils.create_stripe_checkout_session_for_order( session = stripe_utils.create_stripe_checkout_session_for_order(
order, order,
success_url, success_url,

View File

@ -102,7 +102,10 @@ class PaymentMethodChangeView(LoginRequiredMixin, View):
log = log.getChild('PaymentMethodChangeView') log = log.getChild('PaymentMethodChangeView')
subscription: models.Subscription subscription: models.Subscription
success_url = '/' success_url = '/'
cancel_url = '/' cancel_url = 'settings_home'
def get_cancel_url(self):
return reverse(self.cancel_url)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.subscription = get_object_or_404( self.subscription = get_object_or_404(
@ -120,7 +123,7 @@ class PaymentMethodChangeView(LoginRequiredMixin, View):
# we have to do it to avoid uri-encoding of curly braces, # we have to do it to avoid uri-encoding of curly braces,
# otherwise stripe doesn't do the template substitution # otherwise stripe doesn't do the template substitution
success_url = success_url.replace('CHECKOUT_SESSION_ID', '{CHECKOUT_SESSION_ID}', 1) success_url = success_url.replace('CHECKOUT_SESSION_ID', '{CHECKOUT_SESSION_ID}', 1)
cancel_url = self.request.build_absolute_uri(reverse(self.cancel_url)) cancel_url = self.request.build_absolute_uri(self.get_cancel_url())
session = stripe_utils.setup_stripe_payment_method( session = stripe_utils.setup_stripe_payment_method(
self.subscription.currency.lower(), self.subscription.currency.lower(),
request.user.customer, request.user.customer,

14
poetry.lock generated
View File

@ -1785,6 +1785,18 @@ lxml = "*"
reportlab = "*" reportlab = "*"
tinycss2 = ">=0.6.0" tinycss2 = ">=0.6.0"
[[package]]
name = "tblib"
version = "3.0.0"
description = "Traceback serialization library."
category = "dev"
optional = false
python-versions = ">=3.8"
files = [
{file = "tblib-3.0.0-py3-none-any.whl", hash = "sha256:80a6c77e59b55e83911e1e607c649836a69c103963c5f28a46cbeef44acf8129"},
{file = "tblib-3.0.0.tar.gz", hash = "sha256:93622790a0a29e04f0346458face1e144dc4d32f493714c6c3dff82a4adb77e6"},
]
[[package]] [[package]]
name = "tinycss2" name = "tinycss2"
version = "1.2.1" version = "1.2.1"
@ -2060,4 +2072,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 = "cf5844e74a99155ee23a29cc3f711e0b8577264b72ff07acd858ce16a407e05d" content-hash = "00e67af2695baac5d34a3c9a67ac24286a6a218b30196cccc14c3a35461ffc05"

View File

@ -40,6 +40,7 @@ flake8-implicit-str-concat = "^0.1.0"
psycopg2 = "*" psycopg2 = "*"
mypy = "^0.991" mypy = "^0.991"
factory-boy = "^3.0" factory-boy = "^3.0"
tblib = "3.0.0"
[tool.black] [tool.black]
line-length = 100 line-length = 100