From 30f4b3d569020e814a1360a454f1e81a51151001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Sat, 22 Aug 2026 18:42:31 +0200 Subject: [PATCH 01/20] feat(billing): cancel flow with exit survey, pause offer, and save discount - In-app cancel now runs through a 3-step modal: required reason (Polar's cancellation enum) + optional comment -> pause offer (1-3 months, billing stops at period end, events keep flowing) -> one-time 30%-off-for-12-months discount -> frictionless cancel. - Polar SDK 0.48.1 -> 0.49.0 for subscription pause/resume support. - New subscription states: pausing (active + pause scheduled) and paused (blocks dashboard with a resume prompt; ingestion continues as before). - Webhook syncs pause fields + customer cancellation reason/comment, so portal-driven cancels are captured too. - New org columns: subscriptionCancelReason/Comment, subscriptionSaveDiscountAppliedAt, subscriptionPauseAtPeriodEnd, subscriptionResumesAt. - create-save-discount script provisions the reusable Polar discount (POLAR_SAVE_DISCOUNT_ID). - Checkout guard: paused subs must resume before changing plans (prevents a second Polar subscription). Claude-Session: https://claude.ai/code/session_017resSrRFv7wxsc9ifsALAh --- .../api/src/controllers/webhook.controller.ts | 38 +++ .../organization/billing-plan-picker.tsx | 38 +-- .../organization/billing-prompt.tsx | 78 ++++- .../src/components/organization/billing.tsx | 70 ++++- apps/start/src/modals/cancel-subscription.tsx | 276 ++++++++++++++++++ apps/start/src/modals/index.tsx | 2 + .../start/src/routes/_app.$organizationId.tsx | 1 + .../migration.sql | 10 + packages/db/prisma/schema.prisma | 9 + packages/db/scripts/set-subscription-state.ts | 33 +++ packages/db/src/prisma-client.ts | 3 + packages/db/src/types.ts | 13 +- packages/payments/package.json | 2 +- .../payments/scripts/create-save-discount.ts | 88 ++++++ packages/payments/src/polar.ts | 75 ++++- .../payments/src/subscription-state-meta.ts | 52 +++- .../payments/src/subscription-state.test.ts | 11 + packages/payments/src/subscription-state.ts | 22 +- packages/trpc/src/routers/subscription.ts | 133 ++++++++- packages/validation/src/index.ts | 27 ++ pnpm-lock.yaml | 11 +- 21 files changed, 919 insertions(+), 73 deletions(-) create mode 100644 apps/start/src/modals/cancel-subscription.tsx create mode 100644 packages/db/prisma/migrations/20260822090000_churn_cancel_flow_pause_discount/migration.sql create mode 100644 packages/payments/scripts/create-save-discount.ts diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts index 4f6ace1b1..401e45aca 100644 --- a/apps/api/src/controllers/webhook.controller.ts +++ b/apps/api/src/controllers/webhook.controller.ts @@ -143,10 +143,36 @@ const TRACKED_SUBSCRIPTION_FIELDS = [ 'subscriptionStartsAt', 'subscriptionEndsAt', 'subscriptionCanceledAt', + 'subscriptionCancelReason', 'subscriptionInterval', 'subscriptionPeriodEventsLimit', + 'subscriptionPauseAtPeriodEnd', + 'subscriptionResumesAt', ] 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; +} + const normalizeLogValue = (value: unknown) => value instanceof Date ? value.toISOString() : (value ?? null); @@ -267,6 +293,15 @@ 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, subscriptionPeriodEventsLimit, subscriptionPeriodEventsCountExceededAt: typeof subscriptionPeriodEventsLimit === 'number' && @@ -419,6 +454,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/start/src/components/organization/billing-plan-picker.tsx b/apps/start/src/components/organization/billing-plan-picker.tsx index 488119b84..78792a92d 100644 --- a/apps/start/src/components/organization/billing-plan-picker.tsx +++ b/apps/start/src/components/organization/billing-plan-picker.tsx @@ -12,6 +12,7 @@ import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { useNumber } from '@/hooks/use-numer-formatter'; import { useTRPC } from '@/integrations/trpc/react'; +import { pushModal } from '@/modals'; import { cn } from '@/utils/cn'; import { op } from '@/utils/op'; @@ -87,30 +88,6 @@ export default function BillingPlanPicker({ }), ); - 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) => { setPendingProductId(product.id); op.track('subscription_checkout_started', { @@ -152,15 +129,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, }); + pushModal('CancelSubscription', { organization }); }; const renderAction = () => { @@ -294,7 +266,9 @@ export default function BillingPlanPicker({
{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) 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..c5ea3dd1b 100644 --- a/apps/start/src/components/organization/billing.tsx +++ b/apps/start/src/components/organization/billing.tsx @@ -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; @@ -77,6 +100,7 @@ export default function Billing({ organization }: Props) { const meta = getSubscriptionStateMeta(organization.subscriptionState, { endsAt: organization.subscriptionEndsAt, canceledAt: organization.subscriptionCanceledAt, + resumesAt: organization.subscriptionResumesAt, }); if (!meta.statusLine) { @@ -173,19 +197,37 @@ export default function Billing({ organization }: Props) { Customer portal - +
+ {isPauseState && ( + + )} + +
diff --git a/apps/start/src/modals/cancel-subscription.tsx b/apps/start/src/modals/cancel-subscription.tsx new file mode 100644 index 000000000..bea62db4b --- /dev/null +++ b/apps/start/src/modals/cancel-subscription.tsx @@ -0,0 +1,276 @@ +import type { IServiceOrganization } from '@openpanel/db'; +import type { ICancellationReason } from '@openpanel/validation'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { popModal } from '.'; +import { ModalContent, ModalHeader } from './Modal/Container'; +import { ButtonContainer } from '@/components/button-container'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Textarea } from '@/components/ui/textarea'; +import { useTRPC } from '@/integrations/trpc/react'; +import { cn } from '@/utils/cn'; +import { formatDate } from '@/utils/date'; +import { op } from '@/utils/op'; + +export interface CancelSubscriptionProps { + organization: IServiceOrganization; +} + +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 CancelSubscription({ + organization, +}: CancelSubscriptionProps) { + 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; + + const invalidate = () => { + queryClient.invalidateQueries(trpc.organization.pathFilter()); + queryClient.invalidateQueries(trpc.subscription.pathFilter()); + }; + + const pauseMutation = useMutation( + trpc.subscription.pauseSubscription.mutationOptions({ + onSuccess(data) { + 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.`, + }); + popModal('CancelSubscription'); + }, + onError(error) { + toast.error(error.message); + }, + }) + ); + + const discountMutation = useMutation( + trpc.subscription.applySaveDiscount.mutationOptions({ + onSuccess() { + invalidate(); + toast.success('Discount applied', { + description: + '30% off your next 12 invoices, starting with the next billing cycle.', + }); + popModal('CancelSubscription'); + }, + onError(error) { + toast.error(error.message); + }, + }) + ); + + const cancelMutation = useMutation( + trpc.subscription.cancelSubscription.mutationOptions({ + onSuccess() { + invalidate(); + toast.success('Subscription canceled', { + description: organization.subscriptionEndsAt + ? `Your subscription stays active until ${formatDate(organization.subscriptionEndsAt)}.` + : 'It might take a few seconds to update', + }); + popModal('CancelSubscription'); + }, + 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 ( + + + setReason(value as ICancellationReason)} + value={reason ?? undefined} + > + {REASONS.map((item) => ( + + ))} + +