Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/api/src/controllers/webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ const TRACKED_SUBSCRIPTION_FIELDS = [
'subscriptionPeriodEventsLimit',
'subscriptionPauseAtPeriodEnd',
'subscriptionResumesAt',
'subscriptionFirstStartedAt',
] as const;

const CANCELLATION_REASONS = [
Expand Down Expand Up @@ -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' &&
Expand Down
3 changes: 3 additions & 0 deletions apps/public/src/app/(home)/_sections/pricing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export function Pricing() {
+ VAT if applicable
</span>
</div>
<span className="mt-2 text-emerald-600 text-sm dark:text-emerald-500">
Pay yearly and get 2 months free
</span>
</>
) : (
<div className="text-lg">
Expand Down
3 changes: 3 additions & 0 deletions apps/public/src/components/pricing-slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export function PricingSlider() {
>
+ VAT if applicable
</span>
<div className="mt-1 text-emerald-600 text-sm dark:text-emerald-500">
Pay yearly and get 2 months free
</div>
</div>
) : (
<div className="text-lg">
Expand Down
30 changes: 26 additions & 4 deletions apps/start/src/components/organization/billing-plan-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface Props {
organization: IServiceOrganization;
currentProduct: IPolarProduct | null;
onComplete?: () => void;
defaultInterval?: 'year' | 'month';
}

const getPrice = (product: IPolarProduct) => {
Expand All @@ -32,6 +33,7 @@ export default function BillingPlanPicker({
organization,
currentProduct,
onComplete,
defaultInterval,
}: Props) {
const number = useNumber();
const trpc = useTRPC();
Expand All @@ -41,16 +43,25 @@ 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'),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
const [selectedProductId, setSelectedProductId] = useState<string | null>(
organization.subscriptionProductId || null,
);
const [pendingProductId, setPendingProductId] = useState<string | null>(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
Expand Down Expand Up @@ -243,7 +254,13 @@ export default function BillingPlanPicker({
<div className="row items-center justify-between gap-2 -mb-2">
<div className="font-medium">
{recurringInterval === 'year' ? (
'Switch to monthly'
<>
Yearly billing —{' '}
<span className="text-emerald-500">2 months free</span>{' '}
<span className="text-muted-foreground font-normal">
(pay for 10 months, get 12)
</span>
</>
) : (
<>
Switch to yearly and get{' '}
Expand Down Expand Up @@ -310,7 +327,12 @@ export default function BillingPlanPicker({
>
<span className={'font-medium'}>{product.name}</span>
<div className="row items-center gap-2">
<span className="font-bold">{number.currency(price)}</span>
<span className="font-bold">
{number.currency(price)}
<span className="font-normal text-muted-foreground">
/{recurringInterval === 'year' ? 'yr' : 'mo'}
</span>
</span>
{renderRowIndicator(product)}
</div>
</button>
Expand Down
105 changes: 105 additions & 0 deletions apps/start/src/components/organization/yearly-switch-prompt.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="col gap-1 border-b bg-card p-4 lg:p-8">
<div className="font-medium text-lg">
Switch to yearly — get 2 months free
</div>
<div className="mb-1">
You've been with us for a while. Pay for 10 months and get 12: same
plan, same limits, one invoice a year.
</div>
<div className="row gap-2">
<Button
loading={currentProductQuery.isLoading}
onClick={() => {
op.track('yearly_prompt_clicked', {
organizationId: organization.id,
});
pushModal('SelectBillingPlan', {
organization,
currentProduct: currentProductQuery.data ?? null,
defaultInterval: 'year',
});
}}
>
See my yearly price
</Button>
<Button onClick={dismiss} variant="outline">
Maybe later
</Button>
</div>
</div>
);
}
3 changes: 3 additions & 0 deletions apps/start/src/modals/select-billing-plan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ModalContent className="!flex !flex-col !overflow-hidden">
<ModalHeader title="Select a billing plan" />
<BillingPlanPicker
currentProduct={currentProduct}
defaultInterval={defaultInterval}
onComplete={popModal}
organization={organization}
/>
Expand Down
7 changes: 7 additions & 0 deletions apps/start/src/routes/_app.$organizationId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -138,8 +139,14 @@ function Component() {
isProjectRoute &&
subscriptionBlocksDashboard(organization.subscriptionState);

const location = useLocation();
const isBillingPage = /\/.+\/billing/.test(location.pathname);

return (
<>
{!stateMeta.banner && !isBillingPage && (
<YearlySwitchPrompt organization={organization} />
)}
{stateMeta.banner && !hideBannerForPrompt && (
<Alert
title={stateMeta.banner.title}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Stable subscription-creation timestamp for tenure. subscriptionStartsAt is
-- overwritten with the current period start on every renewal, so it cannot
-- answer "how long has this customer been subscribed" — this column can.
-- Backfilled from Polar via packages/payments/scripts/sync-subscriptions.ts.
ALTER TABLE "organizations"
ADD COLUMN "subscriptionFirstStartedAt" TIMESTAMP(3);
4 changes: 4 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ model Organization {
// until the period ends, then flips to `paused`.
subscriptionPauseAtPeriodEnd Boolean @default(false)
subscriptionResumesAt DateTime?
// When the current Polar subscription was first created. Unlike
// subscriptionStartsAt (overwritten with each period start on renewal) this
// is stable, so it can measure tenure (e.g. the switch-to-yearly prompt).
subscriptionFirstStartedAt DateTime?

// When deleteAt > now(), the organization will be deleted
deleteAt DateTime?
Expand Down
5 changes: 5 additions & 0 deletions packages/payments/scripts/sync-subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -299,6 +303,7 @@ async function main() {
'subscriptionEndsAt',
'subscriptionCreatedByUserId',
'subscriptionInterval',
'subscriptionFirstStartedAt',
'subscriptionPeriodEventsLimit',
] as const;

Expand Down
Loading