Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ee/api/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +272 to +277
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Missing error handling for the switch_plan operation. Other billing endpoints in this file (like deactivate, get_invoices) have try-catch blocks to handle billing service errors gracefully and return appropriate error responses.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ee/api/billing.py
Line: 272:277

Comment:
**logic:** Missing error handling for the switch_plan operation. Other billing endpoints in this file (like deactivate, get_invoices) have try-catch blocks to handle billing service errors gracefully and return appropriate error responses.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Contributor Author

@a-lider a-lider Oct 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the billing request fails, this will return a 500 error, and the user sees a toast "There was an error switching your plan. Please try again or contact support." This is handled in billingLogic.tsx
So I don’t think we need to expose detailed errors from billing (like validation errors).


@action(methods=["GET"], detail=False)
def portal(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
license = get_cached_instance_license()
Expand Down
12 changes: 12 additions & 0 deletions ee/billing/billing_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like flaky test, unrelated change

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions frontend/src/lib/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,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
Expand Down
82 changes: 80 additions & 2 deletions frontend/src/scenes/billing/BillingProductAddonActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 } =
Expand All @@ -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) {
Expand Down Expand Up @@ -196,6 +208,68 @@ export const BillingProductAddonActions = ({
return null
}

const renderDowngradeActions = (): JSX.Element | null => {
if (!upgradePlan || !currentPlatformAddon) {
return null
}

return (
<More
overlay={
<LemonButton
fullWidth
loading={switchPlanLoading === addon.type}
// TODO: Show confirmation modal with AddonFeatureLossNotice
onClick={() =>
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),
})
Comment on lines +225 to +229
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Potential runtime error if currentPlatformAddon is undefined or plans array is empty. Add null safety checks.

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/scenes/billing/BillingProductAddonActions.tsx
Line: 225:229

Comment:
**logic:** Potential runtime error if currentPlatformAddon is undefined or plans array is empty. Add null safety checks.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added check for currentPlatformAddon

}
>
Downgrade
</LemonButton>
}
/>
)
}

const renderUpgradeActions = (): JSX.Element | null => {
if (!upgradePlan || !currentPlatformAddon) {
return null
}

const showPricing = upgradePlan.flat_rate

return (
<>
{showPricing && (
<h4 className="leading-5 font-bold mb-0 flex gap-x-0.5">
{formatFlatRate(Number(upgradePlan.unit_amount_usd), upgradePlan.unit)}
</h4>
)}

<LemonButton
type="primary"
loading={switchPlanLoading === addon.type}
onClick={() =>
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),
})
Comment on lines +259 to +263
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Same issue as downgrade - needs null safety for currentPlatformAddon and plans[0]

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/scenes/billing/BillingProductAddonActions.tsx
Line: 259:263

Comment:
**logic:** Same issue as downgrade - needs null safety for currentPlatformAddon and plans[0]

How can I resolve this? If you propose a fix, please make it concise.

}
>
Upgrade
</LemonButton>
</>
// TODO: show prorated amount similar to renderPricingInfo
)
}

let content
if (addon.subscribed && !addon.inclusion_only) {
content = renderSubscribedActions()
Expand All @@ -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 (
Expand Down
66 changes: 66 additions & 0 deletions frontend/src/scenes/billing/billingLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
BillingPeriod,
BillingPlan,
BillingPlanType,
BillingProductV2AddonType,
BillingProductV2Type,
BillingType,
ProductKey,
Expand Down Expand Up @@ -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>): BillingType => {
if (data.billing_period) {
data.billing_period = {
Expand Down Expand Up @@ -162,6 +170,7 @@ export const billingLogic = kea<billingLogicType>([
setComputedDiscount: (discount: number) => ({ discount }),
setCreditBrackets: (creditBrackets: any[]) => ({ creditBrackets }),
scrollToProduct: (productType: string) => ({ productType }),
setSwitchPlanLoading: (productKey: string | null) => ({ productKey }),
}),
connect(() => ({
values: [
Expand Down Expand Up @@ -280,6 +289,12 @@ export const billingLogic = kea<billingLogicType>([
setCreditBrackets: (_, { creditBrackets }) => creditBrackets || [],
},
],
switchPlanLoading: [
null as string | null,
{
setSwitchPlanLoading: (_, { productKey }) => productKey,
},
],
}),
lazyLoaders(({ actions, values }) => ({
billing: [
Expand Down Expand Up @@ -374,6 +389,30 @@ export const billingLogic = kea<billingLogicType>([
return values.billing
}
},
switchFlatrateSubscriptionPlan: async (data: SwitchPlanPayload, breakpoint) => {
try {
await api.create('api/billing/subscription/switch-plan', data)

const productDisplayName = data.to_product_key[0].toUpperCase() + data.to_product_key.slice(1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think we have a util function for this somewhere

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

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) {
console.error(error)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove console log and instead capture the error

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, thanks, forgot to remove this

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: [
Expand Down Expand Up @@ -500,6 +539,24 @@ export const billingLogic = kea<billingLogicType>([
?.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],
Expand Down Expand Up @@ -693,6 +750,15 @@ export const billingLogic = kea<billingLogicType>([
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()
Expand Down
23 changes: 22 additions & 1 deletion frontend/src/scenes/billing/billingProductLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,14 @@ export const billingProductLogic = kea<billingProductLogicType>([
connect(() => ({
values: [
billingLogic,
['billing', 'isUnlicensedDebug', 'scrollToProductKey', 'unsubscribeError'],
[
'billing',
'isUnlicensedDebug',
'scrollToProductKey',
'unsubscribeError',
'currentPlatformAddon',
'platformAddons',
],
featureFlagLogic,
['featureFlags'],
],
Expand Down Expand Up @@ -247,6 +254,20 @@ export const billingProductLogic = kea<billingProductLogicType>([
],
}),
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) => {
Expand Down
Loading