0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-21 13:39:22 +01:00

feat: trials UI on teams (#25885)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Zach Waterfield 2024-11-01 11:50:36 -04:00 committed by GitHub
parent a38e5ab62c
commit 003ac8a029
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 283 additions and 40 deletions

View File

@ -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()

View File

@ -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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 261 KiB

View File

@ -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]

View File

@ -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 ''

View File

@ -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}

View File

@ -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>
)
}

View File

@ -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?.()

View File

@ -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 {