Skip to content

Commit dff6074

Browse files
authored
feat: support pending changes for plan upgrades (supabase#36430)
This PR implements the new flow to confirm subscription upgrades using Orb pending changes. This is backwards compatible and based on a flag exposed by the backend (`subscriptionPreview.pending_subscription_flow`). Just like the organization creation, the entire flow is slightly different - instead of creating a payment method separately, the payment method is added inline while doing the upgrade and then attached to the customer. If payment fails, the upgrade will not go through. If payment requires additional action, the user needs to confirm the payment before allowing the upgrade. For testing the new flow locally, toggle the flag in `flags.ts` on the backend. Changes include - No longer rely on the `changeType` from the plans endpoint as this is regularly out-of-sync and displays wrong up/downgrade info due to race conditions - `readOnly` mode for Stripe elements if anything is loading/submitting - Reduced prop drilling for some components - Hide payment method and address selection on downgrade
1 parent 7d9bdb3 commit dff6074

File tree

14 files changed

+626
-154
lines changed

14 files changed

+626
-154
lines changed

apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { OrgSubscription, ProjectSelectedAddon } from 'data/subscriptions/types'
1+
import type { OrgSubscription, PlanId, ProjectSelectedAddon } from 'data/subscriptions/types'
22
import { IS_PLATFORM } from 'lib/constants'
33

44
export const getAddons = (selectedAddons: ProjectSelectedAddon[]) => {
@@ -32,3 +32,43 @@ export const billingPartnerLabel = (billingPartner?: string) => {
3232
return billingPartner
3333
}
3434
}
35+
36+
type PlanChangeType = 'upgrade' | 'downgrade' | 'none'
37+
38+
export const getPlanChangeType = (
39+
fromPlan: PlanId | undefined,
40+
toPlan: PlanId | undefined
41+
): PlanChangeType => {
42+
const planChangeTypes: Record<PlanId, Record<PlanId, PlanChangeType>> = {
43+
free: {
44+
free: 'none',
45+
pro: 'upgrade',
46+
team: 'upgrade',
47+
enterprise: 'upgrade',
48+
},
49+
pro: {
50+
free: 'downgrade',
51+
pro: 'none',
52+
team: 'upgrade',
53+
enterprise: 'upgrade',
54+
},
55+
team: {
56+
free: 'downgrade',
57+
pro: 'downgrade',
58+
team: 'none',
59+
enterprise: 'upgrade',
60+
},
61+
enterprise: {
62+
free: 'downgrade',
63+
pro: 'downgrade',
64+
team: 'downgrade',
65+
enterprise: 'none',
66+
},
67+
}
68+
69+
if (!fromPlan || !toPlan) {
70+
return 'none'
71+
}
72+
73+
return planChangeTypes[fromPlan]?.[toPlan] ?? 'none'
74+
}

apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,10 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
252252
name="paymentMethod"
253253
render={() => (
254254
<PaymentMethodSelection
255+
createPaymentMethodInline={false}
255256
onSelectPaymentMethod={(pm) => form.setValue('paymentMethod', pm)}
256257
selectedPaymentMethod={form.getValues('paymentMethod')}
258+
readOnly={executingTopUp || paymentConfirmationLoading}
257259
/>
258260
)}
259261
/>

apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ const NewPaymentMethodElement = forwardRef(
1515
{
1616
pending_subscription_flow_enabled,
1717
email,
18-
}: { pending_subscription_flow_enabled: boolean; email?: string },
18+
readOnly,
19+
}: { pending_subscription_flow_enabled: boolean; email?: string; readOnly: boolean },
1920
ref
2021
) => {
2122
const stripe = useStripe()
@@ -58,7 +59,7 @@ const NewPaymentMethodElement = forwardRef(
5859
createPaymentMethod,
5960
}))
6061

61-
return <PaymentElement options={{ defaultValues: { billingDetails: { email } } }} />
62+
return <PaymentElement options={{ defaultValues: { billingDetails: { email } }, readOnly }} />
6263
}
6364
)
6465

apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import type { OrgSubscription, ProjectAddon } from 'data/subscriptions/types'
55
import { PricingInformation } from 'shared-data'
66
import { Modal } from 'ui'
77
import { Admonition } from 'ui-patterns'
8+
import { plans as subscriptionsPlans } from 'shared-data/plans'
9+
import { useMemo } from 'react'
810

911
export interface DowngradeModalProps {
1012
visible: boolean
11-
selectedPlan?: PricingInformation
1213
subscription?: OrgSubscription
1314
onClose: () => void
1415
onConfirm: () => void
@@ -50,12 +51,13 @@ const ProjectDowngradeListItem = ({ projectAddon }: { projectAddon: ProjectAddon
5051

5152
const DowngradeModal = ({
5253
visible,
53-
selectedPlan,
5454
subscription,
5555
onClose,
5656
onConfirm,
5757
projects,
5858
}: DowngradeModalProps) => {
59+
const selectedPlan = useMemo(() => subscriptionsPlans.find((tier) => tier.id === 'tier_free'), [])
60+
5961
// Filter out the micro addon as we're dealing with that separately
6062
const previousProjectAddons =
6163
subscription?.project_addons.flatMap((projectAddons) => {

apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx

Lines changed: 182 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,143 @@
11
import { PermissionAction } from '@supabase/shared-types/out/constants'
2-
import { useEffect, useState } from 'react'
2+
import {
3+
forwardRef,
4+
useCallback,
5+
useEffect,
6+
useImperativeHandle,
7+
useMemo,
8+
useRef,
9+
useState,
10+
} from 'react'
311
import { toast } from 'sonner'
412

513
import AddNewPaymentMethodModal from 'components/interfaces/Billing/Payment/AddNewPaymentMethodModal'
614
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
715
import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query'
816
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
917
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
10-
import { BASE_PATH } from 'lib/constants'
18+
import { BASE_PATH, STRIPE_PUBLIC_KEY } from 'lib/constants'
1119
import { getURL } from 'lib/helpers'
1220
import { AlertCircle, CreditCard, Loader, Plus } from 'lucide-react'
1321
import { Listbox } from 'ui'
22+
import HCaptcha from '@hcaptcha/react-hcaptcha'
23+
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
24+
import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation'
25+
import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation'
26+
import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js'
27+
import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils'
28+
import { useTheme } from 'next-themes'
29+
import { Elements } from '@stripe/react-stripe-js'
30+
import { NewPaymentMethodElement } from '../PaymentMethods/NewPaymentMethodElement'
31+
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
32+
33+
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
1434

1535
export interface PaymentMethodSelectionProps {
1636
selectedPaymentMethod?: string
1737
onSelectPaymentMethod: (id: string) => void
1838
layout?: 'vertical' | 'horizontal'
39+
createPaymentMethodInline: boolean
40+
readOnly: boolean
1941
}
2042

21-
const PaymentMethodSelection = ({
22-
selectedPaymentMethod,
23-
onSelectPaymentMethod,
24-
layout = 'vertical',
25-
}: PaymentMethodSelectionProps) => {
43+
const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
44+
{
45+
selectedPaymentMethod,
46+
onSelectPaymentMethod,
47+
layout = 'vertical',
48+
createPaymentMethodInline = false,
49+
readOnly,
50+
}: PaymentMethodSelectionProps,
51+
ref
52+
) {
2653
const selectedOrganization = useSelectedOrganization()
2754
const slug = selectedOrganization?.slug
2855
const [showAddNewPaymentMethodModal, setShowAddNewPaymentMethodModal] = useState(false)
56+
const captchaLoaded = useIsHCaptchaLoaded()
57+
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
58+
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
59+
const [setupIntent, setSetupIntent] = useState<SetupIntentResponse | undefined>(undefined)
60+
const { resolvedTheme } = useTheme()
61+
const paymentRef = useRef<{ createPaymentMethod: () => Promise<PaymentMethod | undefined> }>(null)
62+
const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState<boolean | null>(null)
2963

3064
const {
3165
data: paymentMethods,
3266
isLoading,
3367
refetch: refetchPaymentMethods,
3468
} = useOrganizationPaymentMethodsQuery({ slug })
3569

70+
const captchaRefCallback = useCallback((node: any) => {
71+
setCaptchaRef(node)
72+
}, [])
73+
74+
const { mutate: initSetupIntent, isLoading: setupIntentLoading } =
75+
useOrganizationPaymentMethodSetupIntent({
76+
onSuccess: (intent) => {
77+
setSetupIntent(intent)
78+
},
79+
onError: (error) => {
80+
toast.error(`Failed to setup intent: ${error.message}`)
81+
},
82+
})
83+
84+
useEffect(() => {
85+
if (paymentMethods?.data && paymentMethods.data.length === 0 && setupNewPaymentMethod == null) {
86+
setSetupNewPaymentMethod(true)
87+
}
88+
}, [paymentMethods])
89+
90+
useEffect(() => {
91+
const loadSetupIntent = async (hcaptchaToken: string | undefined) => {
92+
const slug = selectedOrganization?.slug
93+
if (!slug) return console.error('Slug is required')
94+
if (!hcaptchaToken) return console.error('HCaptcha token required')
95+
96+
setSetupIntent(undefined)
97+
initSetupIntent({ slug: slug!, hcaptchaToken })
98+
}
99+
100+
const loadPaymentForm = async () => {
101+
if (setupNewPaymentMethod && createPaymentMethodInline && captchaRef && captchaLoaded) {
102+
let token = captchaToken
103+
104+
try {
105+
if (!token) {
106+
const captchaResponse = await captchaRef.execute({ async: true })
107+
token = captchaResponse?.response ?? null
108+
}
109+
} catch (error) {
110+
return
111+
}
112+
113+
await loadSetupIntent(token ?? undefined)
114+
resetCaptcha()
115+
}
116+
}
117+
118+
loadPaymentForm()
119+
}, [createPaymentMethodInline, captchaRef, captchaLoaded, setupNewPaymentMethod])
120+
121+
const resetCaptcha = () => {
122+
setCaptchaToken(null)
123+
captchaRef?.resetCaptcha()
124+
}
125+
36126
const canUpdatePaymentMethods = useCheckPermissions(
37127
PermissionAction.BILLING_WRITE,
38128
'stripe.payment_methods'
39129
)
40130

131+
const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo(
132+
() =>
133+
({
134+
clientSecret: setupIntent ? setupIntent.client_secret! : '',
135+
appearance: getStripeElementsAppearanceOptions(resolvedTheme),
136+
paymentMethodCreation: 'manual',
137+
}) as const,
138+
[setupIntent, resolvedTheme]
139+
)
140+
41141
useEffect(() => {
42142
if (paymentMethods?.data && paymentMethods.data.length > 0) {
43143
const selectedPaymentMethodExists = paymentMethods.data.some(
@@ -55,15 +155,49 @@ const PaymentMethodSelection = ({
55155
}
56156
}, [selectedPaymentMethod, paymentMethods, onSelectPaymentMethod])
57157

158+
// If createPaymentMethod already exists, use it. Otherwise, define it here.
159+
const createPaymentMethod = async () => {
160+
if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) {
161+
return paymentRef.current?.createPaymentMethod()
162+
} else {
163+
return { id: selectedPaymentMethod }
164+
}
165+
}
166+
167+
useImperativeHandle(ref, () => ({
168+
createPaymentMethod,
169+
}))
170+
58171
return (
59172
<>
173+
<HCaptcha
174+
ref={captchaRefCallback}
175+
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
176+
size="invisible"
177+
onOpen={() => {
178+
// [Joshen] This is to ensure that hCaptcha popup remains clickable
179+
if (document !== undefined) document.body.classList.add('!pointer-events-auto')
180+
}}
181+
onClose={() => {
182+
setSetupIntent(undefined)
183+
if (document !== undefined) document.body.classList.remove('!pointer-events-auto')
184+
}}
185+
onVerify={(token) => {
186+
setCaptchaToken(token)
187+
if (document !== undefined) document.body.classList.remove('!pointer-events-auto')
188+
}}
189+
onExpire={() => {
190+
setCaptchaToken(null)
191+
}}
192+
/>
193+
60194
<div>
61195
{isLoading ? (
62196
<div className="flex items-center px-4 py-2 space-x-4 border rounded-md border-strong bg-surface-200">
63197
<Loader className="animate-spin" size={14} />
64198
<p className="text-sm text-foreground-light">Retrieving payment methods</p>
65199
</div>
66-
) : paymentMethods?.data.length === 0 ? (
200+
) : paymentMethods?.data.length === 0 && !createPaymentMethodInline ? (
67201
<div className="flex items-center justify-between px-4 py-2 border border-dashed rounded-md bg-alternative">
68202
<div className="flex items-center space-x-4 text-foreground-light">
69203
<AlertCircle size={16} strokeWidth={1.5} />
@@ -74,7 +208,13 @@ const PaymentMethodSelection = ({
74208
type="default"
75209
disabled={!canUpdatePaymentMethods}
76210
icon={<CreditCard />}
77-
onClick={() => setShowAddNewPaymentMethodModal(true)}
211+
onClick={() => {
212+
if (createPaymentMethodInline) {
213+
setSetupNewPaymentMethod(true)
214+
} else {
215+
setShowAddNewPaymentMethodModal(true)
216+
}
217+
}}
78218
htmlType="button"
79219
tooltip={{
80220
content: {
@@ -93,7 +233,7 @@ const PaymentMethodSelection = ({
93233
Add new
94234
</ButtonTooltip>
95235
</div>
96-
) : (
236+
) : paymentMethods?.data && paymentMethods?.data.length > 0 && !setupNewPaymentMethod ? (
97237
<Listbox
98238
layout={layout}
99239
label="Payment method"
@@ -126,14 +266,42 @@ const PaymentMethodSelection = ({
126266
})}
127267
<div
128268
className="flex items-center px-3 py-2 space-x-2 transition cursor-pointer group hover:bg-surface-300"
129-
onClick={() => setShowAddNewPaymentMethodModal(true)}
269+
onClick={() => {
270+
if (createPaymentMethodInline) {
271+
setSetupNewPaymentMethod(true)
272+
} else {
273+
setShowAddNewPaymentMethodModal(true)
274+
}
275+
}}
130276
>
131277
<Plus size={16} />
132278
<p className="transition text-foreground-light group-hover:text-foreground">
133279
Add new payment method
134280
</p>
135281
</div>
136282
</Listbox>
283+
) : null}
284+
285+
{stripePromise && setupIntent && (
286+
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
287+
<NewPaymentMethodElement
288+
ref={paymentRef}
289+
pending_subscription_flow_enabled={true}
290+
email={selectedOrganization?.billing_email}
291+
readOnly={readOnly}
292+
/>
293+
</Elements>
294+
)}
295+
296+
{setupIntentLoading && (
297+
<div className="space-y-2">
298+
<ShimmeringLoader className="h-10" />
299+
<div className="grid grid-cols-2 gap-4">
300+
<ShimmeringLoader className="h-10" />
301+
<ShimmeringLoader className="h-10" />
302+
</div>
303+
<ShimmeringLoader className="h-10" />
304+
</div>
137305
)}
138306
</div>
139307

@@ -158,6 +326,8 @@ const PaymentMethodSelection = ({
158326
/>
159327
</>
160328
)
161-
}
329+
})
330+
331+
PaymentMethodSelection.displayName = 'PaymentMethodSelection'
162332

163333
export default PaymentMethodSelection

0 commit comments

Comments
 (0)