diff --git a/ee/api/billing.py b/ee/api/billing.py index 8b7691be5e5..7fe7e4942b5 100644 --- a/ee/api/billing.py +++ b/ee/api/billing.py @@ -275,6 +275,20 @@ class BillingViewset(TeamAndOrgViewSetMixin, viewsets.GenericViewSet): res = billing_manager.purchase_credits(organization, request.data) return Response(res, status=status.HTTP_200_OK) + @action(methods=["POST"], detail=False, url_path="trials/activate") + def activate_trial(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse: + organization = self._get_org_required() + billing_manager = self.get_billing_manager() + res = billing_manager.activate_trial(organization, request.data) + return Response(res, status=status.HTTP_200_OK) + + @action(methods=["POST"], detail=False, url_path="trials/cancel") + def cancel_trial(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse: + organization = self._get_org_required() + billing_manager = self.get_billing_manager() + res = billing_manager.cancel_trial(organization, request.data) + return Response(res, status=status.HTTP_200_OK) + @action(methods=["POST"], detail=False, url_path="activate/authorize") def authorize(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 f28029bcaac..18d3d3fbbee 100644 --- a/ee/billing/billing_manager.py +++ b/ee/billing/billing_manager.py @@ -378,6 +378,26 @@ class BillingManager: return res.json() + def activate_trial(self, organization: Organization, data: dict[str, Any]): + res = requests.post( + f"{BILLING_SERVICE_URL}/api/trials/activate", + headers=self.get_auth_headers(organization), + json=data, + ) + + handle_billing_service_error(res) + + return res.json() + + def cancel_trial(self, organization: Organization, data: dict[str, Any]): + res = requests.post( + f"{BILLING_SERVICE_URL}/api/trials/cancel", + headers=self.get_auth_headers(organization), + json=data, + ) + + handle_billing_service_error(res) + def authorize(self, organization: Organization): res = requests.post( f"{BILLING_SERVICE_URL}/api/activate/authorize", diff --git a/frontend/__snapshots__/scenes-app-insights--user-paths-edit--light.png b/frontend/__snapshots__/scenes-app-insights--user-paths-edit--light.png index 87356fa9f2e..aa8524427af 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--user-paths-edit--light.png and b/frontend/__snapshots__/scenes-app-insights--user-paths-edit--light.png differ diff --git a/frontend/__snapshots__/scenes-other-billing--billing--dark.png b/frontend/__snapshots__/scenes-other-billing--billing--dark.png index 001bea8f2da..1f8a330ec0c 100644 Binary files a/frontend/__snapshots__/scenes-other-billing--billing--dark.png and b/frontend/__snapshots__/scenes-other-billing--billing--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-billing--billing--light.png b/frontend/__snapshots__/scenes-other-billing--billing--light.png index 71fc9742561..f2ab57943a8 100644 Binary files a/frontend/__snapshots__/scenes-other-billing--billing--light.png and b/frontend/__snapshots__/scenes-other-billing--billing--light.png differ diff --git a/frontend/__snapshots__/scenes-other-billing--billing-with-credit-cta--dark.png b/frontend/__snapshots__/scenes-other-billing--billing-with-credit-cta--dark.png index 957b54b65e7..505db4406be 100644 Binary files a/frontend/__snapshots__/scenes-other-billing--billing-with-credit-cta--dark.png and b/frontend/__snapshots__/scenes-other-billing--billing-with-credit-cta--dark.png differ diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index f61fc539c2f..b971755535e 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -225,6 +225,7 @@ export const FEATURE_FLAGS = { EXPERIMENTS_HOLDOUTS: 'experiments-holdouts', // owner: @jurajmajerik #team-experiments MESSAGING: 'messaging', // owner @mariusandra #team-cdp SESSION_REPLAY_URL_BLOCKLIST: 'session-replay-url-blocklist', // owner: @richard-better #team-replay + BILLING_TRIAL_FLOW: 'billing-trial-flow', // owner: @zach DEAD_CLICKS_AUTOCAPTURE: 'dead-clicks-autocapture', // owner: @pauldambra #team-replay } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/lib/utils.tsx b/frontend/src/lib/utils.tsx index 27e54bb9eee..23bc258fa0d 100644 --- a/frontend/src/lib/utils.tsx +++ b/frontend/src/lib/utils.tsx @@ -62,6 +62,10 @@ export const humanizeBytes = (fileSizeInBytes: number | null): string => { return convertedBytes.toFixed(2) + ' ' + byteUnits[i] } +export function toSentenceCase(str: string): string { + return str.replace(/\b\w/g, (c) => c.toUpperCase()) +} + export function toParams(obj: Record, explodeArrays: boolean = false): string { if (!obj) { return '' diff --git a/frontend/src/scenes/billing/Billing.tsx b/frontend/src/scenes/billing/Billing.tsx index 75038e5fbd1..fd06e8a6785 100644 --- a/frontend/src/scenes/billing/Billing.tsx +++ b/frontend/src/scenes/billing/Billing.tsx @@ -11,7 +11,7 @@ import { useResizeBreakpoints } from 'lib/hooks/useResizeObserver' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { LemonLabel } from 'lib/lemon-ui/LemonLabel/LemonLabel' import { SpinnerOverlay } from 'lib/lemon-ui/Spinner/Spinner' -import { humanFriendlyCurrency } from 'lib/utils' +import { humanFriendlyCurrency, toSentenceCase } from 'lib/utils' import { useEffect } from 'react' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { SceneExport } from 'scenes/sceneTypes' @@ -108,9 +108,13 @@ export function Billing(): JSX.Element { )} - {billing?.free_trial_until ? ( - - You are currently on a free trial until {billing.free_trial_until.format('LL')} + {billing?.trial ? ( + + You are currently on a free trial for {toSentenceCase(billing.trial.target)} plan until{' '} + {dayjs(billing.trial.expires_at).format('LL')}. At the end of the trial{' '} + {billing.trial.type === 'autosubscribe' + ? 'you will be automatically subscribed to the plan.' + : 'you will be asked to subscribe. If you choose not to, you will lose access to the features.'} ) : null} diff --git a/frontend/src/scenes/billing/BillingProductAddon.tsx b/frontend/src/scenes/billing/BillingProductAddon.tsx index df746aba9ad..918538cf31c 100644 --- a/frontend/src/scenes/billing/BillingProductAddon.tsx +++ b/frontend/src/scenes/billing/BillingProductAddon.tsx @@ -1,9 +1,12 @@ import { IconCheckCircle, IconPlus } from '@posthog/icons' -import { LemonButton, LemonSelectOptions, LemonTag, Link, Tooltip } from '@posthog/lemon-ui' +import { LemonButton, LemonModal, LemonSelectOptions, LemonTag, Link, Tooltip } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' -import { UNSUBSCRIBE_SURVEY_ID } from 'lib/constants' +import { supportLogic } from 'lib/components/Support/supportLogic' +import { FEATURE_FLAGS, UNSUBSCRIBE_SURVEY_ID } from 'lib/constants' +import { dayjs } from 'lib/dayjs' import { More } from 'lib/lemon-ui/LemonButton/More' -import { humanFriendlyCurrency } from 'lib/utils' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { humanFriendlyCurrency, toSentenceCase } from 'lib/utils' import { ReactNode, useMemo, useRef } from 'react' import { getProductIcon } from 'scenes/products/Products' @@ -31,12 +34,25 @@ const formatFlatRate = (flatRate: number, unit: string | null): string | ReactNo export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonType }): JSX.Element => { const productRef = useRef(null) const { billing, redirectPath, billingError, timeTotalInSeconds, timeRemainingInSeconds } = useValues(billingLogic) - const { isPricingModalOpen, currentAndUpgradePlans, surveyID, billingProductLoading } = useValues( - billingProductLogic({ product: addon, productRef }) - ) - const { toggleIsPricingModalOpen, reportSurveyShown, setSurveyResponse, initiateProductUpgrade } = useActions( - billingProductLogic({ product: addon }) - ) + const { + isPricingModalOpen, + currentAndUpgradePlans, + surveyID, + billingProductLoading, + trialModalOpen, + trialLoading, + } = useValues(billingProductLogic({ product: addon, productRef })) + const { + toggleIsPricingModalOpen, + reportSurveyShown, + setSurveyResponse, + initiateProductUpgrade, + setTrialModalOpen, + activateTrial, + } = useActions(billingProductLogic({ product: addon })) + const { openSupportForm } = useActions(supportLogic) + + const { featureFlags } = useValues(featureFlagLogic) const upgradePlan = currentAndUpgradePlans?.upgradePlan @@ -70,6 +86,21 @@ export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonTyp addon.type === 'enhanced_persons' && addon.plans?.find((plan) => plan.plan_key === 'addon-20240404-og-customers') + const trialExperiment = featureFlags[FEATURE_FLAGS.BILLING_TRIAL_FLOW] + + const handleTrialActivation = (): void => { + if (trialExperiment === 'modal') { + // Modal - Show trial modal (default behavior) + setTrialModalOpen(true) + } else if (trialExperiment === 'control') { + // Direct - Activate trial immediately + activateTrial() + } else { + // No trial flow even without the feature flag + initiateProductUpgrade(addon, currentAndUpgradePlans?.upgradePlan, redirectPath) + } + } + return (
) + ) : billing?.trial?.target === addon.type ? ( +
+ + You are currently on a free trial for{' '} + {toSentenceCase(billing.trial.target)} until{' '} + {dayjs(billing.trial.expires_at).format('LL')}. At the end of the + trial{' '} + {billing.trial.type === 'autosubscribe' + ? 'you will be automatically subscribed to the plan.' + : 'you will be asked to subscribe. If you choose not to, you will lose access to the features.'} +

+ } + > + }> + You're on a trial for this add-on + +
+ {/* Comment out until we can make sure a customer can't activate a trial multiple times */} + {/* + Cancel trial + */} +
) : addon.included_with_main_product ? ( }> Included with plan @@ -152,9 +213,16 @@ export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonTyp <> {currentAndUpgradePlans?.upgradePlan?.flat_rate ? (

- - {formatFlatRate(Number(upgradePlan?.unit_amount_usd), upgradePlan?.unit)} - + {addon.trial ? ( + {addon.trial.length} day free trial + ) : ( + + {formatFlatRate( + Number(upgradePlan?.unit_amount_usd), + upgradePlan?.unit + )} + + )}

) : ( )} - {!addon.inclusion_only && ( - } - size="small" - disableClientSideRouting - disabledReason={ - (billingError && billingError.message) || - (billing?.subscription_level === 'free' && 'Upgrade to add add-ons') - } - loading={billingProductLoading === addon.type} - onClick={() => - initiateProductUpgrade( - addon, - currentAndUpgradePlans?.upgradePlan, - redirectPath - ) - } - > - Add - - )} + {!addon.inclusion_only && + (addon.trial && !!trialExperiment ? ( + } + size="small" + disableClientSideRouting + disabledReason={ + (billingError && billingError.message) || + (billing?.subscription_level === 'free' && 'Upgrade to add add-ons') + } + loading={billingProductLoading === addon.type} + onClick={handleTrialActivation} + > + Start trial + + ) : ( + } + size="small" + disableClientSideRouting + disabledReason={ + (billingError && billingError.message) || + (billing?.subscription_level === 'free' && 'Upgrade to add add-ons') + } + loading={billingProductLoading === addon.type} + onClick={() => + initiateProductUpgrade( + addon, + currentAndUpgradePlans?.upgradePlan, + redirectPath + ) + } + > + Add + + ))} )}
- {!addon.inclusion_only && isProrated && !addon.contact_support && ( + {!addon.inclusion_only && !addon.trial && isProrated && !addon.contact_support && (

Pay ~${prorationAmount} today (prorated) and
@@ -199,6 +283,12 @@ export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonTyp thereafter.

)} + {!!addon.trial && !billing?.trial && ( +

+ You'll have {addon.trial.length} days to try it out. Then you'll be charged{' '} + {formatFlatRate(Number(upgradePlan?.unit_amount_usd), upgradePlan?.unit)}. +

+ )}
@@ -237,6 +327,57 @@ export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonTyp } /> {surveyID && } + setTrialModalOpen(false)} + title={`Start your ${addon.name} trial`} + description={`You'll have ${addon.trial?.length} days to try it out before being charged.`} + footer={ + <> + setTrialModalOpen(false)}> + Cancel + + + Start trial + + + } + > +

Here's some stuff about the trial:

+
    +
  • + 🎉 It's free! +
  • +
  • + 📅 The trial is for {addon.trial?.length} days +
  • +
  • + 🚀 You'll get access to all the features of the plan immediately +
  • +
  • + 📧 3 days before the trial ends, you'll be emailed a reminder that you'll be charged +
  • +
  • + 🚫 If you don't want to be charged, you can cancel anytime before the trial ends +
  • +
  • + 💵 At the end of the trial, you'll be be subscribed and charged{' '} + {formatFlatRate(Number(upgradePlan?.unit_amount_usd), upgradePlan?.unit)} +
  • +
  • + ☎️ If you have any questions, you can{' '} + { + setTrialModalOpen(false) + openSupportForm({ kind: 'support', target_area: 'billing' }) + }} + className="cursor-pointer" + > + contact us + +
  • +
+
) } diff --git a/frontend/src/scenes/billing/billingProductLogic.ts b/frontend/src/scenes/billing/billingProductLogic.ts index a2b3d9718fa..9a4d54afea8 100644 --- a/frontend/src/scenes/billing/billingProductLogic.ts +++ b/frontend/src/scenes/billing/billingProductLogic.ts @@ -1,6 +1,7 @@ -import { LemonDialog } from '@posthog/lemon-ui' +import { LemonDialog, lemonToast } from '@posthog/lemon-ui' import { actions, connect, events, kea, key, listeners, path, props, reducers, selectors } from 'kea' import { forms } from 'kea-forms' +import api from 'lib/api' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import posthog from 'posthog-js' import React from 'react' @@ -37,6 +38,7 @@ export const billingProductLogic = kea([ [ 'updateBillingLimits', 'updateBillingLimitsSuccess', + 'loadBilling', 'loadBillingSuccess', 'deactivateProduct', 'setProductSpecificAlert', @@ -75,6 +77,10 @@ export const billingProductLogic = kea([ products, redirectPath, }), + activateTrial: true, + cancelTrial: true, + setTrialModalOpen: (isOpen: boolean) => ({ isOpen }), + setTrialLoading: (loading: boolean) => ({ loading }), setUnsubscribeModalStep: (step: number) => ({ step }), resetUnsubscribeModalStep: true, setHedgehogSatisfied: (satisfied: boolean) => ({ satisfied }), @@ -156,6 +162,18 @@ export const billingProductLogic = kea([ toggleIsPlanComparisonModalOpen: (_, { highlightedFeatureKey }) => highlightedFeatureKey || null, }, ], + trialModalOpen: [ + false, + { + setTrialModalOpen: (_, { isOpen }) => isOpen, + }, + ], + trialLoading: [ + false, + { + setTrialLoading: (_, { loading }) => loading, + }, + ], unsubscribeModalStep: [ 1 as number, { @@ -349,6 +367,35 @@ export const billingProductLogic = kea([ redirectPath && `&redirect_path=${redirectPath}` }` }, + activateTrial: async () => { + actions.setTrialLoading(true) + try { + await api.create(`api/billing/trials/activate`, { + type: 'autosubscribe', + target: props.product.type, + }) + lemonToast.success('Your trial has been activated!') + } catch (e) { + lemonToast.error('There was an error activating your trial. Please try again or contact support.') + } finally { + actions.loadBilling() + actions.setTrialLoading(false) + actions.setTrialModalOpen(false) + } + }, + cancelTrial: async () => { + actions.setTrialLoading(true) + try { + await api.create(`api/billing/trials/cancel`) + lemonToast.success('Your trial has been cancelled!') + } catch (e) { + console.error(e) + lemonToast.error('There was an error cancelling your trial. Please try again or contact support.') + } finally { + actions.loadBilling() + actions.setTrialLoading(false) + } + }, triggerMoreHedgehogs: async (_, breakpoint) => { for (let i = 0; i < 5; i++) { props.hogfettiTrigger?.() diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b1519d86e6f..846b63401d2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1618,6 +1618,10 @@ export interface BillingTierType { up_to: number | null } +export interface BillingTrialType { + length: number +} + export interface BillingProductV2Type { type: string usage_key: string | null @@ -1650,6 +1654,7 @@ export interface BillingProductV2Type { addons: BillingProductV2AddonType[] // addons-only: if this addon is included with the base product and not subscribed individually. for backwards compatibility. included_with_main_product?: boolean + trial?: BillingTrialType } export interface BillingProductV2AddonType { @@ -1680,6 +1685,7 @@ export interface BillingProductV2AddonType { features: BillingFeatureType[] included_if?: 'no_active_subscription' | 'has_subscription' | null usage_limit?: number | null + trial?: BillingTrialType } export interface BillingType { customer_id: string @@ -1707,6 +1713,12 @@ export interface BillingType { discount_percent?: number discount_amount_usd?: string amount_off_expires_at?: Dayjs + trial?: { + type: 'autosubscribe' | 'standard' + status: 'active' | 'expired' | 'cancelled' | 'converted' + target: 'paid' | 'teams' | 'enterprise' + expires_at: string + } } export interface BillingPlanType {