Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
30f4b3d
feat(billing): cancel flow with exit survey, pause offer, and save di…
lindesvard Aug 22, 2026
7da211d
fix(billing): address review feedback on cancel flow
lindesvard Aug 22, 2026
d07f0bf
feat(billing): yearly-first plan selector and switch-to-yearly prompt
lindesvard Aug 22, 2026
b18727e
fix(billing): only treat a plan as selected within the displayed inte…
lindesvard Aug 22, 2026
6cc52df
feat(alerts): usage-limit and data-health notifications
lindesvard Aug 22, 2026
689ed01
fix(alerts): address review feedback on usage alerts
lindesvard Aug 22, 2026
f806d6a
feat(onboarding): activation checklist, instant verify, second-projec…
lindesvard Aug 22, 2026
beecc51
fix(hooks): useWS invokes the latest onMessage callback
lindesvard Aug 22, 2026
b4b7258
feat(onboarding): redesign activation checklist as a setup-funnel banner
lindesvard Aug 22, 2026
94f4933
fix(onboarding): size the activation banner with container queries
lindesvard Aug 22, 2026
28e616b
fix(onboarding): stacked banner puts actions in the headline corner
lindesvard Aug 22, 2026
fcc12b4
fix(onboarding): never fabricate credentials from the placeholder secret
lindesvard Aug 22, 2026
43cde75
refactor(billing): cancel flow as internal views of the billing modal
lindesvard Aug 22, 2026
52a814d
fix(billing): center-align the discount callout text
lindesvard Aug 22, 2026
ae8882e
feat(billing): show the active subscription discount on the billing card
lindesvard Aug 22, 2026
28f4fdc
feat(billing): show the discounted price in the plan card header
lindesvard Aug 22, 2026
77434a7
fix(billing): don't render the discount name in the dashboard
lindesvard Aug 22, 2026
5501d7e
fix(billing): say months, not invoices, in the save-offer copy
lindesvard Aug 22, 2026
5d9c60c
fix: address PR review feedback
lindesvard Aug 22, 2026
6bac35c
feat(billing): funnel-grade tracking for the cancel flow
lindesvard Aug 23, 2026
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
91 changes: 89 additions & 2 deletions apps/api/src/controllers/webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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' &&
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
},
});

Expand All @@ -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':
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
52 changes: 35 additions & 17 deletions apps/start/src/components/clients/create-client-success.tsx
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -25,36 +34,45 @@ export function CreateClientSuccess({ id, secret, type }: Props) {
return (
<div className="grid min-w-0 gap-4 [&>*]:min-w-0">
<CopyInput label="Client ID" value={id} />
{secret && (
{hasSecret && (
<div className="w-full min-w-0">
<CopyInput label="Secret" value={secret} />
<p className="mt-1 text-sm text-muted-foreground">
<p className="mt-1 text-muted-foreground text-sm">
You will only need the secret if you want to send server events.
</p>
</div>
)}
{secret && showMcpToken && (
{showMcpToken && (
<div className="w-full min-w-0">
<CopyInput label="MCP Token" value={mcpToken} />
<p className="mt-1 text-sm text-muted-foreground">
<p className="mt-1 text-muted-foreground text-sm">
Use this token to authenticate with the MCP server (base64 encoded
client ID and secret).
</p>
</div>
)}
<Button variant="outline" icon={DownloadIcon} onClick={download}>
Save credentials
</Button>
<div className="row gap-2 [&>*]:flex-1">
<Button
icon={CopyIcon}
onClick={() => clipboard(credentials)}
variant="outline"
>
Copy all
</Button>
<Button icon={DownloadIcon} onClick={download} variant="outline">
Save credentials
</Button>
</div>
<Alert>
<RocketIcon className="h-4 w-4" />
<AlertTitle>Get started!</AlertTitle>
<AlertDescription>
Read our{' '}
<a
target="_blank"
href="https://openpanel.dev/docs"
className="underline"
href="https://openpanel.dev/docs"
rel="noreferrer"
target="_blank"
>
documentation
</a>{' '}
Expand Down
Loading
Loading