diff --git a/ee/api/billing.py b/ee/api/billing.py index 730e6f4d4efac..7ccadd17229ae 100644 --- a/ee/api/billing.py +++ b/ee/api/billing.py @@ -269,6 +269,13 @@ def deactivate(self, request: Request, *args: Any, **kwargs: Any) -> HttpRespons return self.list(request, *args, **kwargs) + @action(methods=["POST"], detail=False, url_path="subscription/switch-plan") + def subscription_switch_plan(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse: + organization = self._get_org_required() + billing_manager = self.get_billing_manager() + res = billing_manager.switch_plan(organization, request.data) + return Response(res, status=status.HTTP_200_OK) + @action(methods=["GET"], detail=False) def portal(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse: license = get_cached_instance_license() diff --git a/ee/billing/billing_manager.py b/ee/billing/billing_manager.py index 85c94329ab302..e1411a0836c22 100644 --- a/ee/billing/billing_manager.py +++ b/ee/billing/billing_manager.py @@ -462,6 +462,18 @@ def authorize_status(self, organization: Organization, data: dict[str, Any]): return res.json() + def switch_plan(self, organization: Organization, data: dict[str, Any]) -> dict[str, Any]: + res = requests.post( + f"{BILLING_SERVICE_URL}/api/subscription/switch-plan/", + headers=self.get_auth_headers(organization), + json=data, + ) + + handle_billing_service_error(res) + self.update_available_product_features(organization) + + return res.json() + def apply_startup_program(self, organization: Organization, data: dict[str, Any]) -> dict[str, Any]: res = requests.post( f"{BILLING_SERVICE_URL}/api/startups/apply", diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 0d596a668f7ee..7ae9c37eb8498 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -298,6 +298,7 @@ export const FEATURE_FLAGS = { IMPROVED_COOKIELESS_MODE: 'improved-cookieless-mode', // owner: @robbie-c #team-web-analytics REPLAY_EXPORT_FULL_VIDEO: 'replay-export-full-video', // owner: @veryayskiy #team-replay PLATFORM_PAYGATE_CTA: 'platform-paygate-cta', // owner: @a-lider #team-platform-features + SWITCH_SUBSCRIPTION_PLAN: 'switch-subscription-plan', // owner: @a-lider #team-platform-features LLM_ANALYTICS_DATASETS: 'llm-analytics-datasets', // owner: #team-llm-analytics #team-max-ai LLM_ANALYTICS_TRACES_QUERY_V2: 'llma-traces-query-v2', // owner: #team-llm-analytics AMPLITUDE_BATCH_IMPORT_OPTIONS: 'amplitude-batch-import-options', // owner: #team-ingestion diff --git a/frontend/src/scenes/billing/BillingProductAddonActions.tsx b/frontend/src/scenes/billing/BillingProductAddonActions.tsx index aa2d2b63f104d..babb29b2898d2 100644 --- a/frontend/src/scenes/billing/BillingProductAddonActions.tsx +++ b/frontend/src/scenes/billing/BillingProductAddonActions.tsx @@ -4,9 +4,10 @@ import { useMemo } from 'react' import { IconCheckCircle, IconPlus } from '@posthog/icons' import { LemonButton, LemonButtonProps, LemonTag, Tooltip } from '@posthog/lemon-ui' -import { TRIAL_CANCELLATION_SURVEY_ID, UNSUBSCRIBE_SURVEY_ID } from 'lib/constants' +import { FEATURE_FLAGS, TRIAL_CANCELLATION_SURVEY_ID, UNSUBSCRIBE_SURVEY_ID } from 'lib/constants' 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 { BillingProductV2AddonType } from '~/types' @@ -30,13 +31,23 @@ export const BillingProductAddonActions = ({ buttonSize, ctaTextOverride, }: BillingProductAddonActionsProps): JSX.Element => { - const { billing, billingError, timeTotalInSeconds, timeRemainingInSeconds } = useValues(billingLogic) + const { + billing, + billingError, + switchPlanLoading, + timeTotalInSeconds, + timeRemainingInSeconds, + currentPlatformAddon, + } = useValues(billingLogic) + const { switchFlatrateSubscriptionPlan } = useActions(billingLogic) + const { featureFlags } = useValues(featureFlagLogic) const { currentAndUpgradePlans, billingProductLoading, trialLoading, isSubscribedToAnotherAddon, isDataPipelinesDeprecated, + isLowerTierThanCurrentAddon, } = useValues(billingProductLogic({ product: addon, productRef })) const { toggleIsPricingModalOpen, reportSurveyShown, setSurveyResponse, initiateProductUpgrade, activateTrial } = @@ -53,6 +64,7 @@ export const BillingProductAddonActions = ({ [billing?.has_active_subscription, upgradePlan, timeRemainingInSeconds, timeTotalInSeconds] ) const isTrialEligible = !!addon.trial + const isSwitchPlanEnabled = !!featureFlags[FEATURE_FLAGS.SWITCH_SUBSCRIPTION_PLAN] const renderSubscribedActions = (): JSX.Element | null => { if (addon.contact_support) { @@ -196,6 +208,68 @@ export const BillingProductAddonActions = ({ return null } + const renderDowngradeActions = (): JSX.Element | null => { + if (!upgradePlan || !currentPlatformAddon) { + return null + } + + return ( + + 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), + }) + } + > + Downgrade + + } + /> + ) + } + + const renderUpgradeActions = (): JSX.Element | null => { + if (!upgradePlan || !currentPlatformAddon) { + return null + } + + const showPricing = upgradePlan.flat_rate + + return ( + <> + {showPricing && ( +

+ {formatFlatRate(Number(upgradePlan.unit_amount_usd), upgradePlan.unit)} +

+ )} + + + 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), + }) + } + > + Upgrade + + + // TODO: show prorated amount similar to renderPricingInfo + ) + } + let content if (addon.subscribed && !addon.inclusion_only) { content = renderSubscribedActions() @@ -219,6 +293,10 @@ export const BillingProductAddonActions = ({ // We don't allow multiple add-ons to be subscribed to at the same time so this checks if the customer is subscribed to another add-on // TODO: add support for when a customer has a Paid Plan trial content = renderPurchaseActions() + } else if (!billing?.trial && isSubscribedToAnotherAddon && isLowerTierThanCurrentAddon && isSwitchPlanEnabled) { + content = renderDowngradeActions() + } else if (!billing?.trial && isSubscribedToAnotherAddon && !isLowerTierThanCurrentAddon && isSwitchPlanEnabled) { + content = renderUpgradeActions() } return ( diff --git a/frontend/src/scenes/billing/billingLogic.tsx b/frontend/src/scenes/billing/billingLogic.tsx index a974ad740b47b..5a2b1a3789555 100644 --- a/frontend/src/scenes/billing/billingLogic.tsx +++ b/frontend/src/scenes/billing/billingLogic.tsx @@ -23,6 +23,7 @@ import { BillingPeriod, BillingPlan, BillingPlanType, + BillingProductV2AddonType, BillingProductV2Type, BillingType, ProductKey, @@ -66,6 +67,13 @@ export interface BillingError { action: LemonButtonPropsBase } +export type SwitchPlanPayload = { + from_product_key: string + from_plan_key: string + to_product_key: string + to_plan_key: string +} + const parseBillingResponse = (data: Partial): BillingType => { if (data.billing_period) { data.billing_period = { @@ -162,6 +170,7 @@ export const billingLogic = kea([ setComputedDiscount: (discount: number) => ({ discount }), setCreditBrackets: (creditBrackets: any[]) => ({ creditBrackets }), scrollToProduct: (productType: string) => ({ productType }), + setSwitchPlanLoading: (productKey: string | null) => ({ productKey }), }), connect(() => ({ values: [ @@ -280,6 +289,12 @@ export const billingLogic = kea([ setCreditBrackets: (_, { creditBrackets }) => creditBrackets || [], }, ], + switchPlanLoading: [ + null as string | null, + { + setSwitchPlanLoading: (_, { productKey }) => productKey, + }, + ], }), lazyLoaders(({ actions, values }) => ({ billing: [ @@ -374,6 +389,30 @@ export const billingLogic = kea([ return values.billing } }, + switchFlatrateSubscriptionPlan: async (data: SwitchPlanPayload, breakpoint) => { + try { + await api.create('api/billing/subscription/switch-plan', data) + + const productDisplayName = capitalizeFirstLetter(data.to_product_key) + lemonToast.success(`You're now on ${productDisplayName}`) + + // Reload billing, user, and organization to get the updated available features + actions.loadBilling() + await breakpoint(2000) + actions.loadUser() + actions.loadCurrentOrganization() + + return values.billing as BillingType + } catch (error: any) { + posthog.captureException(error) + lemonToast.error( + (error && error.detail) || + 'There was an error switching your plan. Please try again or contact support.' + ) + // Keep the current billing state on failure + return values.billing as BillingType + } + }, }, ], billingError: [ @@ -500,6 +539,24 @@ export const billingLogic = kea([ ?.addons.find((addon) => addon.plans.find((plan) => plan.current_plan)) }, ], + platformAddons: [ + (s) => [s.billing], + (billing: BillingType): BillingProductV2AddonType[] => { + const platformProduct = billing?.products?.find( + (product: BillingProductV2Type) => product.type === ProductKey.PLATFORM_AND_SUPPORT + ) + return platformProduct?.addons ?? [] + }, + ], + currentPlatformAddon: [ + (s) => [s.billing], + (billing: BillingType): BillingProductV2AddonType | null => { + const platformProduct = billing?.products?.find( + (product: BillingProductV2Type) => product.type === ProductKey.PLATFORM_AND_SUPPORT + ) + return platformProduct?.addons?.find((addon: BillingProductV2AddonType) => !!addon.subscribed) || null + }, + ], creditDiscount: [(s) => [s.computedDiscount], (computedDiscount) => computedDiscount || 0], billingPlan: [ (s) => [s.billing], @@ -693,6 +750,15 @@ export const billingLogic = kea([ posthog.capture('credits cta hero dismissed') } }, + 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 8a494a2cee259..2c660ab020a0c 100644 --- a/frontend/src/scenes/billing/billingProductLogic.ts +++ b/frontend/src/scenes/billing/billingProductLogic.ts @@ -83,7 +83,14 @@ export const billingProductLogic = kea([ connect(() => ({ values: [ billingLogic, - ['billing', 'isUnlicensedDebug', 'scrollToProductKey', 'unsubscribeError'], + [ + 'billing', + 'isUnlicensedDebug', + 'scrollToProductKey', + 'unsubscribeError', + 'currentPlatformAddon', + 'platformAddons', + ], featureFlagLogic, ['featureFlags'], ], @@ -247,6 +254,20 @@ export const billingProductLogic = kea([ ], }), selectors(({ values }) => ({ + isLowerTierThanCurrentAddon: [ + (s, p) => [p.product, s.currentPlatformAddon, s.platformAddons], + (product, currentPlatformAddon, platformAddons): boolean => { + if (!currentPlatformAddon || platformAddons.length === 0) { + return false + } + const currentIdx = platformAddons.findIndex((a) => a.type === currentPlatformAddon.type) + const targetIdx = platformAddons.findIndex((a) => a.type === product.type) + if (currentIdx < 0 || targetIdx < 0) { + return false + } + return targetIdx < currentIdx + }, + ], isSubscribedToAnotherAddon: [ (s, p) => [s.billing, p.product], (billing: BillingType, addon: BillingProductV2AddonType) => {