diff --git a/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--dark.png b/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--dark.png index 61651b218428a..7f11e90eb5909 100644 Binary files a/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--dark.png and b/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--light.png b/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--light.png index 44f2168da62eb..09d188c0e82ae 100644 Binary files a/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--light.png and b/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--light.png differ diff --git a/frontend/src/layout/GlobalModals.tsx b/frontend/src/layout/GlobalModals.tsx index 8255c3827af8c..181f02a655ccb 100644 --- a/frontend/src/layout/GlobalModals.tsx +++ b/frontend/src/layout/GlobalModals.tsx @@ -1,7 +1,6 @@ import { actions, kea, path, reducers, useActions, useValues } from 'kea' import { useEffect } from 'react' -import { ConfirmUpgradeModal } from 'lib/components/ConfirmUpgradeModal/ConfirmUpgradeModal' import { ItemSelectModal } from 'lib/components/FileSystem/ItemSelectModal/ItemSelectModal' import { LinkToModal } from 'lib/components/FileSystem/LinkTo/LinkTo' import { MoveToModal } from 'lib/components/FileSystem/MoveTo/MoveTo' @@ -100,7 +99,6 @@ export function GlobalModals(): JSX.Element { - diff --git a/frontend/src/lib/components/ConfirmUpgradeModal/ConfirmUpgradeModal.tsx b/frontend/src/lib/components/ConfirmUpgradeModal/ConfirmUpgradeModal.tsx deleted file mode 100644 index c220d624d8ac2..0000000000000 --- a/frontend/src/lib/components/ConfirmUpgradeModal/ConfirmUpgradeModal.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useActions, useValues } from 'kea' -import { useMemo } from 'react' - -import { IconCheckCircle } from '@posthog/icons' -import { LemonButton, LemonModal, Tooltip } from '@posthog/lemon-ui' - -import { getProration } from 'scenes/billing/billing-utils' -import { billingLogic } from 'scenes/billing/billingLogic' - -import { confirmUpgradeModalLogic } from './confirmUpgradeModalLogic' - -export function ConfirmUpgradeModal(): JSX.Element { - const { upgradePlan } = useValues(confirmUpgradeModalLogic) - const { timeRemainingInSeconds, timeTotalInSeconds, billing } = useValues(billingLogic) - const { hideConfirmUpgradeModal, confirm, cancel } = useActions(confirmUpgradeModalLogic) - - const { prorationAmount, isProrated } = useMemo( - () => - getProration({ - timeRemainingInSeconds, - timeTotalInSeconds, - amountUsd: upgradePlan?.unit_amount_usd, - hasActiveSubscription: billing?.has_active_subscription, - }), - [billing?.has_active_subscription, upgradePlan, timeRemainingInSeconds, timeTotalInSeconds] - ) - - return ( - - cancel()}> - Cancel - - confirm()}> - Sign me up - - - } - > -
-

- Woo! You're gonna love the {upgradePlan?.name}. We're just confirming that this is a $ - {Number(upgradePlan?.unit_amount_usd)} / {upgradePlan?.unit} subscription.{' '} - {isProrated - ? `The first payment will be prorated to ~$${prorationAmount} and it will be charged immediately.` - : 'The first payment will be charged immediately.'} -

- {upgradePlan && upgradePlan?.features?.length > 1 && ( -
-

Here are the features included:

-
- {upgradePlan?.features.map((feature, index) => ( -
- - - - {feature.name} - {feature.note ? ': ' + feature.note : ''} - - -
- ))} -
-
- )} -
-
- ) -} diff --git a/frontend/src/lib/components/ConfirmUpgradeModal/confirmUpgradeModalLogic.ts b/frontend/src/lib/components/ConfirmUpgradeModal/confirmUpgradeModalLogic.ts deleted file mode 100644 index a004d0a6a0e56..0000000000000 --- a/frontend/src/lib/components/ConfirmUpgradeModal/confirmUpgradeModalLogic.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { actions, kea, listeners, path, reducers } from 'kea' - -import { BillingPlanType } from '~/types' - -import type { confirmUpgradeModalLogicType } from './confirmUpgradeModalLogicType' - -export const confirmUpgradeModalLogic = kea([ - path(['lib', 'components', 'ConfirmUpgradeModal', 'confirmUpgradeModalLogic']), - actions({ - showConfirmUpgradeModal: ( - upgradePlan: BillingPlanType, - confirmCallback: () => void, - cancelCallback: () => void - ) => ({ - upgradePlan, - confirmCallback, - cancelCallback, - }), - hideConfirmUpgradeModal: true, - confirm: true, - cancel: true, - }), - reducers({ - upgradePlan: [ - null as BillingPlanType | null, - { - showConfirmUpgradeModal: (_, { upgradePlan }) => upgradePlan, - hideConfirmUpgradeModal: () => null, - }, - ], - confirmCallback: [ - null as (() => void) | null, - { - showConfirmUpgradeModal: (_, { confirmCallback }) => confirmCallback, - hideConfirmUpgradeModal: () => null, - }, - ], - cancelCallback: [ - null as (() => void) | null, - { - showConfirmUpgradeModal: (_, { cancelCallback }) => cancelCallback, - hideConfirmUpgradeModal: () => null, - }, - ], - }), - listeners(({ actions, values }) => ({ - confirm: async (_, breakpoint) => { - await breakpoint(100) - if (values.confirmCallback) { - values.confirmCallback() - } - actions.hideConfirmUpgradeModal() - }, - cancel: async (_, breakpoint) => { - await breakpoint(100) - if (values.cancelCallback) { - values.cancelCallback() - } - actions.hideConfirmUpgradeModal() - }, - })), -]) diff --git a/frontend/src/lib/utils/eventUsageLogic.ts b/frontend/src/lib/utils/eventUsageLogic.ts index b997a2a57b10a..e8958c5a359c3 100644 --- a/frontend/src/lib/utils/eventUsageLogic.ts +++ b/frontend/src/lib/utils/eventUsageLogic.ts @@ -561,6 +561,15 @@ export const eventUsageLogic = kea([ reportActivationSideBarTaskClicked: (key: string) => ({ key }), reportBillingUpgradeClicked: (plan: string) => ({ plan }), reportBillingDowngradeClicked: (plan: string) => ({ plan }), + reportBillingAddonPlanSwitchStarted: ( + fromProduct: string, + toProduct: string, + reason: 'upgrade' | 'downgrade' + ) => ({ + fromProduct, + toProduct, + reason, + }), reportRoleCreated: (role: string) => ({ role }), reportFlagsCodeExampleInteraction: (optionType: string) => ({ optionType, @@ -1244,6 +1253,16 @@ export const eventUsageLogic = kea([ plan, }) }, + reportBillingAddonPlanSwitchStarted: ({ fromProduct, toProduct, reason }) => { + const eventName = + reason === 'upgrade' + ? 'billing addon subscription upgrade clicked' + : 'billing addon subscription downgrade clicked' + posthog.capture(eventName, { + from_product: fromProduct, + to_product: toProduct, + }) + }, reportRoleCreated: ({ role }) => { posthog.capture('new role created', { role, diff --git a/frontend/src/scenes/billing/BillingProductAddon.tsx b/frontend/src/scenes/billing/BillingProductAddon.tsx index 0184c204da3b4..c186d4c03a052 100644 --- a/frontend/src/scenes/billing/BillingProductAddon.tsx +++ b/frontend/src/scenes/billing/BillingProductAddon.tsx @@ -13,6 +13,8 @@ import { BillingProductV2AddonType } from '~/types' import { BillingAddonFeaturesList } from './BillingAddonFeaturesList' import { BillingProductAddonActions } from './BillingProductAddonActions' +import { ConfirmDowngradeModal } from './ConfirmDowngradeModal' +import { ConfirmUpgradeModal } from './ConfirmUpgradeModal' import { ProductPricingModal } from './ProductPricingModal' import { TrialCancellationSurveyModal } from './TrialCancellationSurveyModal' import { UnsubscribeSurveyModal } from './UnsubscribeSurveyModal' @@ -165,6 +167,10 @@ export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonTyp {surveyID === UNSUBSCRIBE_SURVEY_ID && } {/* Trial cancellation survey modal */} {surveyID === TRIAL_CANCELLATION_SURVEY_ID && } + {/* Confirm platform addon subscription upgrade */} + + {/* Confirm platform addon subscription downgrade */} + ) } diff --git a/frontend/src/scenes/billing/BillingProductAddonActions.tsx b/frontend/src/scenes/billing/BillingProductAddonActions.tsx index babb29b2898d2..f59519b1611fa 100644 --- a/frontend/src/scenes/billing/BillingProductAddonActions.tsx +++ b/frontend/src/scenes/billing/BillingProductAddonActions.tsx @@ -1,5 +1,4 @@ import { useActions, useValues } from 'kea' -import { useMemo } from 'react' import { IconCheckCircle, IconPlus } from '@posthog/icons' import { LemonButton, LemonButtonProps, LemonTag, Tooltip } from '@posthog/lemon-ui' @@ -9,11 +8,11 @@ import { dayjs } from 'lib/dayjs' import { More } from 'lib/lemon-ui/LemonButton/More' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { toSentenceCase } from 'lib/utils' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { BillingProductV2AddonType } from '~/types' import { formatFlatRate } from './BillingProductAddon' -import { getProration } from './billing-utils' import { billingLogic } from './billingLogic' import { billingProductLogic } from './billingProductLogic' import { DATA_PIPELINES_CUTOFF_DATE } from './constants' @@ -31,15 +30,7 @@ export const BillingProductAddonActions = ({ buttonSize, ctaTextOverride, }: BillingProductAddonActionsProps): JSX.Element => { - const { - billing, - billingError, - switchPlanLoading, - timeTotalInSeconds, - timeRemainingInSeconds, - currentPlatformAddon, - } = useValues(billingLogic) - const { switchFlatrateSubscriptionPlan } = useActions(billingLogic) + const { billing, billingError, currentPlatformAddon, unusedPlatformAddonAmount } = useValues(billingLogic) const { featureFlags } = useValues(featureFlagLogic) const { currentAndUpgradePlans, @@ -48,21 +39,15 @@ export const BillingProductAddonActions = ({ isSubscribedToAnotherAddon, isDataPipelinesDeprecated, isLowerTierThanCurrentAddon, + proratedAmount, + isProrated, } = useValues(billingProductLogic({ product: addon, productRef })) const { toggleIsPricingModalOpen, reportSurveyShown, setSurveyResponse, initiateProductUpgrade, activateTrial } = useActions(billingProductLogic({ product: addon })) + const { showConfirmUpgradeModal, showConfirmDowngradeModal } = useActions(billingProductLogic({ product: addon })) + const { reportBillingAddonPlanSwitchStarted } = useActions(eventUsageLogic) const upgradePlan = currentAndUpgradePlans?.upgradePlan - const { prorationAmount, isProrated } = useMemo( - () => - getProration({ - timeRemainingInSeconds, - timeTotalInSeconds, - amountUsd: upgradePlan?.unit_amount_usd, - hasActiveSubscription: billing?.has_active_subscription, - }), - [billing?.has_active_subscription, upgradePlan, timeRemainingInSeconds, timeTotalInSeconds] - ) const isTrialEligible = !!addon.trial const isSwitchPlanEnabled = !!featureFlags[FEATURE_FLAGS.SWITCH_SUBSCRIPTION_PLAN] @@ -198,7 +183,19 @@ export const BillingProductAddonActions = ({ if (isProrated && !isSubscribedToAnotherAddon) { return (

- Pay ~${prorationAmount} today (prorated) and + Pay ~${proratedAmount.toFixed(2)} today (prorated) and +
+ {formatFlatRate(Number(upgradePlan?.unit_amount_usd), upgradePlan?.unit)} every month thereafter. +

+ ) + } + + // Upgrading from another add-on to this one + if (isSwitchPlanEnabled && isSubscribedToAnotherAddon && !isLowerTierThanCurrentAddon && isProrated) { + const amountDue = Math.max(0, proratedAmount - unusedPlatformAddonAmount) + return ( +

+ Pay ~${amountDue.toFixed(2)} today (prorated) and
{formatFlatRate(Number(upgradePlan?.unit_amount_usd), upgradePlan?.unit)} every month thereafter.

@@ -218,16 +215,10 @@ export const BillingProductAddonActions = ({ overlay={ - switchFlatrateSubscriptionPlan({ - from_product_key: String(currentPlatformAddon?.type), - from_plan_key: String(currentPlatformAddon?.plans[0].plan_key), - to_product_key: addon.type, - to_plan_key: String(upgradePlan?.plan_key), - }) - } + onClick={() => { + reportBillingAddonPlanSwitchStarted(currentPlatformAddon.type, addon.type, 'downgrade') + showConfirmDowngradeModal() + }} > Downgrade @@ -253,20 +244,14 @@ export const BillingProductAddonActions = ({ - switchFlatrateSubscriptionPlan({ - from_product_key: String(currentPlatformAddon?.type), - from_plan_key: String(currentPlatformAddon?.plans[0].plan_key), - to_product_key: addon.type, - to_plan_key: String(upgradePlan?.plan_key), - }) - } + onClick={() => { + reportBillingAddonPlanSwitchStarted(currentPlatformAddon.type, addon.type, 'upgrade') + showConfirmUpgradeModal() + }} > Upgrade - // TODO: show prorated amount similar to renderPricingInfo ) } diff --git a/frontend/src/scenes/billing/ConfirmDowngradeModal.tsx b/frontend/src/scenes/billing/ConfirmDowngradeModal.tsx new file mode 100644 index 0000000000000..b4913147849c7 --- /dev/null +++ b/frontend/src/scenes/billing/ConfirmDowngradeModal.tsx @@ -0,0 +1,129 @@ +import { useActions, useValues } from 'kea' + +import { LemonButton, LemonModal, LemonTable, LemonTableColumns, Link } from '@posthog/lemon-ui' + +import { supportLogic } from 'lib/components/Support/supportLogic' +import { dayjs } from 'lib/dayjs' + +import { BillingInvoiceItemRow, BillingProductV2AddonType } from '~/types' + +import { AddonFeatureLossNotice } from './AddonFeatureLossNotice' +import { billingLogic } from './billingLogic' +import { billingProductLogic } from './billingProductLogic' + +export function ConfirmDowngradeModal({ product }: { product: BillingProductV2AddonType }): JSX.Element | null { + const { currentPlatformAddon, unusedPlatformAddonAmount, switchPlanLoading, billing } = useValues(billingLogic) + const { currentAndUpgradePlans, confirmDowngradeModalOpen, proratedAmount } = useValues( + billingProductLogic({ product }) + ) + const { hideConfirmDowngradeModal, confirmProductDowngrade } = useActions(billingProductLogic({ product })) + const { openSupportForm } = useActions(supportLogic) + + const isLoading = switchPlanLoading === product.type + + const targetPlan = currentAndUpgradePlans?.upgradePlan + const fullMonthlyPrice = parseFloat(String(targetPlan?.unit_amount_usd || '0')) + const nextInvoiceEstimate = Math.max(0, fullMonthlyPrice + proratedAmount - unusedPlatformAddonAmount) + const creditCoversNextInvoice = unusedPlatformAddonAmount > proratedAmount + fullMonthlyPrice + + const periodEnd = billing?.billing_period?.current_period_end + const remainingPeriodFormatted = periodEnd + ? `${dayjs().format('MMM D')} - ${periodEnd.format('MMM D, YYYY')}` + : undefined + + if (!confirmDowngradeModalOpen || !targetPlan || !currentPlatformAddon) { + return null + } + + const rows: BillingInvoiceItemRow[] = [ + { + description: `Credit for unused time on ${currentPlatformAddon.name}`, + dateRange: remainingPeriodFormatted, + amount: `-$${unusedPlatformAddonAmount.toFixed(2)}`, + }, + { + description: `Remaining time on ${product.name}`, + dateRange: remainingPeriodFormatted, + amount: `$${proratedAmount.toFixed(2)}`, + }, + { + description: `${product.name} subscription`, + dateRange: periodEnd ? `From ${periodEnd.format('MMM D, YYYY')}` : undefined, + amount: `$${fullMonthlyPrice.toFixed(2)}`, + }, + { + description: `Estimated next invoice for ${product.name}`, + amount: `$${nextInvoiceEstimate.toFixed(2)}`, + isBold: true, + }, + ] + + const columns: LemonTableColumns = [ + { + title: 'Description', + dataIndex: 'description', + render: (_, row) => ( +
+
{row.description}
+ {row.dateRange &&
{row.dateRange}
} +
+ ), + }, + { + title: 'Amount', + dataIndex: 'amount', + align: 'right', + render: (_, row) =>
{row.amount}
, + }, + ] + + return ( + + + Cancel + + + Confirm + + + } + > +
+

+ You'll lose access to {currentPlatformAddon.name} features immediately. We'll apply credit for + unused time to your next invoice(s). +

+ + + + + + {creditCoversNextInvoice && ( +
+ Remaining credit $ + {Math.max(0, unusedPlatformAddonAmount - proratedAmount - fullMonthlyPrice).toFixed(2)} will + apply to other usage-based charges or roll over to future invoices.{' '} + { + hideConfirmDowngradeModal() + openSupportForm({ target_area: 'billing' }) + }} + > + Request a refund instead. + +
+ )} +
+
+ ) +} diff --git a/frontend/src/scenes/billing/ConfirmUpgradeModal.tsx b/frontend/src/scenes/billing/ConfirmUpgradeModal.tsx new file mode 100644 index 0000000000000..e9ee234b12228 --- /dev/null +++ b/frontend/src/scenes/billing/ConfirmUpgradeModal.tsx @@ -0,0 +1,100 @@ +import { useActions, useValues } from 'kea' + +import { LemonButton, LemonModal, LemonTable, LemonTableColumns } from '@posthog/lemon-ui' + +import { dayjs } from 'lib/dayjs' +import { billingLogic } from 'scenes/billing/billingLogic' +import { billingProductLogic } from 'scenes/billing/billingProductLogic' + +import { BillingInvoiceItemRow, BillingProductV2AddonType } from '~/types' + +export function ConfirmUpgradeModal({ product }: { product: BillingProductV2AddonType }): JSX.Element | null { + const { currentPlatformAddon, unusedPlatformAddonAmount, switchPlanLoading, billing } = useValues(billingLogic) + const { currentAndUpgradePlans, confirmUpgradeModalOpen, proratedAmount } = useValues( + billingProductLogic({ product }) + ) + const { hideConfirmUpgradeModal, confirmProductUpgrade } = useActions(billingProductLogic({ product })) + + const isLoading = switchPlanLoading === product.type + + const targetPlan = currentAndUpgradePlans?.upgradePlan + const amountDue = Math.max(0, proratedAmount - unusedPlatformAddonAmount) + + const periodEnd = billing?.billing_period?.current_period_end + const remainingPeriodFormatted = periodEnd + ? `${dayjs().format('MMM D')} - ${periodEnd.format('MMM D, YYYY')}` + : undefined + + if (!confirmUpgradeModalOpen || !targetPlan || !currentPlatformAddon) { + return null + } + + const rows: BillingInvoiceItemRow[] = [ + { + description: `Remaining time on ${product.name}`, + dateRange: remainingPeriodFormatted, + amount: `$${proratedAmount.toFixed(2)}`, + }, + { + description: `Unused time on ${currentPlatformAddon.name}`, + dateRange: remainingPeriodFormatted, + amount: `-$${unusedPlatformAddonAmount.toFixed(2)}`, + }, + { + description: 'Amount due today', + amount: `$${amountDue.toFixed(2)}`, + isBold: true, + }, + ] + + const columns: LemonTableColumns = [ + { + title: 'Description', + dataIndex: 'description', + render: (_, row) => ( +
+
{row.description}
+ {row.dateRange &&
{row.dateRange}
} +
+ ), + }, + { + title: 'Amount', + dataIndex: 'amount', + align: 'right', + render: (_, row) =>
{row.amount}
, + }, + ] + + return ( + + + Cancel + + + Confirm + + + } + > +
+

+ You'll get access to all {product.name} features right away. ${amountDue.toFixed(2)} will be charged + now for the remaining period until {billing?.billing_period?.current_period_end?.format('MMM D')}, + and ${targetPlan.unit_amount_usd} every {targetPlan.unit} thereafter. +

+ +
+
+ ) +} diff --git a/frontend/src/scenes/billing/billingLogic.tsx b/frontend/src/scenes/billing/billingLogic.tsx index 5a2b1a3789555..f16d5b4441dcb 100644 --- a/frontend/src/scenes/billing/billingLogic.tsx +++ b/frontend/src/scenes/billing/billingLogic.tsx @@ -395,6 +395,7 @@ export const billingLogic = kea([ const productDisplayName = capitalizeFirstLetter(data.to_product_key) lemonToast.success(`You're now on ${productDisplayName}`) + actions.setSwitchPlanLoading(null) // Reload billing, user, and organization to get the updated available features actions.loadBilling() @@ -409,6 +410,7 @@ export const billingLogic = kea([ (error && error.detail) || 'There was an error switching your plan. Please try again or contact support.' ) + actions.setSwitchPlanLoading(null) // Keep the current billing state on failure return values.billing as BillingType } @@ -557,6 +559,23 @@ export const billingLogic = kea([ return platformProduct?.addons?.find((addon: BillingProductV2AddonType) => !!addon.subscribed) || null }, ], + unusedPlatformAddonAmount: [ + (s) => [s.currentPlatformAddon, s.timeRemainingInSeconds, s.timeTotalInSeconds], + ( + currentPlatformAddon: BillingProductV2AddonType | null, + timeRemainingInSeconds: number, + timeTotalInSeconds: number + ): number => { + if (!currentPlatformAddon || !timeTotalInSeconds) { + return 0 + } + const currentPlan = currentPlatformAddon.plans?.[0] + const unitAmount = parseFloat(currentPlan?.unit_amount_usd || '0') + const ratio = Math.max(0, Math.min(1, timeRemainingInSeconds / timeTotalInSeconds)) + const amount = unitAmount * ratio + return Math.round(amount * 100) / 100 + }, + ], creditDiscount: [(s) => [s.computedDiscount], (computedDiscount) => computedDiscount || 0], billingPlan: [ (s) => [s.billing], @@ -753,12 +772,6 @@ export const billingLogic = kea([ switchFlatrateSubscriptionPlan: async (payload) => { actions.setSwitchPlanLoading(payload.to_product_key) }, - switchFlatrateSubscriptionPlanSuccess: () => { - actions.setSwitchPlanLoading(null) - }, - switchFlatrateSubscriptionPlanFailure: () => { - actions.setSwitchPlanLoading(null) - }, loadBillingSuccess: async (_, breakpoint) => { actions.registerInstrumentationProps() actions.determineBillingAlert() diff --git a/frontend/src/scenes/billing/billingProductLogic.ts b/frontend/src/scenes/billing/billingProductLogic.ts index 2c660ab020a0c..572df7f81e8f6 100644 --- a/frontend/src/scenes/billing/billingProductLogic.ts +++ b/frontend/src/scenes/billing/billingProductLogic.ts @@ -90,6 +90,8 @@ export const billingProductLogic = kea([ 'unsubscribeError', 'currentPlatformAddon', 'platformAddons', + 'timeRemainingInSeconds', + 'timeTotalInSeconds', ], featureFlagLogic, ['featureFlags'], @@ -105,6 +107,8 @@ export const billingProductLogic = kea([ 'setProductSpecificAlert', 'setScrollToProductKey', 'deactivateProductSuccess', + 'switchFlatrateSubscriptionPlan', + 'setSwitchPlanLoading', ], ], })), @@ -147,6 +151,12 @@ export const billingProductLogic = kea([ setHedgehogSatisfied: (satisfied: boolean) => ({ satisfied }), triggerMoreHedgehogs: true, removeBillingLimitNextPeriod: (productType: string) => ({ productType }), + showConfirmUpgradeModal: true, + hideConfirmUpgradeModal: true, + confirmProductUpgrade: true, + showConfirmDowngradeModal: true, + hideConfirmDowngradeModal: true, + confirmProductDowngrade: true, }), reducers({ billingLimitInput: [ @@ -252,8 +262,45 @@ export const billingProductLogic = kea([ setHedgehogSatisfied: (_, { satisfied }) => satisfied, }, ], + confirmUpgradeModalOpen: [ + false as boolean, + { + showConfirmUpgradeModal: () => true, + hideConfirmUpgradeModal: () => false, + }, + ], + confirmDowngradeModalOpen: [ + false as boolean, + { + showConfirmDowngradeModal: () => true, + hideConfirmDowngradeModal: () => false, + }, + ], }), selectors(({ values }) => ({ + proratedAmount: [ + (s) => [s.currentAndUpgradePlans, s.timeRemainingInSeconds, s.timeTotalInSeconds], + (currentAndUpgradePlans, timeRemainingInSeconds, timeTotalInSeconds): number => { + if (!timeTotalInSeconds) { + return 0 + } + const amountUsd = currentAndUpgradePlans.upgradePlan?.unit_amount_usd + const unitAmountInt = amountUsd ? parseFloat(amountUsd) : 0 + const ratio = Math.max(0, Math.min(1, timeRemainingInSeconds / timeTotalInSeconds)) // make sure ratio is between 0 and 1 + return Math.round(unitAmountInt * ratio * 100) / 100 + }, + ], + isProrated: [ + (s) => [s.billing, s.currentAndUpgradePlans, s.proratedAmount], + (billing, currentAndUpgradePlans, proratedAmount): boolean => { + const hasActiveSubscription = billing?.has_active_subscription + const amountUsd = currentAndUpgradePlans.upgradePlan?.unit_amount_usd + if (!hasActiveSubscription || !amountUsd) { + return false + } + return proratedAmount !== parseFloat(amountUsd) + }, + ], isLowerTierThanCurrentAddon: [ (s, p) => [p.product, s.currentPlatformAddon, s.platformAddons], (product, currentPlatformAddon, platformAddons): boolean => { @@ -614,6 +661,42 @@ export const billingProductLogic = kea([ redirectPath && `&redirect_path=${redirectPath}` }` }, + confirmProductUpgrade: () => { + const upgradePlan = values.currentAndUpgradePlans.upgradePlan + const currentPlatformAddon = values.currentPlatformAddon + if (!upgradePlan || !currentPlatformAddon) { + return + } + actions.switchFlatrateSubscriptionPlan({ + from_product_key: String(currentPlatformAddon.type), + from_plan_key: String(currentPlatformAddon.plans?.[0]?.plan_key), + to_product_key: props.product.type, + to_plan_key: String(upgradePlan.plan_key), + }) + }, + confirmProductDowngrade: () => { + const targetPlan = values.currentAndUpgradePlans.upgradePlan + const currentPlatformAddon = values.currentPlatformAddon + if (!targetPlan || !currentPlatformAddon) { + return + } + actions.switchFlatrateSubscriptionPlan({ + from_product_key: String(currentPlatformAddon.type), + from_plan_key: String(currentPlatformAddon.plans?.[0]?.plan_key), + to_product_key: props.product.type, + to_plan_key: String(targetPlan.plan_key), + }) + }, + setSwitchPlanLoading: ({ productKey }) => { + if (productKey === null) { + if (values.confirmUpgradeModalOpen) { + actions.hideConfirmUpgradeModal() + } + if (values.confirmDowngradeModalOpen) { + actions.hideConfirmDowngradeModal() + } + } + }, activateTrial: async (_, breakpoint) => { actions.setTrialLoading(true) try { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9ce4da8cdf6db..721c238a2af61 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -5266,6 +5266,13 @@ export type BillingTableTierRow = { subrows: ProductPricingTierSubrows } +export type BillingInvoiceItemRow = { + description: string + dateRange?: string + amount: string + isBold?: boolean +} + export type AvailableOnboardingProducts = Record< | ProductKey.PRODUCT_ANALYTICS | ProductKey.SESSION_REPLAY