diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts index 4f6ace1b1..dafabd5a8 100644 --- a/apps/api/src/controllers/webhook.controller.ts +++ b/apps/api/src/controllers/webhook.controller.ts @@ -6,7 +6,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); import { tryCatch } from '@openpanel/common'; -import { db, getOrganizationByProjectIdCached } from '@openpanel/db'; +import { db, getOrganizationByProjectIdCached, Prisma } from '@openpanel/db'; import { sendSlackNotification, slackInstaller, @@ -143,10 +143,65 @@ const TRACKED_SUBSCRIPTION_FIELDS = [ 'subscriptionStartsAt', 'subscriptionEndsAt', 'subscriptionCanceledAt', + 'subscriptionCancelReason', 'subscriptionInterval', 'subscriptionPeriodEventsLimit', + 'subscriptionPauseAtPeriodEnd', + 'subscriptionResumesAt', + 'subscriptionFirstStartedAt', ] as const; +const CANCELLATION_REASONS = [ + 'too_expensive', + 'missing_features', + 'switched_service', + 'unused', + 'customer_service', + 'low_quality', + 'too_complex', + 'other', +] as const; + +type CancellationReason = (typeof CANCELLATION_REASONS)[number]; + +// Polar types the reason as an open enum (unknown strings can appear); only +// store values our own union knows about. +function parseCancellationReason( + reason: string | null | undefined +): CancellationReason | null { + return CANCELLATION_REASONS.includes(reason as CancellationReason) + ? (reason as CancellationReason) + : null; +} + +type PolarSubscriptionDiscount = PolarSubscriptionData['discount']; + +// Compact summary of Polar's embedded discount object so the dashboard can +// show that a discount is active (save offer or any Polar discount code). +export function toSubscriptionDiscount( + discount: PolarSubscriptionDiscount +): PrismaJson.IPrismaSubscriptionDiscount | null { + if (!discount) { + return null; + } + return { + id: discount.id, + name: discount.name, + type: discount.type === 'fixed' ? 'fixed' : 'percentage', + basisPoints: 'basisPoints' in discount ? discount.basisPoints : null, + amount: 'amount' in discount ? discount.amount : null, + currency: 'currency' in discount ? discount.currency : null, + duration: + discount.duration === 'repeating' + ? 'repeating' + : discount.duration === 'forever' + ? 'forever' + : 'once', + durationInMonths: + 'durationInMonths' in discount ? discount.durationInMonths : null, + }; +} + const normalizeLogValue = (value: unknown) => value instanceof Date ? value.toISOString() : (value ?? null); @@ -267,6 +322,23 @@ async function syncSubscriptionToOrg( : data.canceledAt : data.currentPeriodEnd, subscriptionInterval: data.recurringInterval, + // Cancellation feedback + pause state mirror Polar so portal-driven cancels + // and pauses are captured too (our in-app flows also set them via the API, + // which just echoes back through here). + subscriptionCancelReason: parseCancellationReason( + data.customerCancellationReason + ), + subscriptionCancelComment: data.customerCancellationComment ?? null, + subscriptionPauseAtPeriodEnd: data.pauseAtPeriodEnd, + subscriptionResumesAt: data.resumesAt, + subscriptionDiscount: + toSubscriptionDiscount(data.discount) ?? Prisma.DbNull, + // Stable tenure anchor: keep the stored value while the subscription id is + // unchanged; a new subscription (re-subscribe) restarts tenure. + subscriptionFirstStartedAt: + organization.subscriptionId === data.id + ? (organization.subscriptionFirstStartedAt ?? data.createdAt) + : data.createdAt, subscriptionPeriodEventsLimit, subscriptionPeriodEventsCountExceededAt: typeof subscriptionPeriodEventsLimit === 'number' && @@ -275,6 +347,12 @@ async function syncSubscriptionToOrg( organization.subscriptionPeriodEventsLimit < subscriptionPeriodEventsLimit ? null : undefined, + // A raised limit re-arms the usage alerts for the new headroom. + ...(typeof subscriptionPeriodEventsLimit === 'number' && + typeof organization.subscriptionPeriodEventsLimit === 'number' && + organization.subscriptionPeriodEventsLimit < subscriptionPeriodEventsLimit + ? { usageWarningSentAt: null, usageExceededSentAt: null } + : {}), }; const changes = diffOrganizationFields( @@ -317,7 +395,10 @@ export async function polarWebhook( }>, reply: FastifyReply ) { - request.log.info({ body: request.body }, 'polar webhook received'); + // Don't log the raw body: it can carry customer free text (e.g. the + // cancellation comment) that the logger's redaction patterns don't cover. + // `eventCtx` is logged right after validation instead. + request.log.info('polar webhook received'); const validation = await tryCatch(async () => validatePolarEvent( @@ -402,6 +483,9 @@ export async function polarWebhook( data: { subscriptionPeriodEventsCount: 0, subscriptionPeriodEventsCountExceededAt: null, + // New cycle — the usage alerts may fire again. + usageWarningSentAt: null, + usageExceededSentAt: null, }, }); @@ -419,6 +503,9 @@ export async function polarWebhook( // All subscription lifecycle events carry the same Subscription object; // sync them through a single path (new subs, cancellations, revokes, // reactivations, plan changes, payment-state changes). + // Pause/resume transitions arrive via `subscription.updated` (the SDK's + // webhook union has no dedicated paused/reactivated payloads yet) and are + // reflected in `status` / `pauseAtPeriodEnd` / `resumesAt` below. case 'subscription.created': case 'subscription.active': case 'subscription.updated': diff --git a/apps/public/src/app/(home)/_sections/pricing.tsx b/apps/public/src/app/(home)/_sections/pricing.tsx index 10669f851..476e18467 100644 --- a/apps/public/src/app/(home)/_sections/pricing.tsx +++ b/apps/public/src/app/(home)/_sections/pricing.tsx @@ -79,6 +79,9 @@ export function Pricing() { + VAT if applicable + + Pay yearly and get 2 months free + ) : (
diff --git a/apps/public/src/components/pricing-slider.tsx b/apps/public/src/components/pricing-slider.tsx index b557f8802..31a52c772 100644 --- a/apps/public/src/components/pricing-slider.tsx +++ b/apps/public/src/components/pricing-slider.tsx @@ -49,6 +49,9 @@ export function PricingSlider() { > + VAT if applicable +
+ Pay yearly and get 2 months free +
) : (
diff --git a/apps/start/src/components/clients/create-client-success.tsx b/apps/start/src/components/clients/create-client-success.tsx index 09e0c7fe5..2a252822b 100644 --- a/apps/start/src/components/clients/create-client-success.tsx +++ b/apps/start/src/components/clients/create-client-success.tsx @@ -1,19 +1,28 @@ +import { CopyIcon, DownloadIcon, RocketIcon } from 'lucide-react'; +import CopyInput from '../forms/copy-input'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; -import { DownloadIcon, RocketIcon } from 'lucide-react'; - -import CopyInput from '../forms/copy-input'; +import { isRealClientSecret } from '@/hooks/use-client-secret'; +import { clipboard } from '@/utils/clipboard'; type Props = { id: string; secret: string; type?: 'read' | 'write' | 'root' }; export function CreateClientSuccess({ id, secret, type }: Props) { - const mcpToken = btoa(`${id}:${secret}`); - const showMcpToken = type === 'root' || type === 'read'; + // Only derive credentials from a real secret — the '[CLIENT_SECRET]' + // placeholder is truthy and would render a valid-looking but broken token. + const hasSecret = isRealClientSecret(secret); + const mcpToken = hasSecret ? btoa(`${id}:${secret}`) : null; + const showMcpToken = !!mcpToken && (type === 'root' || type === 'read'); + + const credentials = [ + `CLIENT_ID=${id}`, + hasSecret && `CLIENT_SECRET=${secret}`, + showMcpToken && `MCP_TOKEN=${mcpToken}`, + ] + .filter(Boolean) + .join('\n'); const download = () => { - const credentials = showMcpToken - ? `CLIENT_ID=${id}\nCLIENT_SECRET=${secret}\nMCP_TOKEN=${mcpToken}` - : `CLIENT_ID=${id}\nCLIENT_SECRET=${secret}`; const blob = new Blob([credentials], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -25,36 +34,45 @@ export function CreateClientSuccess({ id, secret, type }: Props) { return (
- {secret && ( + {hasSecret && (
-

+

You will only need the secret if you want to send server events.

)} - {secret && showMcpToken && ( + {showMcpToken && (
-

+

Use this token to authenticate with the MCP server (base64 encoded client ID and secret).

)} - +
+ + +
Get started! Read our{' '} documentation {' '} diff --git a/apps/start/src/components/onboarding/activation-banner.tsx b/apps/start/src/components/onboarding/activation-banner.tsx new file mode 100644 index 000000000..8df35b5b0 --- /dev/null +++ b/apps/start/src/components/onboarding/activation-banner.tsx @@ -0,0 +1,256 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { CheckIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Ping } from '@/components/ping'; +import { Button } from '@/components/ui/button'; +import { useAppParams } from '@/hooks/use-app-params'; +import { useTRPC } from '@/integrations/trpc/react'; +import { pushModal } from '@/modals'; +import { cn } from '@/utils/cn'; +import { op } from '@/utils/op'; + +// The project's first funnel: event -> report -> teammate, drawn as three +// nodes on a progress track. The current step carries the product's live ping +// dot, and while the first event is missing the query polls so the banner +// flips green by itself the moment data arrives. Steps derive from existing +// data (Project.firstEventAt, report count, member count) — no state machine. + +const dismissKey = (projectId: string) => + `op-activation-checklist-dismissed:${projectId}`; + +const readDismissed = (projectId: string) => { + try { + return localStorage.getItem(dismissKey(projectId)) === '1'; + } catch { + // Storage unavailable: show the checklist — new users in storage-blocked + // browsers should still get onboarding help, at the cost of the dismissal + // only lasting the session. + return false; + } +}; + +const HEADLINES = { + 'first-event': { + title: 'Waiting for your first event', + sub: 'Install the snippet — this banner lights up the moment data arrives.', + }, + 'first-report': { + title: 'Data is flowing — now shape it', + sub: 'Build your first report from the events coming in.', + }, + 'invite-teammate': { + title: 'Bring your team in', + sub: 'Invite a teammate to see what you are seeing.', + }, +} as const; + +type StepKey = keyof typeof HEADLINES; + +export default function ActivationBanner() { + const { organizationId, projectId } = useAppParams(); + const trpc = useTRPC(); + const navigate = useNavigate(); + + // Hidden until mounted so SSR and the first client render agree. + const [dismissed, setDismissed] = useState(true); + useEffect(() => { + setDismissed(readDismissed(projectId)); + }, [projectId]); + + const statusQuery = useQuery( + trpc.project.activationStatus.queryOptions( + { projectId }, + { + enabled: !dismissed, + // Poll only while listening for the first event, so the banner reacts + // on its own; afterwards the remaining steps are user-driven. + refetchInterval: (query) => + query.state.data && !query.state.data.hasFirstEvent ? 10_000 : false, + } + ) + ); + const status = statusQuery.data; + + if (dismissed || !status) { + return null; + } + + const steps: { + key: StepKey; + label: string; + done: boolean; + go: () => void; + }[] = [ + { + key: 'first-event', + label: 'First event', + done: status.hasFirstEvent, + go: () => { + op.track('activation_checklist_setup_clicked', { projectId }); + navigate({ + to: '/onboarding/$projectId/connect', + params: { projectId }, + }); + }, + }, + { + key: 'first-report', + label: 'First report', + done: status.hasReport, + go: () => { + op.track('activation_checklist_report_clicked', { projectId }); + navigate({ + to: '/$organizationId/$projectId/reports', + params: { organizationId, projectId }, + }); + }, + }, + { + key: 'invite-teammate', + label: 'Invite a teammate', + done: status.hasTeammate, + go: () => { + op.track('activation_checklist_invite_clicked', { projectId }); + pushModal('CreateInvite'); + }, + }, + ]; + + const doneCount = steps.filter((step) => step.done).length; + if (doneCount === steps.length) { + return null; + } + + const current = steps.find((step) => !step.done) ?? steps[0]!; + const headline = HEADLINES[current.key]; + + const dismiss = () => { + op.track('activation_checklist_dismissed', { projectId }); + try { + localStorage.setItem(dismissKey(projectId), '1'); + } catch { + // Storage unavailable — the banner just shows again next session. + } + setDismissed(true); + }; + + const actions = ( + <> + + + + ); + + return ( + // Container queries, not viewport breakpoints: the banner sits beside the + // sidebar, so its own width — not the window's — decides when the + // three-column row fits. In the stacked layout the actions move up to the + // headline row (top-right corner) instead of dangling below the funnel. +
+
+ +
+
+
+
+ Setup funnel + + {doneCount}/{steps.length} + +
+
+ {headline.title} +
+
{headline.sub}
+
+ +
+ {actions} +
+
+ +
+ {steps.map((step, index) => { + const isCurrent = step.key === current.key; + return ( +
0 && 'flex-1')} + key={step.key} + > + {index > 0 && ( +
+ )} + +
+ ); + })} +
+ +
+ {actions} +
+
+
+ ); +} diff --git a/apps/start/src/components/organization/billing-plan-picker.tsx b/apps/start/src/components/organization/billing-plan-picker.tsx index 488119b84..80ac819ba 100644 --- a/apps/start/src/components/organization/billing-plan-picker.tsx +++ b/apps/start/src/components/organization/billing-plan-picker.tsx @@ -19,6 +19,10 @@ interface Props { organization: IServiceOrganization; currentProduct: IPolarProduct | null; onComplete?: () => void; + // Switches the host modal to the cancel flow; the cancel action only + // renders when provided. + onCancel?: () => void; + defaultInterval?: 'year' | 'month'; } const getPrice = (product: IPolarProduct) => { @@ -31,6 +35,8 @@ export default function BillingPlanPicker({ organization, currentProduct, onComplete, + onCancel, + defaultInterval, }: Props) { const number = useNumber(); const trpc = useTRPC(); @@ -38,18 +44,27 @@ export default function BillingPlanPicker({ const productsQuery = useQuery( trpc.subscription.products.queryOptions({ organizationId: organization.id, - }), + }) ); + // Yearly is the default for new subscribers — it churns far less and saves + // them 2 months. Existing subscribers land on their current interval. const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>( - (organization.subscriptionInterval as 'year' | 'month') || 'month', + defaultInterval ?? + ((organization.subscriptionInterval as 'year' | 'month') || 'year') ); const [selectedProductId, setSelectedProductId] = useState( - organization.subscriptionProductId || null, + organization.subscriptionProductId || null ); const [pendingProductId, setPendingProductId] = useState(null); const products = productsQuery.data || []; + // Only treat a product as selected while it belongs to the displayed + // interval. Opening the picker preset to yearly for a monthly subscriber + // (or toggling the interval) must not keep the monthly plan "selected" — + // that would render the cancel action under a list it isn't part of. const selectedProduct = products.find( - (product) => product.id === selectedProductId, + (product) => + product.id === selectedProductId && + product.recurringInterval === recurringInterval ); // No current plan to compare against → a plan row is the buy button (straight @@ -66,13 +81,13 @@ export default function BillingPlanPicker({ queryClient.invalidateQueries( trpc.organization.get.queryOptions({ organizationId: organization.id, - }), + }) ); queryClient.invalidateQueries( trpc.subscription.getCurrent.queryOptions({ organizationId: organization.id, - }), + }) ); toast.success('Subscription updated', { description: 'It might take a few seconds to update', @@ -84,31 +99,7 @@ export default function BillingPlanPicker({ setPendingProductId(null); toast.error(error.message); }, - }), - ); - - const cancelSubscription = useMutation( - trpc.subscription.cancelSubscription.mutationOptions({ - onSuccess() { - queryClient.invalidateQueries( - trpc.organization.get.queryOptions({ - organizationId: organization.id, - }), - ); - queryClient.invalidateQueries( - trpc.subscription.getCurrent.queryOptions({ - organizationId: organization.id, - }), - ); - toast.success('Subscription canceled', { - description: 'It might take a few seconds to update', - }); - onComplete?.(); - }, - onError(error) { - toast.error(error.message); - }, - }), + }) ); const startCheckout = (product: IPolarProduct) => { @@ -126,7 +117,9 @@ export default function BillingPlanPicker({ }; const handleCheckout = () => { - if (!selectedProduct) return; + if (!selectedProduct) { + return; + } startCheckout(selectedProduct); }; @@ -143,7 +136,7 @@ export default function BillingPlanPicker({ } if (selectedProductId === product.id) { return ( -
+
); @@ -152,15 +145,10 @@ export default function BillingPlanPicker({ }; const handleCancelSubscription = () => { - if (!selectedProduct) return; - op.track('subscription_canceled', { - organizationId: organization.id, - limit: selectedProduct.metadata.eventsLimit, - price: getPrice(selectedProduct), - }); - cancelSubscription.mutate({ + op.track('cancel_flow_opened', { organizationId: organization.id, }); + onCancel?.(); }; const renderAction = () => { @@ -170,13 +158,13 @@ export default function BillingPlanPicker({ const isCurrentProduct = selectedProduct.id === currentProduct?.id; - if (isCurrentProduct && organization.isActive) { + if (isCurrentProduct && organization.isActive && onCancel) { return ( @@ -203,45 +191,45 @@ export default function BillingPlanPicker({ return (
-
+
{products .filter((product) => - product.prices.some((p) => p.amountType !== 'free'), + // `free` no longer exists in the SDK's amountType union, but + // retired free-plan products can still come back from Polar's API. + product.prices.some((p) => (p.amountType as string) !== 'free') ) .filter((product) => product.metadata.eventsLimit) .filter((product) => product.recurringInterval === recurringInterval) @@ -312,29 +308,34 @@ export default function BillingPlanPicker({ return ( diff --git a/apps/start/src/components/organization/billing-prompt.tsx b/apps/start/src/components/organization/billing-prompt.tsx index 92a6db788..33392a3f4 100644 --- a/apps/start/src/components/organization/billing-prompt.tsx +++ b/apps/start/src/components/organization/billing-prompt.tsx @@ -1,5 +1,5 @@ import type { IServiceOrganization } from '@openpanel/db'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { CheckIcon } from 'lucide-react'; import { useEffect } from 'react'; import { toast } from 'sonner'; @@ -29,13 +29,13 @@ interface CopyVariant { title: string; lead: string; dateLabel: string; - action: 'checkout' | 'portal'; + action: 'checkout' | 'portal' | 'resume'; cta: (plan: string, price: string) => string; note: string | null; } const COPY: Record< - 'expired' | 'trialEnded' | 'unpaid' | 'freePlan', + 'expired' | 'trialEnded' | 'unpaid' | 'freePlan' | 'paused', CopyVariant > = { trialEnded: { @@ -68,6 +68,16 @@ const COPY: Record< cta: () => 'Update payment method', note: null, }, + paused: { + badge: { label: 'Paused', variant: 'secondary' }, + gradient: 'rgb(16 185 129)', + title: 'Your subscription is on a break', + lead: "You paused your subscription, so billing is stopped — but we're still collecting every event. Resume whenever you're ready and your dashboards are back immediately, right where you left them.", + dateLabel: 'Billing resumes', + action: 'resume', + cta: () => 'Resume subscription now', + note: 'Resuming starts a new billing period right away. Your events keep being collected while paused, so nothing is lost.', + }, freePlan: { badge: { label: 'Plan change', variant: 'secondary' }, gradient: 'rgb(16 185 129)', @@ -120,6 +130,21 @@ export default function BillingPrompt({ }, }) ); + const queryClient = useQueryClient(); + const resume = useMutation( + trpc.subscription.resumeSubscription.mutationOptions({ + onSuccess() { + queryClient.invalidateQueries(trpc.organization.pathFilter()); + queryClient.invalidateQueries(trpc.subscription.pathFilter()); + toast.success('Welcome back!', { + description: 'It might take a few seconds to update', + }); + }, + onError(error) { + toast.error(error.message); + }, + }) + ); const eventsCount = organization.subscriptionPeriodEventsCount ?? 0; const bestProductFit = products @@ -150,6 +175,25 @@ export default function BillingPrompt({ }, [type]); const renderCta = () => { + if (copy.action === 'resume') { + return ( + + ); + } + if (copy.action === 'portal') { return (
- {organization.subscriptionEndsAt && ( -
-
- {formatDate(organization.subscriptionEndsAt)} + {(() => { + // Paused shows the automatic resume date instead of the period end. + const date = + type === 'paused' + ? organization.subscriptionResumesAt + : organization.subscriptionEndsAt; + if (!date) { + return null; + } + return ( +
+
+ {formatDate(date)} +
+
+ {copy.dateLabel} +
-
- {copy.dateLabel} -
-
- )} + ); + })()}
diff --git a/apps/start/src/components/organization/billing.tsx b/apps/start/src/components/organization/billing.tsx index 47ebf29b5..d956a18b7 100644 --- a/apps/start/src/components/organization/billing.tsx +++ b/apps/start/src/components/organization/billing.tsx @@ -1,4 +1,5 @@ import type { IServiceOrganization } from '@openpanel/db'; +import { getSubscriptionStateMeta } from '@openpanel/payments/subscription-state-meta'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { differenceInDays } from 'date-fns'; import { useQueryState } from 'nuqs'; @@ -16,7 +17,6 @@ import useWS from '@/hooks/use-ws'; import { useTRPC } from '@/integrations/trpc/react'; import { pushModal, useOnPushModal } from '@/modals'; import { formatDate } from '@/utils/date'; -import { getSubscriptionStateMeta } from '@openpanel/payments/subscription-state-meta'; type Props = { organization: IServiceOrganization; @@ -53,6 +53,25 @@ export default function Billing({ organization }: Props) { }) ); + const resumeMutation = useMutation( + trpc.subscription.resumeSubscription.mutationOptions({ + onSuccess() { + queryClient.invalidateQueries(trpc.organization.pathFilter()); + queryClient.invalidateQueries(trpc.subscription.pathFilter()); + toast.success('Subscription resumed', { + description: 'It might take a few seconds to update', + }); + }, + onError(error) { + toast.error(error.message); + }, + }) + ); + + const isPauseState = + organization.subscriptionState === 'pausing' || + organization.subscriptionState === 'paused'; + useWS(`/live/organization/${organization.id}`, () => { queryClient.invalidateQueries(trpc.organization.pathFilter()); queryClient.invalidateQueries(trpc.subscription.pathFilter()); @@ -65,7 +84,11 @@ export default function Billing({ organization }: Props) { const products = useMemo(() => { return (productsQuery.data || []) .filter((product) => product.recurringInterval === recurringInterval) - .filter((product) => product.prices.some((p) => p.amountType !== 'free')); + .filter((product) => + // `free` no longer exists in the SDK's amountType union, but retired + // free-plan products can still come back from Polar's API. + product.prices.some((p) => (p.amountType as string) !== 'free') + ); }, [productsQuery.data, recurringInterval]); const currentProduct = currentProductQuery.data ?? null; @@ -73,10 +96,62 @@ export default function Billing({ organization }: Props) { p.amountType === 'fixed' ? [p] : [] )[0]; + // What the customer actually pays while a recurring discount applies. A + // `once` discount only hits the next invoice, so the header keeps the list + // price and the discount line below explains it. + const listPrice = currentPrice ? currentPrice.priceAmount / 100 : null; + const discountedPrice = (() => { + const active = organization.subscriptionDiscount; + if (!active || listPrice === null || active.duration === 'once') { + return null; + } + if (active.type === 'percentage' && active.basisPoints) { + return listPrice * (1 - active.basisPoints / 10_000); + } + if (active.type === 'fixed' && active.amount) { + return Math.max(0, listPrice - active.amount / 100); + } + return null; + })(); + + // Synced from Polar — covers the cancel-flow save offer and any discount + // code the customer redeemed. Without this line the card shows the full + // list price and an applied discount is invisible outside Polar's portal. + const discount = organization.subscriptionDiscount; + const renderDiscount = () => { + if (!discount) { + return null; + } + const value = + discount.type === 'percentage' && discount.basisPoints + ? `−${discount.basisPoints / 100}%` + : discount.amount + ? `−${number.currency(discount.amount / 100)}` + : null; + if (!value) { + return null; + } + const duration = + discount.duration === 'repeating' && discount.durationInMonths + ? `for the next ${discount.durationInMonths} months` + : discount.duration === 'forever' + ? 'on every invoice' + : 'on your next invoice'; + // Deliberately no discount name here: names are often the redeemable code + // itself, and the save offer's name would advertise what the cancel flow + // grants. The value + duration is all the customer needs. + return ( +

+ {value} {duration} +

+ ); + }; + const renderStatus = () => { const meta = getSubscriptionStateMeta(organization.subscriptionState, { endsAt: organization.subscriptionEndsAt, canceledAt: organization.subscriptionCanceledAt, + resumesAt: organization.subscriptionResumesAt, }); if (!meta.statusLine) { @@ -86,7 +161,9 @@ export default function Billing({ organization }: Props) { return (

{meta.statusLine.text} @@ -117,8 +194,15 @@ export default function Billing({ organization }: Props) {

{currentProduct.name}
+ {discountedPrice !== null && ( + + {number.currency(currentPrice.priceAmount / 100)} + + )} - {number.currency(currentPrice.priceAmount / 100)} + {number.currency( + discountedPrice ?? currentPrice.priceAmount / 100 + )} {' / '} @@ -128,6 +212,7 @@ export default function Billing({ organization }: Props) { {renderStatus()} + {renderDiscount()}
{number.format(organization.subscriptionPeriodEventsCount)} /{' '} @@ -173,19 +258,37 @@ export default function Billing({ organization }: Props) { Customer portal - +
+ {isPauseState && ( + + )} + +
diff --git a/apps/start/src/components/organization/cancel-subscription-flow.tsx b/apps/start/src/components/organization/cancel-subscription-flow.tsx new file mode 100644 index 000000000..eb45b0d75 --- /dev/null +++ b/apps/start/src/components/organization/cancel-subscription-flow.tsx @@ -0,0 +1,342 @@ +import type { IServiceOrganization } from '@openpanel/db'; +import type { ICancellationReason } from '@openpanel/validation'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { CheckIcon } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { ButtonContainer } from '@/components/button-container'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { useTRPC } from '@/integrations/trpc/react'; +import { ModalHeader } from '@/modals/Modal/Container'; +import { cn } from '@/utils/cn'; +import { formatDate } from '@/utils/date'; +import { op } from '@/utils/op'; + +// The cancel flow lives inside the SelectBillingPlan modal as internal views +// (no stacked modals): reason -> pause offer -> discount offer -> cancel. + +interface Props { + organization: IServiceOrganization; + // Back to the plan picker view. + onBack: () => void; + // A flow outcome happened (paused, discounted, or canceled) — close the modal. + onComplete: () => void; +} + +const REASONS: { value: ICancellationReason; label: string }[] = [ + { value: 'unused', label: "I'm not using it right now" }, + { value: 'too_expensive', label: "It's too expensive" }, + { value: 'missing_features', label: "It's missing features I need" }, + { value: 'switched_service', label: 'I switched to another service' }, + { value: 'too_complex', label: "It's too complicated" }, + { value: 'low_quality', label: "Quality didn't meet my expectations" }, + { value: 'customer_service', label: 'Unhappy with customer service' }, + { value: 'other', label: 'Other' }, +]; + +const PAUSE_MONTHS = [1, 2, 3] as const; + +type Step = 'reason' | 'pause' | 'discount'; + +export default function CancelSubscriptionFlow({ + organization, + onBack, + onComplete, +}: Props) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [step, setStep] = useState('reason'); + const [reason, setReason] = useState(null); + const [comment, setComment] = useState(''); + const [pauseMonths, setPauseMonths] = useState<1 | 2 | 3>(1); + + const discountAvailable = !organization.subscriptionSaveDiscountAppliedAt; + + // Funnel instrumentation. Every step render emits a step_viewed event, and + // the unmount cleanup emits cancel_flow_abandoned when the flow closes with + // no recorded outcome (X, esc, click-outside, navigation) — that's the + // silent drop-off a decisions-only funnel can't see. Refs so the cleanup + // reads the latest values. + const outcomeRef = useRef< + 'paused' | 'discounted' | 'canceled' | 'kept' | null + >(null); + const stepRef = useRef('reason'); + stepRef.current = step; + const reasonRef = useRef(null); + reasonRef.current = reason; + + useEffect(() => { + op.track('cancel_flow_step_viewed', { + organizationId: organization.id, + step, + reason: reasonRef.current, + }); + }, [step, organization.id]); + + useEffect( + () => () => { + if (!outcomeRef.current) { + op.track('cancel_flow_abandoned', { + organizationId: organization.id, + step: stepRef.current, + reason: reasonRef.current, + }); + } + }, + [organization.id] + ); + + const invalidate = () => { + queryClient.invalidateQueries(trpc.organization.pathFilter()); + queryClient.invalidateQueries(trpc.subscription.pathFilter()); + }; + + const pauseMutation = useMutation( + trpc.subscription.pauseSubscription.mutationOptions({ + onSuccess(data) { + outcomeRef.current = 'paused'; + invalidate(); + toast.success('Subscription paused', { + description: `Billing stops at the end of your current period and resumes on ${formatDate(data.resumesAt)}. Your events keep flowing in.`, + }); + onComplete(); + }, + onError(error) { + toast.error(error.message); + }, + }) + ); + + const discountMutation = useMutation( + trpc.subscription.applySaveDiscount.mutationOptions({ + onSuccess() { + outcomeRef.current = 'discounted'; + invalidate(); + toast.success('Discount applied', { + description: + '30% off for the next 12 months, starting with your next billing cycle.', + }); + onComplete(); + }, + onError(error) { + toast.error(error.message); + }, + }) + ); + + const cancelMutation = useMutation( + trpc.subscription.cancelSubscription.mutationOptions({ + onSuccess() { + outcomeRef.current = 'canceled'; + invalidate(); + toast.success('Subscription canceled', { + description: organization.subscriptionEndsAt + ? `Your subscription stays active until ${formatDate(organization.subscriptionEndsAt)}.` + : 'It might take a few seconds to update', + }); + onComplete(); + }, + onError(error) { + toast.error(error.message); + }, + }) + ); + + const isPending = + pauseMutation.isPending || + discountMutation.isPending || + cancelMutation.isPending; + + const cancelNow = () => { + if (!reason) { + return; + } + op.track('subscription_canceled', { + organizationId: organization.id, + reason, + }); + cancelMutation.mutate({ + organizationId: organization.id, + reason, + comment: comment.trim() || undefined, + }); + }; + + if (step === 'reason') { + return ( + <> + +
+
+ {REASONS.map((item) => ( + + ))} +
+