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
38 changes: 38 additions & 0 deletions apps/api/src/controllers/webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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' &&
Expand Down Expand Up @@ -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':
Expand Down
40 changes: 8 additions & 32 deletions apps/start/src/components/organization/billing-plan-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -152,15 +129,12 @@ 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,
});
// Forward onComplete so a finished cancel flow also closes the modal that
// opened the picker (SelectBillingPlan passes popModal).
pushModal('CancelSubscription', { organization, onComplete });
};

const renderAction = () => {
Expand Down Expand Up @@ -294,7 +268,9 @@ export default function BillingPlanPicker({
<div className="col divide-y divide-border border rounded-lg overflow-y-auto min-h-0">
{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)
Expand Down
78 changes: 66 additions & 12 deletions apps/start/src/components/organization/billing-prompt.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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)',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -150,6 +175,25 @@ export default function BillingPrompt({
}, [type]);

const renderCta = () => {
if (copy.action === 'resume') {
return (
<Button
className="w-full"
loading={resume.isPending}
onClick={() => {
op.track('billing_prompt_upgrade_clicked', {
type,
cta: 'resume',
});
resume.mutate({ organizationId: organization.id });
}}
size="lg"
>
{copy.cta('', '')}
</Button>
);
}

if (copy.action === 'portal') {
return (
<Button
Expand Down Expand Up @@ -238,16 +282,26 @@ export default function BillingPrompt({
Events tracked, all safe and stored
</div>
</div>
{organization.subscriptionEndsAt && (
<div className="col flex-1 gap-1 rounded-md bg-def-200 p-4">
<div className="font-bold font-mono text-2xl">
{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 (
<div className="col flex-1 gap-1 rounded-md bg-def-200 p-4">
<div className="font-bold font-mono text-2xl">
{formatDate(date)}
</div>
<div className="text-muted-foreground text-sm">
{copy.dateLabel}
</div>
</div>
<div className="text-muted-foreground text-sm">
{copy.dateLabel}
</div>
</div>
)}
);
})()}
</div>

<div className="col gap-3 p-6 md:p-8">
Expand Down
70 changes: 56 additions & 14 deletions apps/start/src/components/organization/billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -173,19 +197,37 @@ export default function Billing({ organization }: Props) {
</svg>
Customer portal
</Button>
<Button
onClick={() =>
pushModal('SelectBillingPlan', {
organization,
currentProduct,
})
}
size="sm"
>
{organization.isWillBeCanceled
? 'Reactivate subscription'
: 'Change subscription'}
</Button>
<div className="row gap-2">
{isPauseState && (
<Button
loading={resumeMutation.isPending}
onClick={() =>
resumeMutation.mutate({
organizationId: organization.id,
})
}
size="sm"
>
{organization.subscriptionState === 'paused'
? 'Resume subscription'
: 'Keep subscription'}
</Button>
)}
<Button
onClick={() =>
pushModal('SelectBillingPlan', {
organization,
currentProduct,
})
}
size="sm"
variant={isPauseState ? 'outline' : 'default'}
>
{organization.isWillBeCanceled
? 'Reactivate subscription'
: 'Change subscription'}
</Button>
</div>
</div>
</div>
</WidgetBody>
Expand Down
Loading
Loading