From d07f0bfdeb9e0c2983033802ab43cca8f902c3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Sat, 22 Aug 2026 18:46:21 +0200 Subject: [PATCH 1/2] feat(billing): yearly-first plan selector and switch-to-yearly prompt - Plan picker defaults to yearly for new subscribers (existing subs keep their interval), with clearer savings copy and /yr vs /mo price suffixes. - Public pricing (home section + slider) surfaces 'pay yearly, 2 months free'. - New Organization.subscriptionFirstStartedAt: stable tenure anchor set from Polar's subscription.createdAt in the webhook (subscriptionStartsAt resets every renewal so it can't measure tenure); backfilled by sync-subscriptions. - Dismissible in-app prompt for org admins on monthly plans with 3+ months tenure: opens the plan picker preset to yearly. Claude-Session: https://claude.ai/code/session_017resSrRFv7wxsc9ifsALAh --- .../api/src/controllers/webhook.controller.ts | 7 ++ .../src/app/(home)/_sections/pricing.tsx | 3 + apps/public/src/components/pricing-slider.tsx | 3 + .../organization/billing-plan-picker.tsx | 22 +++- .../organization/yearly-switch-prompt.tsx | 105 ++++++++++++++++++ apps/start/src/modals/select-billing-plan.tsx | 3 + .../start/src/routes/_app.$organizationId.tsx | 7 ++ .../migration.sql | 6 + packages/db/prisma/schema.prisma | 4 + .../payments/scripts/sync-subscriptions.ts | 5 + 10 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 apps/start/src/components/organization/yearly-switch-prompt.tsx create mode 100644 packages/db/prisma/migrations/20260822100000_subscription_first_started_at/migration.sql diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts index 401e45aca..694003e51 100644 --- a/apps/api/src/controllers/webhook.controller.ts +++ b/apps/api/src/controllers/webhook.controller.ts @@ -148,6 +148,7 @@ const TRACKED_SUBSCRIPTION_FIELDS = [ 'subscriptionPeriodEventsLimit', 'subscriptionPauseAtPeriodEnd', 'subscriptionResumesAt', + 'subscriptionFirstStartedAt', ] as const; const CANCELLATION_REASONS = [ @@ -302,6 +303,12 @@ async function syncSubscriptionToOrg( subscriptionCancelComment: data.customerCancellationComment ?? null, subscriptionPauseAtPeriodEnd: data.pauseAtPeriodEnd, subscriptionResumesAt: data.resumesAt, + // 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' && 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/organization/billing-plan-picker.tsx b/apps/start/src/components/organization/billing-plan-picker.tsx index 9bdc4c61a..2d557b0d1 100644 --- a/apps/start/src/components/organization/billing-plan-picker.tsx +++ b/apps/start/src/components/organization/billing-plan-picker.tsx @@ -20,6 +20,7 @@ interface Props { organization: IServiceOrganization; currentProduct: IPolarProduct | null; onComplete?: () => void; + defaultInterval?: 'year' | 'month'; } const getPrice = (product: IPolarProduct) => { @@ -32,6 +33,7 @@ export default function BillingPlanPicker({ organization, currentProduct, onComplete, + defaultInterval, }: Props) { const number = useNumber(); const trpc = useTRPC(); @@ -41,8 +43,11 @@ export default function BillingPlanPicker({ 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, @@ -243,7 +248,13 @@ export default function BillingPlanPicker({
{recurringInterval === 'year' ? ( - 'Switch to monthly' + <> + Yearly billing —{' '} + 2 months free{' '} + + (pay for 10 months, get 12) + + ) : ( <> Switch to yearly and get{' '} @@ -310,7 +321,12 @@ export default function BillingPlanPicker({ > {product.name}
- {number.currency(price)} + + {number.currency(price)} + + /{recurringInterval === 'year' ? 'yr' : 'mo'} + + {renderRowIndicator(product)}
diff --git a/apps/start/src/components/organization/yearly-switch-prompt.tsx b/apps/start/src/components/organization/yearly-switch-prompt.tsx new file mode 100644 index 000000000..bab8973d9 --- /dev/null +++ b/apps/start/src/components/organization/yearly-switch-prompt.tsx @@ -0,0 +1,105 @@ +import type { IServiceOrganization } from '@openpanel/db'; +import { useQuery } from '@tanstack/react-query'; +import { differenceInMonths } from 'date-fns'; +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { useTRPC } from '@/integrations/trpc/react'; +import { pushModal } from '@/modals'; +import { op } from '@/utils/op'; + +// Yearly plans churn a fraction of monthly ones. Nudge monthly subscribers who +// have stuck around for a while to switch — they get 2 months free, we keep +// them longer. Dismissal is per-org and per-browser. +const MIN_TENURE_MONTHS = 3; + +const dismissKey = (organizationId: string) => + `op-yearly-prompt-dismissed:${organizationId}`; + +const readDismissed = (organizationId: string) => { + try { + return localStorage.getItem(dismissKey(organizationId)) === '1'; + } catch { + return true; + } +}; + +export default function YearlySwitchPrompt({ + organization, +}: { + organization: IServiceOrganization; +}) { + const trpc = useTRPC(); + // Hidden until mounted so SSR and the first client render agree. + const [dismissed, setDismissed] = useState(true); + useEffect(() => { + setDismissed(readDismissed(organization.id)); + }, [organization.id]); + + const eligible = + organization.subscriptionState === 'active' && + organization.subscriptionInterval === 'month' && + !!organization.subscriptionFirstStartedAt && + differenceInMonths(new Date(), organization.subscriptionFirstStartedAt) >= + MIN_TENURE_MONTHS; + + const accessQuery = useQuery( + trpc.organization.myAccess.queryOptions( + { organizationId: organization.id }, + { enabled: eligible && !dismissed } + ) + ); + const isAdmin = accessQuery.data?.role === 'org:admin'; + + const currentProductQuery = useQuery( + trpc.subscription.getCurrent.queryOptions( + { organizationId: organization.id }, + { enabled: eligible && !dismissed && isAdmin } + ) + ); + + if (!(eligible && isAdmin) || dismissed) { + return null; + } + + const dismiss = () => { + op.track('yearly_prompt_dismissed', { organizationId: organization.id }); + try { + localStorage.setItem(dismissKey(organization.id), '1'); + } catch { + // Storage unavailable — the prompt just shows again next session. + } + setDismissed(true); + }; + + return ( +
+
+ Switch to yearly — get 2 months free +
+
+ You've been with us for a while. Pay for 10 months and get 12: same + plan, same limits, one invoice a year. +
+
+ + +
+
+ ); +} diff --git a/apps/start/src/modals/select-billing-plan.tsx b/apps/start/src/modals/select-billing-plan.tsx index 7ed8d93cd..ece9a5816 100644 --- a/apps/start/src/modals/select-billing-plan.tsx +++ b/apps/start/src/modals/select-billing-plan.tsx @@ -7,17 +7,20 @@ import BillingPlanPicker from '@/components/organization/billing-plan-picker'; interface Props { organization: IServiceOrganization; currentProduct: IPolarProduct | null; + defaultInterval?: 'year' | 'month'; } export default function SelectBillingPlan({ organization, currentProduct, + defaultInterval, }: Props) { return ( diff --git a/apps/start/src/routes/_app.$organizationId.tsx b/apps/start/src/routes/_app.$organizationId.tsx index 0a442a72f..25d85e19d 100644 --- a/apps/start/src/routes/_app.$organizationId.tsx +++ b/apps/start/src/routes/_app.$organizationId.tsx @@ -2,6 +2,7 @@ import { FullPageEmptyState } from '@/components/full-page-empty-state'; import FullPageLoadingState from '@/components/full-page-loading-state'; import FeedbackPrompt from '@/components/organization/feedback-prompt'; import SupporterPrompt from '@/components/organization/supporter-prompt'; +import YearlySwitchPrompt from '@/components/organization/yearly-switch-prompt'; import { LinkButton } from '@/components/ui/button'; import { useTRPC } from '@/integrations/trpc/react'; import { cn } from '@/utils/cn'; @@ -138,8 +139,14 @@ function Component() { isProjectRoute && subscriptionBlocksDashboard(organization.subscriptionState); + const location = useLocation(); + const isBillingPage = /\/.+\/billing/.test(location.pathname); + return ( <> + {!stateMeta.banner && !isBillingPage && ( + + )} {stateMeta.banner && !hideBannerForPrompt && ( now(), the organization will be deleted deleteAt DateTime? diff --git a/packages/payments/scripts/sync-subscriptions.ts b/packages/payments/scripts/sync-subscriptions.ts index 28231a19d..9f881eb36 100644 --- a/packages/payments/scripts/sync-subscriptions.ts +++ b/packages/payments/scripts/sync-subscriptions.ts @@ -277,6 +277,10 @@ async function main() { subscriptionCreatedByUserId: metadataUserId ?? organization.subscriptionCreatedByUserId, subscriptionInterval: subscription.recurringInterval, + subscriptionFirstStartedAt: + organization.subscriptionId === subscription.id + ? (organization.subscriptionFirstStartedAt ?? subscription.createdAt) + : subscription.createdAt, subscriptionPeriodEventsLimit: subscriptionPeriodEventsLimit ?? organization.subscriptionPeriodEventsLimit, subscriptionPeriodEventsCountExceededAt: @@ -299,6 +303,7 @@ async function main() { 'subscriptionEndsAt', 'subscriptionCreatedByUserId', 'subscriptionInterval', + 'subscriptionFirstStartedAt', 'subscriptionPeriodEventsLimit', ] as const; From b18727ec786211d8e8b9892e5330304be716baff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Sat, 22 Aug 2026 20:15:22 +0200 Subject: [PATCH 2/2] fix(billing): only treat a plan as selected within the displayed interval Opening the picker preset to yearly for a monthly subscriber kept the monthly product 'selected', rendering the cancel action under the yearly list. The selection now only resolves against products of the active interval. Claude-Session: https://claude.ai/code/session_017resSrRFv7wxsc9ifsALAh --- .../src/components/organization/billing-plan-picker.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/start/src/components/organization/billing-plan-picker.tsx b/apps/start/src/components/organization/billing-plan-picker.tsx index 2d557b0d1..72f55e583 100644 --- a/apps/start/src/components/organization/billing-plan-picker.tsx +++ b/apps/start/src/components/organization/billing-plan-picker.tsx @@ -54,8 +54,14 @@ export default function BillingPlanPicker({ ); 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