feat: trials UI on teams (#25885)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
@ -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()
|
||||
|
@ -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",
|
||||
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 175 KiB |
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 261 KiB |
@ -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]
|
||||
|
@ -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<string, any>, explodeArrays: boolean = false): string {
|
||||
if (!obj) {
|
||||
return ''
|
||||
|
@ -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 {
|
||||
</LemonBanner>
|
||||
)}
|
||||
|
||||
{billing?.free_trial_until ? (
|
||||
<LemonBanner type="success" className="mb-2">
|
||||
You are currently on a free trial until <b>{billing.free_trial_until.format('LL')}</b>
|
||||
{billing?.trial ? (
|
||||
<LemonBanner type="info" className="mb-2">
|
||||
You are currently on a free trial for <b>{toSentenceCase(billing.trial.target)} plan</b> until{' '}
|
||||
<b>{dayjs(billing.trial.expires_at).format('LL')}</b>. 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.'}
|
||||
</LemonBanner>
|
||||
) : null}
|
||||
|
||||
|
@ -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<HTMLDivElement | null>(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 (
|
||||
<div
|
||||
className="bg-bg-3000 rounded p-6 flex flex-col"
|
||||
@ -140,6 +171,36 @@ export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonTyp
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : billing?.trial?.target === addon.type ? (
|
||||
<div className="flex flex-col items-end justify-end">
|
||||
<Tooltip
|
||||
title={
|
||||
<p>
|
||||
You are currently on a free trial for{' '}
|
||||
<b>{toSentenceCase(billing.trial.target)}</b> until{' '}
|
||||
<b>{dayjs(billing.trial.expires_at).format('LL')}</b>. 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.'}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<LemonTag type="completion" icon={<IconCheckCircle />}>
|
||||
You're on a trial for this add-on
|
||||
</LemonTag>
|
||||
</Tooltip>
|
||||
{/* Comment out until we can make sure a customer can't activate a trial multiple times */}
|
||||
{/* <LemonButton
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={cancelTrial}
|
||||
loading={trialLoading}
|
||||
className="mt-1"
|
||||
>
|
||||
Cancel trial
|
||||
</LemonButton> */}
|
||||
</div>
|
||||
) : addon.included_with_main_product ? (
|
||||
<LemonTag type="completion" icon={<IconCheckCircle />}>
|
||||
Included with plan
|
||||
@ -152,9 +213,16 @@ export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonTyp
|
||||
<>
|
||||
{currentAndUpgradePlans?.upgradePlan?.flat_rate ? (
|
||||
<h4 className="leading-5 font-bold mb-0 space-x-0.5">
|
||||
<span>
|
||||
{formatFlatRate(Number(upgradePlan?.unit_amount_usd), upgradePlan?.unit)}
|
||||
</span>
|
||||
{addon.trial ? (
|
||||
<span>{addon.trial.length} day free trial</span>
|
||||
) : (
|
||||
<span>
|
||||
{formatFlatRate(
|
||||
Number(upgradePlan?.unit_amount_usd),
|
||||
upgradePlan?.unit
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
) : (
|
||||
<LemonButton
|
||||
@ -166,32 +234,48 @@ export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonTyp
|
||||
View pricing
|
||||
</LemonButton>
|
||||
)}
|
||||
{!addon.inclusion_only && (
|
||||
<LemonButton
|
||||
type="primary"
|
||||
icon={<IconPlus />}
|
||||
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
|
||||
</LemonButton>
|
||||
)}
|
||||
{!addon.inclusion_only &&
|
||||
(addon.trial && !!trialExperiment ? (
|
||||
<LemonButton
|
||||
type="primary"
|
||||
icon={<IconPlus />}
|
||||
size="small"
|
||||
disableClientSideRouting
|
||||
disabledReason={
|
||||
(billingError && billingError.message) ||
|
||||
(billing?.subscription_level === 'free' && 'Upgrade to add add-ons')
|
||||
}
|
||||
loading={billingProductLoading === addon.type}
|
||||
onClick={handleTrialActivation}
|
||||
>
|
||||
Start trial
|
||||
</LemonButton>
|
||||
) : (
|
||||
<LemonButton
|
||||
type="primary"
|
||||
icon={<IconPlus />}
|
||||
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
|
||||
</LemonButton>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!addon.inclusion_only && isProrated && !addon.contact_support && (
|
||||
{!addon.inclusion_only && !addon.trial && isProrated && !addon.contact_support && (
|
||||
<p className="mt-2 text-xs text-muted text-right">
|
||||
Pay ~${prorationAmount} today (prorated) and
|
||||
<br />
|
||||
@ -199,6 +283,12 @@ export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonTyp
|
||||
thereafter.
|
||||
</p>
|
||||
)}
|
||||
{!!addon.trial && !billing?.trial && (
|
||||
<p className="mt-2 text-xs text-muted text-right">
|
||||
You'll have {addon.trial.length} days to try it out. Then you'll be charged{' '}
|
||||
{formatFlatRate(Number(upgradePlan?.unit_amount_usd), upgradePlan?.unit)}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 ml-11">
|
||||
@ -237,6 +327,57 @@ export const BillingProductAddon = ({ addon }: { addon: BillingProductV2AddonTyp
|
||||
}
|
||||
/>
|
||||
{surveyID && <UnsubscribeSurveyModal product={addon} />}
|
||||
<LemonModal
|
||||
isOpen={trialModalOpen}
|
||||
onClose={() => setTrialModalOpen(false)}
|
||||
title={`Start your ${addon.name} trial`}
|
||||
description={`You'll have ${addon.trial?.length} days to try it out before being charged.`}
|
||||
footer={
|
||||
<>
|
||||
<LemonButton type="secondary" onClick={() => setTrialModalOpen(false)}>
|
||||
Cancel
|
||||
</LemonButton>
|
||||
<LemonButton type="primary" onClick={activateTrial} loading={trialLoading}>
|
||||
Start trial
|
||||
</LemonButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="mb-1.5">Here's some stuff about the trial:</p>
|
||||
<ul className="space-y-0.5">
|
||||
<li className="ml-2">
|
||||
🎉 It's <b>free!</b>
|
||||
</li>
|
||||
<li className="ml-2">
|
||||
📅 The trial is for <b>{addon.trial?.length} days</b>
|
||||
</li>
|
||||
<li className="ml-2">
|
||||
🚀 You'll get access to <b>all the features</b> of the plan immediately
|
||||
</li>
|
||||
<li className="ml-2">
|
||||
📧 3 days before the trial ends, you'll be emailed a reminder that you'll be charged
|
||||
</li>
|
||||
<li className="ml-2">
|
||||
🚫 If you don't want to be charged, you can cancel anytime before the trial ends
|
||||
</li>
|
||||
<li className="ml-2">
|
||||
💵 At the end of the trial, you'll be be subscribed and charged{' '}
|
||||
{formatFlatRate(Number(upgradePlan?.unit_amount_usd), upgradePlan?.unit)}
|
||||
</li>
|
||||
<li className="ml-2">
|
||||
☎️ If you have any questions, you can{' '}
|
||||
<Link
|
||||
onClick={() => {
|
||||
setTrialModalOpen(false)
|
||||
openSupportForm({ kind: 'support', target_area: 'billing' })
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
contact us
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</LemonModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -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<billingProductLogicType>([
|
||||
[
|
||||
'updateBillingLimits',
|
||||
'updateBillingLimitsSuccess',
|
||||
'loadBilling',
|
||||
'loadBillingSuccess',
|
||||
'deactivateProduct',
|
||||
'setProductSpecificAlert',
|
||||
@ -75,6 +77,10 @@ export const billingProductLogic = kea<billingProductLogicType>([
|
||||
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<billingProductLogicType>([
|
||||
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<billingProductLogicType>([
|
||||
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?.()
|
||||
|
@ -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 {
|
||||
|