Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add success message after Stripe checkout #4392

Merged
merged 19 commits into from
Apr 5, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
30 changes: 28 additions & 2 deletions jsapp/js/account/plan.component.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, {useEffect, useReducer, useState} from 'react';
import React, {useEffect, useReducer, useRef, useState} from 'react';
import {useSearchParams} from "react-router-dom";
import styles from './plan.module.scss';
import type {
BaseSubscription,
Expand All @@ -14,8 +15,9 @@ import {
postCheckout,
postCustomerPortal,
} from './stripe.api';
import Icon from '../components/common/icon';
import Icon from 'js/components/common/icon';
import Button from 'js/components/common/button';
import {notify} from "js/utils";

interface PlanState {
isLoading: boolean;
Expand Down Expand Up @@ -72,6 +74,9 @@ export default function Plan() {
const [state, dispatch] = useReducer(planReducer, initialState);
const [expandComparison, setExpandComparison] = useState(false);
const [buttonsDisabled, setButtonDisabled] = useState(false);
const [showExpand, setShowExpand] = useState(false);
const [searchParams, _setSearchParams] = useSearchParams();
const didMount = useRef(false);

useEffect(() => {
getProducts().then((data) => {
Expand Down Expand Up @@ -100,6 +105,27 @@ export default function Plan() {
checkMetaFeatures();
}, [state.products]);

useEffect(() => {
// display a success message if we're returning from Stripe checkout
// only run *after* first render
if (!didMount.current) {
didMount.current = true;
return;
}
const priceId = searchParams.get('checkout');
if (priceId) {
const isSubscriptionUpdated = state.subscribedProduct.find((subscription: BaseSubscription) => {
return subscription.items.find((item) => item.price.id === priceId)
});
if (isSubscriptionUpdated) {
notify.success( t('Thanks for your upgrade! We appreciate your continued support. Reach out to billing@kobotoolbox.org if you have any questions about your plan.') );
}
else {
notify.success( t('Thanks for your upgrade! We appreciate your continued support. If your account is not immediately updated, wait a few minutes and refresh the page.') );
}
}
}, [state.subscribedProduct])

// Filter prices based on plan interval
const filterPrices = (): Price[] => {
if (state.products.length > 0) {
Expand Down
2 changes: 1 addition & 1 deletion jsapp/js/account/plan.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,4 @@ color: colors.$kobo-teal;
.iconContainer {
margin-right: sizes.$x10;
}
}
}
1 change: 1 addition & 0 deletions jsapp/js/account/stripe.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface BasePrice {
export interface BaseSubscription {
id: number;
price: Product;
items: [{ price:BasePrice }];
}

export interface Organization {
Expand Down
13 changes: 12 additions & 1 deletion kobo/apps/stripe/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.core.exceptions import SuspiciousOperation, ValidationError
from djstripe.models import (
Customer,
Session,
Price,
Product,
Subscription,
Expand All @@ -9,6 +9,17 @@
from rest_framework import serializers


class OneTimeAddOnSerializer(serializers.ModelSerializer):
payment_intent = serializers.SlugRelatedField(
slug_field='status',
read_only=True,
many=False,
)
LMNTL marked this conversation as resolved.
Show resolved Hide resolved
class Meta:
model = Session
fields = ('metadata', 'created', 'payment_intent',)


class BaseProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
Expand Down
75 changes: 75 additions & 0 deletions kobo/apps/stripe/tests/test_one_time_addons_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from django.contrib.auth.models import User
from django.urls import reverse
from djstripe.enums import BillingScheme
from djstripe.models import Customer, PaymentIntent
from model_bakery import baker
from rest_framework import status

from kobo.apps.organizations.models import Organization
from kpi.tests.kpi_test_case import BaseTestCase


class OneTimeAddOnAPITestCase(BaseTestCase):

fixtures = ['test_data']

def setUp(self):
self.someuser = User.objects.get(username='someuser')
self.client.force_login(self.someuser)
self.url = reverse('addons-list')
self.price_id = 'price_305dfs432ltnjw'

def _insert_data(self):
self.organization = baker.make(Organization)
self.organization.add_user(self.someuser, is_admin=True)
self.customer = baker.make(Customer, subscriber=self.organization)

def _create_session_and_payment_intent(self):
payment_intent = baker.make(
PaymentIntent,
customer=self.customer,
status='succeeded',
payment_method_types=["card"],
livemode=False,
)
session = baker.make(
'djstripe.Session',
customer=self.customer,
metadata={
'organization_uid': self.organization.uid,
'price_id': self.price_id,
},
mode='payment',
payment_intent=payment_intent,
payment_method_types=["card"],
items__price__livemode=False,
items__price__billing_scheme=BillingScheme.per_unit,
livemode=False,
)

def test_no_addons(self):
response = self.client.get(self.url)
assert response.status_code == status.HTTP_200_OK
assert response.data['results'] == []

def test_get_endpoint(self):
self._insert_data()
self._create_session_and_payment_intent()
response = self.client.get(self.url)
assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 1

def test_anonymous_user(self):
self._insert_data()
self._create_session_and_payment_intent()
self.client.logout()
response = self.client.get(self.url)
assert response.status_code == status.HTTP_403_FORBIDDEN

def test_not_own_addon(self):
self._insert_data()
self._create_session_and_payment_intent()
self.client.force_login(User.objects.get(username='anotheruser'))
response_get_list = self.client.get(self.url)
assert response_get_list.status_code == status.HTTP_200_OK
assert response_get_list.data['results'] == []
3 changes: 2 additions & 1 deletion kobo/apps/stripe/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
from rest_framework.routers import SimpleRouter


from kobo.apps.stripe.views import SubscriptionViewSet, CheckoutLinkView, CustomerPortalView, ProductViewSet
from kobo.apps.stripe.views import SubscriptionViewSet, CheckoutLinkView, CustomerPortalView, OneTimeAddOnViewSet, ProductViewSet

router = SimpleRouter()
router.register(r'subscriptions', SubscriptionViewSet, basename='subscriptions')
router.register(r'products', ProductViewSet)
router.register(r'addons', OneTimeAddOnViewSet, basename='addons')


urlpatterns = [
Expand Down
24 changes: 22 additions & 2 deletions kobo/apps/stripe/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from django.conf import settings
from django.db.models import Prefetch

from djstripe.models import Customer, Price, Product, Subscription, SubscriptionItem
from djstripe.models import Customer, Price, Product, Session, Subscription, SubscriptionItem
from djstripe.settings import djstripe_settings

from organizations.utils import create_organization
Expand All @@ -17,6 +17,7 @@
SubscriptionSerializer,
CheckoutLinkSerializer,
CustomerPortalSerializer,
OneTimeAddOnSerializer,
ProductSerializer,
)

Expand All @@ -25,6 +26,25 @@
)


# Lists the one-time purchases made by the organization that the logged-in user owns
class OneTimeAddOnViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
LMNTL marked this conversation as resolved.
Show resolved Hide resolved
):
permission_classes = (IsAuthenticated,)
serializer_class = OneTimeAddOnSerializer
queryset = Session.objects.all()

def get_queryset(self):
return self.queryset.filter(
livemode=settings.STRIPE_LIVE_MODE,
customer__subscriber__owner__organization_user__user=self.request.user,
LMNTL marked this conversation as resolved.
Show resolved Hide resolved
mode='payment',
payment_intent__status__in=['succeeded', 'processing']
).prefetch_related('payment_intent')


class CheckoutLinkView(
APIView
):
Expand Down Expand Up @@ -74,7 +94,7 @@ def start_checkout_session(customer_id, price, organization_uid):
},
mode=checkout_mode,
payment_method_types=["card"],
success_url=f'{settings.KOBOFORM_URL}/#/account/plan?checkout_complete=true',
success_url=f'{settings.KOBOFORM_URL}/#/account/plan?checkout={price.id}',
)

def post(self, request):
Expand Down