SubscriptionPlanUpdateDialog.tsx601 lines · main
1import { Elements } from '@stripe/react-stripe-js'
2import { loadStripe, PaymentIntentResult, StripeElementsOptions } from '@stripe/stripe-js'
3import { useParams } from 'common'
4import { Check, InfoIcon } from 'lucide-react'
5import { useTheme } from 'next-themes'
6import Link from 'next/link'
7import { useMemo, useRef, useState } from 'react'
8import { plans as subscriptionsPlans } from 'shared-data/plans'
9import { toast } from 'sonner'
10import { Button, cn, Dialog, DialogContent } from 'ui'
11import { Admonition } from 'ui-patterns'
12import { InfoTooltip } from 'ui-patterns/info-tooltip'
13import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
14
15import { InvoiceEstimateTooltip } from './InvoiceEstimateTooltip'
16import PaymentMethodSelection from './PaymentMethodSelection'
17import { getStripeElementsAppearanceOptions } from '@/components/interfaces/Billing/Payment/Payment.utils'
18import { PaymentConfirmation } from '@/components/interfaces/Billing/Payment/PaymentConfirmation'
19import type { PaymentMethodElementRef } from '@/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement'
20import {
21 billingPartnerLabel,
22 getPlanChangeType,
23} from '@/components/interfaces/Billing/Subscription/Subscription.utils'
24import { type OrganizationBillingSubscriptionPreviewQueryResult } from '@/data/organizations/organization-billing-subscription-preview'
25import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
26import { OrgProject } from '@/data/projects/org-projects-infinite-query'
27import { useConfirmPendingSubscriptionChangeMutation } from '@/data/subscriptions/org-subscription-confirm-pending-change'
28import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
29import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation'
30import { OrgPlan, SubscriptionTier } from '@/data/subscriptions/types'
31import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
32import {
33 DOCS_URL,
34 PRICING_TIER_PRODUCT_IDS,
35 PROJECT_STATUS,
36 STRIPE_PUBLIC_KEY,
37} from '@/lib/constants'
38import { formatCurrency } from '@/lib/helpers'
39
40const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
41
42const PLAN_HEADINGS = {
43 tier_pro:
44 'the Pro plan to unlock more compute resources, daily backups, no project pausing, and email support whenever you need it',
45 tier_team: 'the Team plan for SOC2, SSO, priority support and greater data and log retention',
46 default: 'to a new plan',
47} as const
48
49type PlanHeadingKey = keyof typeof PLAN_HEADINGS
50
51// Add downgrade headings
52const DOWNGRADE_PLAN_HEADINGS = {
53 tier_free: 'the Free plan with limited resources and active projects',
54 tier_pro: 'the Pro plan',
55 default: 'to a lower plan',
56} as const
57
58type DowngradePlanHeadingKey = keyof typeof DOWNGRADE_PLAN_HEADINGS
59
60type BreakdownItem =
61 | { type: 'amount'; label: string; amount: number; tooltip?: string }
62 | { type: 'notice'; label: string }
63
64interface Props {
65 selectedTier: 'tier_free' | 'tier_pro' | 'tier_team' | undefined
66 onClose: () => void
67 planMeta?: OrgPlan | null
68 currentPlanMeta?: Partial<OrgPlan> & { features: (string | string[])[] }
69 subscriptionPreviewQueryResult: OrganizationBillingSubscriptionPreviewQueryResult
70 projects: OrgProject[]
71 onAddressChange?: (address: CustomerAddress) => void
72 onTaxIdChange?: (taxId: CustomerTaxId | null) => void
73 useAsDefaultBillingAddress: boolean
74 onUseAsDefaultBillingAddressChange: (useAsDefault: boolean) => void
75}
76
77export const SubscriptionPlanUpdateDialog = ({
78 selectedTier,
79 onClose,
80 planMeta,
81 subscriptionPreviewQueryResult,
82 currentPlanMeta,
83 projects,
84 onAddressChange,
85 onTaxIdChange,
86 useAsDefaultBillingAddress,
87 onUseAsDefaultBillingAddressChange,
88}: Props) => {
89 const { slug } = useParams()
90 const { resolvedTheme } = useTheme()
91 const { data: selectedOrganization } = useSelectedOrganizationQuery()
92 const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>()
93 const [paymentIntentSecret, setPaymentIntentSecret] = useState<string | null>(null)
94 const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
95 const paymentMethodSelectionRef = useRef<{
96 createPaymentMethod: PaymentMethodElementRef['createPaymentMethod']
97 validateBillingProfile: () => Promise<boolean>
98 }>(null)
99
100 const {
101 data: subscriptionPreview,
102 isPending: subscriptionPreviewIsLoading,
103 isFetching: subscriptionPreviewIsFetching,
104 isSuccess: subscriptionPreviewInitialized,
105 } = subscriptionPreviewQueryResult
106
107 const { data: subscription } = useOrgSubscriptionQuery({
108 orgSlug: slug,
109 })
110 const billingViaPartner = subscription?.billing_via_partner === true
111 const billingPartner = subscription?.billing_partner
112
113 const stripeOptionsConfirm = useMemo(() => {
114 return {
115 clientSecret: paymentIntentSecret,
116 appearance: getStripeElementsAppearanceOptions(resolvedTheme),
117 } as StripeElementsOptions
118 }, [paymentIntentSecret, resolvedTheme])
119
120 const changeType = useMemo(() => {
121 return getPlanChangeType(subscription?.plan?.id, planMeta?.id)
122 }, [planMeta, subscription])
123
124 const subscriptionPlanMeta = useMemo(
125 () => subscriptionsPlans.find((tier) => tier.id === selectedTier),
126 [selectedTier]
127 )
128
129 const onSuccessfulPlanChange = () => {
130 setPaymentConfirmationLoading(false)
131 toast.success(
132 `Successfully ${changeType === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!`
133 )
134 onClose()
135 window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
136 }
137
138 const { mutate: updateOrgSubscription, isPending: isUpdating } = useOrgSubscriptionUpdateMutation(
139 {
140 onSuccess: (data) => {
141 if (data.pending_payment_intent_secret) {
142 setPaymentIntentSecret(data.pending_payment_intent_secret)
143 return
144 }
145
146 onSuccessfulPlanChange()
147 },
148 onError: (error) => {
149 setPaymentConfirmationLoading(false)
150 toast.error(`Unable to update subscription: ${error.message}`)
151 },
152 }
153 )
154
155 const { mutate: confirmPendingSubscriptionChange, isPending: isConfirming } =
156 useConfirmPendingSubscriptionChangeMutation({
157 onSuccess: () => {
158 onSuccessfulPlanChange()
159 },
160 onError: (error) => {
161 toast.error(`Unable to update subscription: ${error.message}`)
162 },
163 })
164
165 const paymentIntentConfirmed = async (paymentIntentConfirmation: PaymentIntentResult) => {
166 // Reset payment intent secret to ensure another attempt works as expected
167 setPaymentIntentSecret('')
168
169 if (paymentIntentConfirmation.paymentIntent?.status === 'succeeded') {
170 await confirmPendingSubscriptionChange({
171 slug: selectedOrganization?.slug,
172 payment_intent_id: paymentIntentConfirmation.paymentIntent.id,
173 })
174 } else {
175 setPaymentConfirmationLoading(false)
176 // If the payment intent is not successful, we reset the payment method and show an error
177 toast.error(`Could not confirm payment. Please try again or use a different card.`)
178 }
179 }
180
181 const onUpdateSubscription = async () => {
182 if (!selectedOrganization?.slug) return console.error('org slug is required')
183 if (!selectedTier) return console.error('Selected plan is required')
184
185 setPaymentConfirmationLoading(true)
186
187 if (paymentMethodSelectionRef.current) {
188 const isValid = await paymentMethodSelectionRef.current.validateBillingProfile()
189 if (!isValid) {
190 setPaymentConfirmationLoading(false)
191 return
192 }
193 }
194
195 const result = await paymentMethodSelectionRef.current?.createPaymentMethod()
196 if (result) {
197 setSelectedPaymentMethod(result.paymentMethod.id)
198 } else {
199 setPaymentConfirmationLoading(false)
200 }
201
202 if (!result && subscription?.payment_method_type !== 'invoice' && changeType === 'upgrade') {
203 return
204 }
205
206 // If the user is downgrading from team, should have spend cap disabled by default
207 const tier =
208 subscription?.plan?.id === 'team' && selectedTier === PRICING_TIER_PRODUCT_IDS.PRO
209 ? (PRICING_TIER_PRODUCT_IDS.PAYG as SubscriptionTier)
210 : selectedTier
211
212 updateOrgSubscription({
213 slug: selectedOrganization?.slug,
214 tier,
215 paymentMethod: result?.paymentMethod?.id,
216 address: result?.address,
217 tax_id: result?.taxId ?? undefined,
218 billing_name: result?.customerName ?? undefined,
219 })
220 }
221
222 const features = subscriptionPlanMeta?.features || []
223 const topFeatures = features
224
225 // Get current plan features for downgrade comparison
226 const currentPlanFeatures = currentPlanMeta?.features || []
227
228 // Features that will be lost when downgrading
229 const featuresToLose =
230 changeType === 'downgrade'
231 ? currentPlanFeatures.filter((feature) => {
232 const featureStr = typeof feature === 'string' ? feature : feature[0]
233 // Check if this feature exists in the new plan
234 return !topFeatures.some((newFeature: string | string[]) => {
235 const newFeatureStr = typeof newFeature === 'string' ? newFeature : newFeature[0]
236 return newFeatureStr === featureStr
237 })
238 })
239 : []
240
241 const upfrontCharge = subscriptionPreview?.upfront_charge
242
243 const proratedCredit = upfrontCharge?.prorated_credit ?? 0
244 const customerBalance = upfrontCharge?.customer_balance ?? 0
245 const totalCharge = upfrontCharge?.total ?? 0
246 const tax = upfrontCharge?.tax
247 const taxableAmount = upfrontCharge?.taxable_amount
248 const taxStatus = upfrontCharge?.tax_status
249 const hasTax = taxStatus === 'calculated' && (tax?.tax_amount ?? 0) > 0
250 const taxFailed = taxStatus === 'failed'
251
252 const newPlanCost = Number(subscriptionPlanMeta?.priceMonthly) || 0
253
254 const currentPlanId = subscription?.plan?.id
255 const currentPlanName = subscription?.plan?.name
256
257 // Derives the itemized charge breakdown rows shown above "Charge today".
258 // Example: Pro -> Team upgrade with proration, tax, and credits:
259 // Team Plan $25.00
260 // Tax (10%) $1.67
261 // Subtotal $26.67
262 // Unused Time on Pro -$8.33
263 // Credits -$5.00
264 // ─────────────────────────────
265 // Charge today $13.34
266 const breakdownItems = useMemo(() => {
267 const items: BreakdownItem[] = []
268
269 if (hasTax && tax) {
270 items.push({
271 type: 'amount',
272 label: `Tax (${tax.tax_rate_percentage}%)`,
273 amount: tax.tax_amount,
274 })
275 if (taxableAmount !== newPlanCost) {
276 items.push({ type: 'amount', label: 'Subtotal', amount: taxableAmount! })
277 }
278 }
279
280 if (taxFailed) {
281 items.push({
282 type: 'notice',
283 label: 'Tax could not be estimated and may be applied separately',
284 })
285 }
286
287 if (currentPlanId !== 'free' && proratedCredit > 0) {
288 items.push({
289 type: 'amount',
290 label: `Unused Time on ${currentPlanName} Plan`,
291 amount: -proratedCredit,
292 tooltip:
293 'Your previous plan was charged upfront, so a plan change will prorate any unused time in credits. If the prorated credits exceed the new plan charge, the excessive credits are added to your organization for future use.' +
294 (hasTax ? ' Includes proportional tax if applicable.' : ''),
295 })
296 }
297
298 if (customerBalance > 0) {
299 items.push({
300 type: 'amount',
301 label: 'Credits',
302 amount: -customerBalance,
303 tooltip: 'Credits will be used first before charging your card.',
304 })
305 }
306
307 // Prepend the plan cost row when there are adjustment items to show
308 if (items.length > 0) {
309 items.unshift({
310 type: 'amount',
311 label: `${subscriptionPlanMeta?.name} Plan`,
312 amount: newPlanCost,
313 })
314 }
315
316 return items
317 }, [
318 currentPlanId,
319 currentPlanName,
320 proratedCredit,
321 hasTax,
322 tax,
323 taxableAmount,
324 newPlanCost,
325 taxFailed,
326 customerBalance,
327 subscriptionPlanMeta?.name,
328 ])
329
330 return (
331 <Dialog
332 open={selectedTier !== undefined && selectedTier !== 'tier_free'}
333 onOpenChange={(open) => {
334 // Do not allow closing mid-change
335 if (isUpdating || paymentConfirmationLoading || isConfirming) {
336 return
337 }
338 if (!open) onClose()
339 }}
340 >
341 <DialogContent
342 onOpenAutoFocus={(event) => event.preventDefault()}
343 size="xlarge"
344 className="p-0"
345 >
346 <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 h-full items-stretch">
347 {/* Left Column */}
348 <div className="p-8 pb-8 flex flex-col xl:col-span-3">
349 <div className="flex-1">
350 <div>
351 {!billingViaPartner &&
352 subscriptionPreviewInitialized &&
353 changeType === 'upgrade' && (
354 <div className="space-y-2 mb-4">
355 <PaymentMethodSelection
356 ref={paymentMethodSelectionRef}
357 selectedPaymentMethod={selectedPaymentMethod}
358 onSelectPaymentMethod={(pm) => setSelectedPaymentMethod(pm)}
359 readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
360 onAddressChange={onAddressChange}
361 onTaxIdChange={onTaxIdChange}
362 useAsDefaultBillingAddress={useAsDefaultBillingAddress}
363 onUseAsDefaultBillingAddressChange={onUseAsDefaultBillingAddressChange}
364 />
365 </div>
366 )}
367
368 {billingViaPartner && (
369 <div className="mb-4">
370 <p className="text-sm">
371 This organization is billed through our partner{' '}
372 {billingPartnerLabel(billingPartner)}.{' '}
373 {/* @ts-ignore [Joshen] Might be API types issue */}
374 {billingPartner === 'aws' ? (
375 <>The organization's credit balance will be decreased accordingly.</>
376 ) : (
377 <>You will be charged by them directly.</>
378 )}
379 </p>
380 {billingViaPartner &&
381 billingPartner === 'fly' &&
382 subscriptionPreview?.plan_change_type === 'downgrade' && (
383 <p className="text-sm">
384 Your organization will be downgraded at the end of your current billing
385 cycle.
386 </p>
387 )}
388 </div>
389 )}
390 </div>
391
392 {subscriptionPreviewIsLoading && (
393 <div className="space-y-2 mb-4 mt-2">
394 <ShimmeringLoader />
395 <ShimmeringLoader className="w-3/4" />
396 <ShimmeringLoader className="w-1/2" />
397 </div>
398 )}
399 {subscriptionPreviewInitialized && (
400 <>
401 <div
402 className={cn(
403 'mt-2 mb-4 text-foreground-light text-sm transition-opacity',
404 subscriptionPreviewIsFetching && 'opacity-50'
405 )}
406 >
407 {breakdownItems.map((item, i) =>
408 item.type === 'amount' ? (
409 <div
410 key={i}
411 className="flex items-center justify-between gap-2 border-b border-muted text-xs"
412 >
413 <div className="py-2 pl-0 flex items-center gap-1">
414 <span>{item.label}</span>
415 {item.tooltip && (
416 <InfoTooltip className="max-w-sm">{item.tooltip}</InfoTooltip>
417 )}
418 </div>
419 <div className="py-2 pr-0 text-right tabular-nums" translate="no">
420 {formatCurrency(item.amount)}
421 </div>
422 </div>
423 ) : (
424 <div
425 key={i}
426 className="flex items-center justify-between gap-2 border-b border-muted text-xs"
427 >
428 <div className="py-2 pl-0 text-foreground-lighter">{item.label}</div>
429 </div>
430 )
431 )}
432
433 <div className="flex items-center justify-between gap-2 border-b border-muted text-foreground">
434 <div className="py-2 pl-0">Charge today</div>
435 <div className="py-2 pr-0 text-right tabular-nums" translate="no">
436 {formatCurrency(totalCharge)}
437 {currentPlanId !== 'free' && (
438 <>
439 {' '}
440 <Link
441 href={`/org/${selectedOrganization?.slug}/billing#breakdown`}
442 className="text-sm text-brand hover:text-brand-600 transition"
443 target="_blank"
444 >
445 + current spend
446 </Link>
447 </>
448 )}
449 </div>
450 </div>
451
452 <div className="flex items-center justify-between gap-2 text-foreground-lighter text-xs mt-4">
453 <div className="py-2 pl-0 flex items-center gap-1">
454 <span>Monthly invoice estimate</span>
455 <InvoiceEstimateTooltip
456 subscriptionPreviewQueryResult={subscriptionPreviewQueryResult}
457 />
458 </div>
459 <div className="py-2 pr-0 text-right tabular-nums" translate="no">
460 {formatCurrency(
461 subscriptionPreview?.breakdown.reduce(
462 (prev: number, cur) => prev + cur.total_price,
463 0
464 ) ?? 0
465 )}
466 </div>
467 </div>
468 </div>
469 </>
470 )}
471 </div>
472
473 <div className="pt-4">
474 {projects.filter(
475 (it) =>
476 it.status === PROJECT_STATUS.ACTIVE_HEALTHY ||
477 it.status === PROJECT_STATUS.COMING_UP
478 ).length === 0 &&
479 subscriptionPreview?.plan_change_type !== 'downgrade' && (
480 <div className="pb-2">
481 <Admonition title="Empty organization" type="warning">
482 This organization has no active projects. Did you select the correct
483 organization?
484 </Admonition>
485 </div>
486 )}
487
488 {projects.filter(
489 (it) =>
490 it.status === PROJECT_STATUS.ACTIVE_HEALTHY ||
491 it.status === PROJECT_STATUS.COMING_UP
492 ).length === 1 &&
493 subscriptionPlanMeta?.planId === 'pro' &&
494 changeType === 'upgrade' && (
495 <div className="pb-2">
496 <Admonition type="note">
497 <div className="text-sm prose">
498 First project included. Additional projects cost{' '}
499 <span translate="no">$10</span>+/month regardless of activity.{' '}
500 <Link
501 href={`${DOCS_URL}/guides/platform/manage-your-usage/compute`}
502 target="_blank"
503 className="underline"
504 >
505 Learn more
506 </Link>
507 </div>
508 </Admonition>
509 </div>
510 )}
511
512 <div className="flex space-x-2">
513 <Button
514 loading={isUpdating || paymentConfirmationLoading || isConfirming}
515 disabled={subscriptionPreviewIsLoading || subscriptionPreviewIsFetching}
516 type="primary"
517 onClick={onUpdateSubscription}
518 className="flex-1"
519 size="small"
520 >
521 Confirm {changeType === 'downgrade' ? 'downgrade' : 'upgrade'}
522 </Button>
523 </div>
524 </div>
525 </div>
526
527 {/* Right Column */}
528 <div className="bg-surface-100 p-8 flex flex-col border-l xl:col-span-2">
529 <h3 className="mb-8">
530 {changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '}
531 <span className="font-bold">{selectedOrganization?.name}</span> to{' '}
532 {changeType === 'downgrade'
533 ? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default']
534 : PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}
535 </h3>
536 {changeType === 'downgrade'
537 ? featuresToLose.length > 0 && (
538 <div className="mb-4">
539 <h3 className="text-sm mb-1">Features you'll lose</h3>
540 <p className="text-xs text-foreground-light mb-4">
541 Please review carefully before downgrading.
542 </p>
543 <div className="space-y-2 mb-4 text-foreground-light">
544 {featuresToLose.map((feature) => (
545 <div
546 key={typeof feature === 'string' ? feature : feature[0]}
547 className="flex items-center gap-2"
548 >
549 <div className="w-4">
550 <InfoIcon className="h-3 w-3 text-amber-900" strokeWidth={3} />
551 </div>
552 <p className="text-sm">
553 {typeof feature === 'string' ? feature : feature[0]}
554 </p>
555 </div>
556 ))}
557 </div>
558 </div>
559 )
560 : topFeatures.length > 0 && (
561 <div className="mb-4">
562 <h3 className="text-sm mb-4">Upgrade features</h3>
563
564 <div className="space-y-2 mb-4 text-foreground-light">
565 {topFeatures.map((feature: string | string[]) => (
566 <div
567 key={typeof feature === 'string' ? feature : feature[0]}
568 className="flex items-center gap-2"
569 >
570 <div className="w-4">
571 <Check className="h-3 w-3 text-brand" strokeWidth={3} />
572 </div>
573 <div className="text-sm">
574 <p>{typeof feature === 'string' ? feature : feature[0]}</p>
575 {Array.isArray(feature) && feature.length > 1 && (
576 <p className="text-foreground-lighter text-xs">{feature[1]}</p>
577 )}
578 </div>
579 </div>
580 ))}
581 </div>
582 </div>
583 )}
584 </div>
585 </div>
586
587 {stripePromise && paymentIntentSecret && (
588 <Elements stripe={stripePromise} options={stripeOptionsConfirm}>
589 <PaymentConfirmation
590 paymentIntentSecret={paymentIntentSecret}
591 onPaymentIntentConfirm={(paymentIntentConfirmation) =>
592 paymentIntentConfirmed(paymentIntentConfirmation)
593 }
594 onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)}
595 />
596 </Elements>
597 )}
598 </DialogContent>
599 </Dialog>
600 )
601}