diff --git a/apps/start/src/components/organization/billing.tsx b/apps/start/src/components/organization/billing.tsx
index 47ebf29b5..d956a18b7 100644
--- a/apps/start/src/components/organization/billing.tsx
+++ b/apps/start/src/components/organization/billing.tsx
@@ -1,4 +1,5 @@
import type { IServiceOrganization } from '@openpanel/db';
+import { getSubscriptionStateMeta } from '@openpanel/payments/subscription-state-meta';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { differenceInDays } from 'date-fns';
import { useQueryState } from 'nuqs';
@@ -16,7 +17,6 @@ import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal, useOnPushModal } from '@/modals';
import { formatDate } from '@/utils/date';
-import { getSubscriptionStateMeta } from '@openpanel/payments/subscription-state-meta';
type Props = {
organization: IServiceOrganization;
@@ -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;
@@ -73,10 +96,62 @@ export default function Billing({ organization }: Props) {
p.amountType === 'fixed' ? [p] : []
)[0];
+ // What the customer actually pays while a recurring discount applies. A
+ // `once` discount only hits the next invoice, so the header keeps the list
+ // price and the discount line below explains it.
+ const listPrice = currentPrice ? currentPrice.priceAmount / 100 : null;
+ const discountedPrice = (() => {
+ const active = organization.subscriptionDiscount;
+ if (!active || listPrice === null || active.duration === 'once') {
+ return null;
+ }
+ if (active.type === 'percentage' && active.basisPoints) {
+ return listPrice * (1 - active.basisPoints / 10_000);
+ }
+ if (active.type === 'fixed' && active.amount) {
+ return Math.max(0, listPrice - active.amount / 100);
+ }
+ return null;
+ })();
+
+ // Synced from Polar — covers the cancel-flow save offer and any discount
+ // code the customer redeemed. Without this line the card shows the full
+ // list price and an applied discount is invisible outside Polar's portal.
+ const discount = organization.subscriptionDiscount;
+ const renderDiscount = () => {
+ if (!discount) {
+ return null;
+ }
+ const value =
+ discount.type === 'percentage' && discount.basisPoints
+ ? `−${discount.basisPoints / 100}%`
+ : discount.amount
+ ? `−${number.currency(discount.amount / 100)}`
+ : null;
+ if (!value) {
+ return null;
+ }
+ const duration =
+ discount.duration === 'repeating' && discount.durationInMonths
+ ? `for the next ${discount.durationInMonths} months`
+ : discount.duration === 'forever'
+ ? 'on every invoice'
+ : 'on your next invoice';
+ // Deliberately no discount name here: names are often the redeemable code
+ // itself, and the save offer's name would advertise what the cancel flow
+ // grants. The value + duration is all the customer needs.
+ return (
+
+ {value} {duration}
+
+ );
+ };
+
const renderStatus = () => {
const meta = getSubscriptionStateMeta(organization.subscriptionState, {
endsAt: organization.subscriptionEndsAt,
canceledAt: organization.subscriptionCanceledAt,
+ resumesAt: organization.subscriptionResumesAt,
});
if (!meta.statusLine) {
@@ -86,7 +161,9 @@ export default function Billing({ organization }: Props) {
return (
{meta.statusLine.text}
@@ -117,8 +194,15 @@ export default function Billing({ organization }: Props) {
{currentProduct.name}
+ {discountedPrice !== null && (
+
+ {number.currency(currentPrice.priceAmount / 100)}
+
+ )}
- {number.currency(currentPrice.priceAmount / 100)}
+ {number.currency(
+ discountedPrice ?? currentPrice.priceAmount / 100
+ )}
{' / '}
@@ -128,6 +212,7 @@ export default function Billing({ organization }: Props) {
{renderStatus()}
+ {renderDiscount()}
{number.format(organization.subscriptionPeriodEventsCount)} /{' '}
@@ -173,19 +258,37 @@ export default function Billing({ organization }: Props) {
Customer portal
-
+
+ {isPauseState && (
+
+ )}
+
+
diff --git a/apps/start/src/components/organization/cancel-subscription-flow.tsx b/apps/start/src/components/organization/cancel-subscription-flow.tsx
new file mode 100644
index 000000000..eb45b0d75
--- /dev/null
+++ b/apps/start/src/components/organization/cancel-subscription-flow.tsx
@@ -0,0 +1,342 @@
+import type { IServiceOrganization } from '@openpanel/db';
+import type { ICancellationReason } from '@openpanel/validation';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { CheckIcon } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+import { toast } from 'sonner';
+import { ButtonContainer } from '@/components/button-container';
+import { Button } from '@/components/ui/button';
+import { Textarea } from '@/components/ui/textarea';
+import { useTRPC } from '@/integrations/trpc/react';
+import { ModalHeader } from '@/modals/Modal/Container';
+import { cn } from '@/utils/cn';
+import { formatDate } from '@/utils/date';
+import { op } from '@/utils/op';
+
+// The cancel flow lives inside the SelectBillingPlan modal as internal views
+// (no stacked modals): reason -> pause offer -> discount offer -> cancel.
+
+interface Props {
+ organization: IServiceOrganization;
+ // Back to the plan picker view.
+ onBack: () => void;
+ // A flow outcome happened (paused, discounted, or canceled) — close the modal.
+ onComplete: () => void;
+}
+
+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 CancelSubscriptionFlow({
+ organization,
+ onBack,
+ onComplete,
+}: Props) {
+ 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;
+
+ // Funnel instrumentation. Every step render emits a step_viewed event, and
+ // the unmount cleanup emits cancel_flow_abandoned when the flow closes with
+ // no recorded outcome (X, esc, click-outside, navigation) — that's the
+ // silent drop-off a decisions-only funnel can't see. Refs so the cleanup
+ // reads the latest values.
+ const outcomeRef = useRef<
+ 'paused' | 'discounted' | 'canceled' | 'kept' | null
+ >(null);
+ const stepRef = useRef('reason');
+ stepRef.current = step;
+ const reasonRef = useRef(null);
+ reasonRef.current = reason;
+
+ useEffect(() => {
+ op.track('cancel_flow_step_viewed', {
+ organizationId: organization.id,
+ step,
+ reason: reasonRef.current,
+ });
+ }, [step, organization.id]);
+
+ useEffect(
+ () => () => {
+ if (!outcomeRef.current) {
+ op.track('cancel_flow_abandoned', {
+ organizationId: organization.id,
+ step: stepRef.current,
+ reason: reasonRef.current,
+ });
+ }
+ },
+ [organization.id]
+ );
+
+ const invalidate = () => {
+ queryClient.invalidateQueries(trpc.organization.pathFilter());
+ queryClient.invalidateQueries(trpc.subscription.pathFilter());
+ };
+
+ const pauseMutation = useMutation(
+ trpc.subscription.pauseSubscription.mutationOptions({
+ onSuccess(data) {
+ outcomeRef.current = 'paused';
+ 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.`,
+ });
+ onComplete();
+ },
+ onError(error) {
+ toast.error(error.message);
+ },
+ })
+ );
+
+ const discountMutation = useMutation(
+ trpc.subscription.applySaveDiscount.mutationOptions({
+ onSuccess() {
+ outcomeRef.current = 'discounted';
+ invalidate();
+ toast.success('Discount applied', {
+ description:
+ '30% off for the next 12 months, starting with your next billing cycle.',
+ });
+ onComplete();
+ },
+ onError(error) {
+ toast.error(error.message);
+ },
+ })
+ );
+
+ const cancelMutation = useMutation(
+ trpc.subscription.cancelSubscription.mutationOptions({
+ onSuccess() {
+ outcomeRef.current = 'canceled';
+ invalidate();
+ toast.success('Subscription canceled', {
+ description: organization.subscriptionEndsAt
+ ? `Your subscription stays active until ${formatDate(organization.subscriptionEndsAt)}.`
+ : 'It might take a few seconds to update',
+ });
+ onComplete();
+ },
+ 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 (
+ <>
+
+
+
+ {REASONS.map((item) => (
+
+ ))}
+
+
+
+
+
+
+ >
+ );
+ }
+
+ if (step === 'pause') {
+ return (
+ <>
+
+
+
+ {PAUSE_MONTHS.map((months) => (
+
+ ))}
+
+
+ Billing automatically resumes after the pause — or resume earlier
+ any time from the billing page. You pay nothing while paused.
+
+
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ −30%
+
+ for the next 12 months
+
+
+
+
+
+
+ >
+ );
+}
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/hooks/use-client-secret.ts b/apps/start/src/hooks/use-client-secret.ts
index 50e430ad8..8dc5facd2 100644
--- a/apps/start/src/hooks/use-client-secret.ts
+++ b/apps/start/src/hooks/use-client-secret.ts
@@ -3,19 +3,45 @@ import { useEffect, useState } from 'react';
export const ONBOARDING_SECRET_KEY = 'onboarding.clientSecret';
const DEFAULT_SECRET = '[CLIENT_SECRET]';
+// The secret only exists client-side right after creation (we store a hash).
+// Anything derived from it (MCP token, env snippets) must check this first —
+// deriving from the placeholder produces valid-looking but broken values.
+export const isRealClientSecret = (
+ secret: string | null | undefined
+): secret is string => !!secret && secret !== DEFAULT_SECRET;
+
+// Storage can throw (blocked cookies/site data, some private modes); a missing
+// secret must degrade to the placeholder flow, never crash the page.
+const safeRead = () => {
+ try {
+ return sessionStorage.getItem(ONBOARDING_SECRET_KEY);
+ } catch {
+ return null;
+ }
+};
+
+const safeWrite = (value: string) => {
+ try {
+ sessionStorage.setItem(ONBOARDING_SECRET_KEY, value);
+ } catch {
+ // Unavailable — the connect page falls back to its secret-already-shown
+ // notice.
+ }
+};
+
export function useClientSecret() {
const [clientSecret, setClientSecret] = useState(DEFAULT_SECRET);
useEffect(() => {
if (clientSecret && DEFAULT_SECRET !== clientSecret) {
- sessionStorage.setItem(ONBOARDING_SECRET_KEY, clientSecret);
+ safeWrite(clientSecret);
}
}, [clientSecret]);
useEffect(() => {
- const clientSecret = sessionStorage.getItem(ONBOARDING_SECRET_KEY);
- if (clientSecret) {
- setClientSecret(clientSecret);
+ const stored = safeRead();
+ if (stored) {
+ setClientSecret(stored);
}
}, []);
diff --git a/apps/start/src/hooks/use-ws.ts b/apps/start/src/hooks/use-ws.ts
index b3f01b0eb..fe74da522 100644
--- a/apps/start/src/hooks/use-ws.ts
+++ b/apps/start/src/hooks/use-ws.ts
@@ -1,5 +1,5 @@
import debounce from 'lodash.debounce';
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { useWebSocket } from 'react-use-websocket/dist/lib/use-websocket';
import { getSuperJson } from '@openpanel/json';
@@ -21,12 +21,22 @@ export default function useWS(
const ws = context.apiUrl.replace(/^https/, 'wss').replace(/^http/, 'ws');
const [baseUrl, setBaseUrl] = useState(`${ws}${path}`);
+ // Always call the latest onMessage. The memoized (debounced) wrapper below
+ // otherwise captures the first render's callback — if the path changes
+ // without unmounting (e.g. switching projects), the socket would reconnect
+ // but keep invoking a handler closed over the old path's state.
+ const onMessageRef = useRef(onMessage);
+ useEffect(() => {
+ onMessageRef.current = onMessage;
+ });
+
const debouncedOnMessage = useMemo(() => {
+ const invokeLatest = (event: T) => onMessageRef.current(event);
if (options?.debounce) {
- return debounce(onMessage, options.debounce.delay, options.debounce);
+ return debounce(invokeLatest, options.debounce.delay, options.debounce);
}
- return onMessage;
- }, [options?.debounce?.delay]);
+ return invokeLatest;
+ }, [options?.debounce?.delay, options?.debounce?.maxWait]);
useEffect(() => {
if (baseUrl === `${ws}${path}`) return;
diff --git a/apps/start/src/modals/add-project.tsx b/apps/start/src/modals/add-project.tsx
index 860f3a1c3..75b688a62 100644
--- a/apps/start/src/modals/add-project.tsx
+++ b/apps/start/src/modals/add-project.tsx
@@ -22,6 +22,7 @@ import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
+import { ONBOARDING_SECRET_KEY } from '@/hooks/use-client-secret';
import { handleError, useTRPC } from '@/integrations/trpc/react';
const validator = zOnboardingProject;
@@ -109,9 +110,31 @@ export default function AddProject() {
)}
-
>
) : (
diff --git a/apps/start/src/modals/index.tsx b/apps/start/src/modals/index.tsx
index 0708a1056..a5189d4b7 100644
--- a/apps/start/src/modals/index.tsx
+++ b/apps/start/src/modals/index.tsx
@@ -26,11 +26,10 @@ import EditMember from './edit-member';
import EditReference from './edit-reference';
import EditReport from './edit-report';
import EventDetails from './event-details';
-import InsightDetails from './insight-details';
import Instructions from './Instructions';
+import InsightDetails from './insight-details';
import OverviewChartDetails from './overview-chart-details';
import OverviewFilters from './overview-filters';
-import TableFilters from './table-filters';
import PageDetails from './page-details';
import RegenerateRecoveryCodes from './regenerate-recovery-codes';
import RequestPasswordReset from './request-reset-password';
@@ -40,6 +39,7 @@ import SetupTwoFactor from './setup-two-factor';
import ShareDashboardModal from './share-dashboard-modal';
import ShareOverviewModal from './share-overview-modal';
import ShareReportModal from './share-report-modal';
+import TableFilters from './table-filters';
import ViewChartUsers from './view-chart-users';
import OverviewTopGenericModal from '@/components/overview/overview-top-generic-modal';
import OverviewTopPagesModal from '@/components/overview/overview-top-pages-modal';
diff --git a/apps/start/src/modals/select-billing-plan.tsx b/apps/start/src/modals/select-billing-plan.tsx
index 7ed8d93cd..e5ee1e885 100644
--- a/apps/start/src/modals/select-billing-plan.tsx
+++ b/apps/start/src/modals/select-billing-plan.tsx
@@ -1,26 +1,46 @@
import type { IServiceOrganization } from '@openpanel/db';
import type { IPolarProduct } from '@openpanel/payments';
+import { useState } from 'react';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
import BillingPlanPicker from '@/components/organization/billing-plan-picker';
+import CancelSubscriptionFlow from '@/components/organization/cancel-subscription-flow';
interface Props {
organization: IServiceOrganization;
currentProduct: IPolarProduct | null;
+ defaultInterval?: 'year' | 'month';
}
export default function SelectBillingPlan({
organization,
currentProduct,
+ defaultInterval,
}: Props) {
+ // Internal router: the cancel flow renders inside this modal instead of
+ // stacking another modal on top.
+ const [view, setView] = useState<'plans' | 'cancel'>('plans');
+
return (
-
-
+ {view === 'plans' ? (
+ <>
+
+ setView('cancel')}
+ onComplete={popModal}
+ organization={organization}
+ />
+ >
+ ) : (
+ setView('plans')}
+ onComplete={popModal}
+ organization={organization}
+ />
+ )}
);
}
diff --git a/apps/start/src/routes/__root.tsx b/apps/start/src/routes/__root.tsx
index 716025f5b..47b1cdc1e 100644
--- a/apps/start/src/routes/__root.tsx
+++ b/apps/start/src/routes/__root.tsx
@@ -2,7 +2,9 @@ import {
createRootRouteWithContext,
HeadContent,
Scripts,
+ useRouteContext,
} from '@tanstack/react-router';
+import { useEffect } from 'react';
import 'flag-icons/css/flag-icons.min.css';
import 'katex/dist/katex.min.css';
@@ -93,6 +95,26 @@ export const Route = createRootRouteWithContext()({
pendingComponent: FullPageLoadingState,
});
+// Tie dashboard events to the signed-in user so activation funnels
+// (signup -> project created -> first event -> report) can be measured.
+function OpIdentify() {
+ const context = useRouteContext({ strict: false });
+ const user = context.session?.user;
+
+ useEffect(() => {
+ if (user?.id) {
+ op.identify({
+ profileId: user.id,
+ email: user.email,
+ firstName: user.firstName ?? undefined,
+ lastName: user.lastName ?? undefined,
+ });
+ }
+ }, [user?.id, user?.email, user?.firstName, user?.lastName]);
+
+ return null;
+}
+
function RootDocument({ children }: { children: React.ReactNode }) {
useSessionExtension();
@@ -102,6 +124,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
+
{children}
diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx
index c89425b2c..706a3cd85 100644
--- a/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx
+++ b/apps/start/src/routes/_app.$organizationId.$projectId.index.tsx
@@ -1,5 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { LazyComponent } from '@/components/lazy-component';
+import ActivationBanner from '@/components/onboarding/activation-banner';
import { useRangePageContext } from '@/hooks/use-page-context-helpers';
import {
OverviewFilterButton,
@@ -39,6 +40,7 @@ function ProjectDashboard() {
useRangePageContext('overview');
return (
+
diff --git a/apps/start/src/routes/_app.$organizationId.tsx b/apps/start/src/routes/_app.$organizationId.tsx
index 0bcb911a6..e86ad5f62 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';
@@ -122,6 +123,7 @@ function Component() {
const stateMeta = getSubscriptionStateMeta(organization.subscriptionState, {
endsAt: organization.subscriptionEndsAt,
canceledAt: organization.subscriptionCanceledAt,
+ resumesAt: organization.subscriptionResumesAt,
});
// Project routes show the full-screen BillingPrompt for blocking states;
@@ -137,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 && (
)}
+ {organization.isActive &&
+ !organization.isExceeded &&
+ organization.subscriptionPeriodEventsLimit > 0 &&
+ organization.subscriptionPeriodEventsCount >=
+ organization.subscriptionPeriodEventsLimit * 0.8 && (
+
+
+ See plans
+
+
+ )}
{organization.subscriptionPeriodEventsCountExceededAt &&
organization.isActive &&
organization.isExceeded && (
{
const blob = new Blob([credentials], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
@@ -80,7 +90,7 @@ function Component() {
onClick={() => clipboard(credentials)}
variant="outline"
>
- Copy
+ Copy all
-
+
+
+ {hasSecret &&
}
+ {mcpToken && (
+
+
+
+ Authenticates the MCP server (base64-encoded client ID and
+ secret).
+
+
+ )}
+ {!hasSecret && (
+
+ Your client secret (and the MCP token derived from it) is only
+ shown once, right after the client is created. If you need it
+ again, create a new client under Settings → Clients.
+
+ )}
+
diff --git a/apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx b/apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx
index f600605c4..abe43ebcd 100644
--- a/apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx
+++ b/apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx
@@ -1,4 +1,4 @@
-import { useQuery } from '@tanstack/react-query';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute, Link, redirect } from '@tanstack/react-router';
import { BoxSelectIcon } from 'lucide-react';
import { ButtonContainer } from '@/components/button-container';
@@ -7,8 +7,11 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
import VerifyListener from '@/components/onboarding/onboarding-verify-listener';
import { VerifyFaq } from '@/components/onboarding/verify-faq';
import { LinkButton } from '@/components/ui/button';
+import { useEffect } from 'react';
+import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/lib/utils';
+import { op } from '@/utils/op';
import { createEntityTitle, PAGE_TITLES } from '@/utils/title';
export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({
@@ -34,15 +37,35 @@ export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
+ const queryClient = useQueryClient();
const { data: events } = useQuery(
trpc.event.events.queryOptions(
{ projectId },
{
- refetchInterval: 2500,
+ // The live websocket below flips the verifier instantly; this poll is
+ // only a fallback for when the socket can't connect.
+ refetchInterval: 10_000,
}
)
);
+ // Refetch the event list the moment an event arrives instead of waiting for
+ // the next poll — same channel the in-app live event feed uses.
+ useWS(`/live/events/${projectId}`, () => {
+ queryClient.invalidateQueries(
+ trpc.event.events.queryFilter({ projectId })
+ );
+ });
const isVerified = events?.data && events.data.length > 0;
+
+ useEffect(() => {
+ op.track('onboarding_verify_viewed', { projectId });
+ }, [projectId]);
+
+ useEffect(() => {
+ if (isVerified) {
+ op.track('onboarding_first_event_verified', { projectId });
+ }
+ }, [isVerified, projectId]);
const { data: project } = useQuery(
trpc.project.getProjectWithClients.queryOptions({ projectId })
);
diff --git a/apps/start/src/routes/_steps.onboarding.project.tsx b/apps/start/src/routes/_steps.onboarding.project.tsx
index a6e616259..108617504 100644
--- a/apps/start/src/routes/_steps.onboarding.project.tsx
+++ b/apps/start/src/routes/_steps.onboarding.project.tsx
@@ -27,6 +27,7 @@ import { Label } from '@/components/ui/label';
import { useClientSecret } from '@/hooks/use-client-secret';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
+import { op } from '@/utils/op';
const validateSearch = z.object({
inviteId: z.string().optional(),
@@ -74,6 +75,7 @@ function Component() {
trpc.onboarding.project.mutationOptions({
onError: handleError,
onSuccess(res) {
+ op.track('onboarding_project_created', { projectId: res.projectId });
queryClient.invalidateQueries(trpc.organization.list.queryFilter());
setSecret(res.secret);
navigate({
diff --git a/apps/worker/src/boot-cron.ts b/apps/worker/src/boot-cron.ts
index 9e17b845e..7c110d3c0 100644
--- a/apps/worker/src/boot-cron.ts
+++ b/apps/worker/src/boot-cron.ts
@@ -113,6 +113,11 @@ export async function bootCron() {
type: 'weeklyDigest',
pattern: '0 8 * * 1', // Mondays 08:00 UTC — weekly analytics digest email
},
+ {
+ name: 'dataHealth',
+ type: 'dataHealth',
+ pattern: '30 7 * * *', // Daily 07:30 UTC — no-data / data-stopped rescue emails
+ },
];
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {
diff --git a/apps/worker/src/boot-debug.ts b/apps/worker/src/boot-debug.ts
index 28bb2dd1e..95c0f9a44 100644
--- a/apps/worker/src/boot-debug.ts
+++ b/apps/worker/src/boot-debug.ts
@@ -30,6 +30,7 @@ const CRON_TYPES = [
'sessionVacuum',
'insightCleanup',
'weeklyDigest',
+ 'dataHealth',
] as const satisfies readonly CronQueueType[];
function escapeHtml(value: string) {
diff --git a/apps/worker/src/jobs/cron.data-health.ts b/apps/worker/src/jobs/cron.data-health.ts
new file mode 100644
index 000000000..5dc2e7513
--- /dev/null
+++ b/apps/worker/src/jobs/cron.data-health.ts
@@ -0,0 +1,207 @@
+import { db, getLastEventPerProject } from '@openpanel/db';
+import { sendEmail } from '@openpanel/email';
+import { logger as baseLogger } from '@/utils/logger';
+
+const logger = baseLogger.child({ job: 'data-health' });
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+// A brand-new project gets 48h to send its first event before we reach out.
+const NO_DATA_AFTER_MS = 2 * DAY_MS;
+// An active project whose newest event is older than this counts as stalled.
+const STALLED_AFTER_MS = 7 * DAY_MS;
+
+interface OrgAlert {
+ organizationId: string;
+ noData: { id: string; name: string }[];
+ stalled: { id: string; name: string; lastEventAt: Date }[];
+}
+
+async function recipientsForOrg(organizationId: string) {
+ const members = await db.member.findMany({
+ where: {
+ organizationId,
+ user: { deletedAt: null },
+ },
+ include: { user: { select: { email: true, firstName: true } } },
+ });
+ const seen = new Map
();
+ for (const member of members) {
+ if (member.user?.email && !seen.has(member.user.email)) {
+ seen.set(member.user.email, member.user.firstName ?? undefined);
+ }
+ }
+ return seen;
+}
+
+/**
+ * Daily rescue emails for paying/trialing orgs whose tracking is broken:
+ * projects that never received an event (48h grace) and projects whose event
+ * flow stalled for 7+ days. A silently broken install reads as "the product
+ * stopped working" — a working install is the cheapest retention there is.
+ *
+ * Dedupe: `noDataNotifiedAt` is sent once per project; `dataStoppedNotifiedAt`
+ * is compared against the newest event, so data resuming and stalling again
+ * notifies again without any clearing step.
+ */
+export async function dataHealthCronJob() {
+ if (process.env.SELF_HOSTED === 'true') {
+ return;
+ }
+
+ const now = Date.now();
+ const lastEventByProject = await getLastEventPerProject();
+
+ // Prefilter on the raw status column (computed fields can't be used in
+ // `where`), then refine with the canonical subscription state below.
+ const projects = await db.project.findMany({
+ where: {
+ deleteAt: null,
+ organization: { subscriptionStatus: { in: ['active', 'trialing'] } },
+ },
+ select: {
+ id: true,
+ name: true,
+ createdAt: true,
+ organizationId: true,
+ noDataNotifiedAt: true,
+ dataStoppedNotifiedAt: true,
+ organization: {
+ select: { id: true, subscriptionState: true, onboarding: true },
+ },
+ },
+ });
+
+ const byOrg = new Map();
+
+ for (const project of projects) {
+ const state = project.organization.subscriptionState;
+ if (state !== 'active' && state !== 'trialing') {
+ continue;
+ }
+
+ const lastEventAt = lastEventByProject.get(project.id);
+
+ if (!lastEventAt) {
+ // Never received an event. One notice per project, after the grace
+ // period. Orgs still in the onboarding email drip are excluded — its
+ // day-2/6 emails already carry "stuck on the install?" variants, so this
+ // notice targets post-onboarding orgs and additional projects.
+ const inOnboardingDrip = project.organization.onboarding !== 'completed';
+ const oldEnough = now - project.createdAt.getTime() > NO_DATA_AFTER_MS;
+ if (oldEnough && !project.noDataNotifiedAt && !inOnboardingDrip) {
+ const entry = byOrg.get(project.organizationId) ?? {
+ organizationId: project.organizationId,
+ noData: [],
+ stalled: [],
+ };
+ entry.noData.push({ id: project.id, name: project.name });
+ byOrg.set(project.organizationId, entry);
+ }
+ continue;
+ }
+
+ const stalled = now - lastEventAt.getTime() > STALLED_AFTER_MS;
+ const alreadyNotifiedForThisStall =
+ project.dataStoppedNotifiedAt &&
+ project.dataStoppedNotifiedAt > lastEventAt;
+ if (stalled && !alreadyNotifiedForThisStall) {
+ const entry = byOrg.get(project.organizationId) ?? {
+ organizationId: project.organizationId,
+ noData: [],
+ stalled: [],
+ };
+ entry.stalled.push({
+ id: project.id,
+ name: project.name,
+ lastEventAt,
+ });
+ byOrg.set(project.organizationId, entry);
+ }
+ }
+
+ let emailsSent = 0;
+
+ for (const alert of byOrg.values()) {
+ try {
+ const recipients = await recipientsForOrg(alert.organizationId);
+ if (recipients.size === 0) {
+ continue;
+ }
+
+ const dashboardUrl = `${process.env.DASHBOARD_URL ?? 'https://dashboard.openpanel.dev'}/${alert.organizationId}`;
+
+ if (alert.noData.length > 0) {
+ for (const [to, firstName] of recipients) {
+ // Per-recipient guard: one bad address must not block the marker
+ // update below — that would re-email everyone tomorrow.
+ try {
+ await sendEmail('tracking-no-data', {
+ to,
+ data: {
+ firstName,
+ projectNames: alert.noData.map((p) => p.name),
+ dashboardUrl,
+ },
+ });
+ emailsSent++;
+ } catch (err) {
+ logger.error(
+ { err, organizationId: alert.organizationId, recipient: to },
+ 'Failed to send no-data alert to recipient'
+ );
+ }
+ }
+ await db.project.updateMany({
+ where: { id: { in: alert.noData.map((p) => p.id) } },
+ data: { noDataNotifiedAt: new Date() },
+ });
+ }
+
+ if (alert.stalled.length > 0) {
+ const newestLastEvent = alert.stalled
+ .map((p) => p.lastEventAt)
+ .sort((a, b) => b.getTime() - a.getTime())[0];
+ for (const [to, firstName] of recipients) {
+ try {
+ await sendEmail('tracking-data-stopped', {
+ to,
+ data: {
+ firstName,
+ projectNames: alert.stalled.map((p) => p.name),
+ lastEventDate: newestLastEvent?.toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ }),
+ dashboardUrl,
+ },
+ });
+ emailsSent++;
+ } catch (err) {
+ logger.error(
+ { err, organizationId: alert.organizationId, recipient: to },
+ 'Failed to send data-stopped alert to recipient'
+ );
+ }
+ }
+ await db.project.updateMany({
+ where: { id: { in: alert.stalled.map((p) => p.id) } },
+ data: { dataStoppedNotifiedAt: new Date() },
+ });
+ }
+ } catch (err) {
+ logger.error(
+ { err, organizationId: alert.organizationId },
+ 'Data-health alert failed'
+ );
+ }
+ }
+
+ logger.info(
+ {
+ projects: projects.length,
+ organizations: byOrg.size,
+ emailsSent,
+ },
+ 'Data-health check complete'
+ );
+}
diff --git a/apps/worker/src/jobs/cron.ts b/apps/worker/src/jobs/cron.ts
index 008d2dbd5..b922ab670 100644
--- a/apps/worker/src/jobs/cron.ts
+++ b/apps/worker/src/jobs/cron.ts
@@ -9,6 +9,7 @@ import {
import type { CronQueuePayload } from '@openpanel/queue';
import type { Job } from 'bullmq';
import { cohortRefreshCronJob } from './cron.cohort-refresh';
+import { dataHealthCronJob } from './cron.data-health';
import { jobDelete } from './cron.delete';
import { insightCleanupCronJob } from './cron.insight-cleanup';
import { weeklyDigestCronJob } from './cron.weekly-digest';
@@ -75,5 +76,8 @@ export async function cronJob(job: Job) {
case 'weeklyDigest': {
return await weeklyDigestCronJob();
}
+ case 'dataHealth': {
+ return await dataHealthCronJob();
+ }
}
}
diff --git a/apps/worker/src/jobs/cron.weekly-digest.ts b/apps/worker/src/jobs/cron.weekly-digest.ts
index 6902bc4cd..6a9435fb2 100644
--- a/apps/worker/src/jobs/cron.weekly-digest.ts
+++ b/apps/worker/src/jobs/cron.weekly-digest.ts
@@ -6,7 +6,11 @@ import { logger as baseLogger } from '@/utils/logger';
const logger = baseLogger.child({ job: 'weekly-digest' });
const DAY_MS = 24 * 60 * 60 * 1000;
-const MIN_EVENTS = 5000;
+// Keep this low: the digest is our main "value without logging in" touchpoint,
+// and the old 5000 gate excluded customers on smaller plans — exactly the ones
+// who benefit most from the reminder. Zero-visitor weeks are still skipped per
+// send, so quiet projects don't get empty emails.
+const MIN_EVENTS = 100;
const MAX_INSIGHTS = 5;
type DigestData = EmailData<'weekly-digest'>;
diff --git a/apps/worker/src/jobs/events.incoming-event.ts b/apps/worker/src/jobs/events.incoming-event.ts
index 0cac12864..8d13de7dd 100644
--- a/apps/worker/src/jobs/events.incoming-event.ts
+++ b/apps/worker/src/jobs/events.incoming-event.ts
@@ -4,6 +4,7 @@ import type { IServiceCreateEventPayload, IServiceEvent } from '@openpanel/db';
import {
checkNotificationRulesForEvent,
createEvent,
+ db,
getProjectByIdCached,
matchEvent,
sessionBuffer,
@@ -35,6 +36,24 @@ async function isEventExcludedByProjectFilter(
return eventExcludeFilters.some((filter) => matchEvent(payload, filter));
}
+/**
+ * Records the project's first-ever event timestamp exactly once. The cached
+ * project read makes this a no-op on every event after the first; the
+ * conditional update keeps concurrent workers idempotent.
+ */
+async function markFirstEvent(projectId: string, logger: ILogger) {
+ const project = await getProjectByIdCached(projectId);
+ if (!project || project.firstEventAt) {
+ return;
+ }
+ await db.project.updateMany({
+ where: { id: projectId, firstEventAt: null },
+ data: { firstEventAt: new Date() },
+ });
+ await getProjectByIdCached.clear(projectId);
+ logger.info({ projectId }, 'Project received its first event');
+}
+
async function createEventAndNotify(
payload: IServiceCreateEventPayload,
logger: ILogger,
@@ -54,6 +73,10 @@ async function createEventAndNotify(
createEvent(payload),
checkNotificationRulesForEvent(payload).catch(() => null),
]);
+ // Only after the event is accepted — recording the first event before a
+ // failed createEvent would leave the activation checklist claiming data
+ // arrived that was never persisted.
+ await markFirstEvent(projectId, logger).catch(() => null);
return event;
}
diff --git a/apps/worker/src/jobs/notification.ts b/apps/worker/src/jobs/notification.ts
index 702fa93e3..91a84c2f7 100644
--- a/apps/worker/src/jobs/notification.ts
+++ b/apps/worker/src/jobs/notification.ts
@@ -1,6 +1,7 @@
import type { Job } from 'bullmq';
import { Prisma, db } from '@openpanel/db';
+import { sendEmail } from '@openpanel/email';
import { sendDiscordNotification } from '@openpanel/integrations/src/discord';
import { sendSlackNotification } from '@openpanel/integrations/src/slack';
import { execute as executeJavaScriptTemplate } from '@openpanel/js-runtime';
@@ -29,6 +30,35 @@ export async function notificationJob(job: Job) {
}
if (notification.sendToEmail) {
+ const project = await db.project.findUniqueOrThrow({
+ where: { id: notification.projectId },
+ select: { name: true, organizationId: true },
+ });
+ const members = await db.member.findMany({
+ where: {
+ organizationId: project.organizationId,
+ user: { deletedAt: null },
+ },
+ include: { user: { select: { email: true } } },
+ });
+ const emails = new Set(
+ members.flatMap((member) =>
+ member.user?.email ? [member.user.email] : [],
+ ),
+ );
+ for (const to of emails) {
+ // Per-recipient unsubscribe (product_alerts category) is handled
+ // inside sendEmail.
+ await sendEmail('notification-rule', {
+ to,
+ data: {
+ title: notification.title,
+ message: notification.message,
+ projectName: project.name,
+ dashboardUrl: `${process.env.DASHBOARD_URL ?? 'https://dashboard.openpanel.dev'}/${project.organizationId}/${notification.projectId}`,
+ },
+ });
+ }
return;
}
diff --git a/apps/worker/src/jobs/sessions.ts b/apps/worker/src/jobs/sessions.ts
index d41303ae6..808d7ba51 100644
--- a/apps/worker/src/jobs/sessions.ts
+++ b/apps/worker/src/jobs/sessions.ts
@@ -1,17 +1,18 @@
-import type { Job } from 'bullmq';
-
-import type { SessionsQueuePayload } from '@openpanel/queue';
-
-import { logger } from '@/utils/logger';
+import type { Organization } from '@openpanel/db';
import {
db,
getOrganizationBillingEventsCount,
getProjectEventsCount,
} from '@openpanel/db';
+import { sendEmail } from '@openpanel/email';
+import type { SessionsQueuePayload } from '@openpanel/queue';
import { cacheable } from '@openpanel/redis';
+import type { Job } from 'bullmq';
import { createSessionEnd } from './events.create-session-end';
+import { logger } from '@/utils/logger';
const INT4_MAX = 2_147_483_647;
+const USAGE_WARNING_THRESHOLD = 0.8;
export async function sessionsJob(job: Job) {
const res = await createSessionEnd(job);
@@ -24,7 +25,7 @@ export async function sessionsJob(job: Job) {
}
const updateEventsCount = cacheable(async function updateEventsCount(
- projectId: string,
+ projectId: string
) {
const organization = await db.organization.findFirst({
where: {
@@ -74,7 +75,7 @@ const updateEventsCount = cacheable(async function updateEventsCount(
data: {
subscriptionPeriodEventsCount: Math.min(
organizationEventsCount,
- INT4_MAX,
+ INT4_MAX
),
subscriptionPeriodEventsCountExceededAt: isSelfHosted
? null
@@ -88,7 +89,140 @@ const updateEventsCount = cacheable(async function updateEventsCount(
: organization.subscriptionPeriodEventsCountExceededAt,
},
});
+
+ if (!isSelfHosted) {
+ try {
+ await sendUsageAlerts(organization, organizationEventsCount);
+ } catch (e) {
+ logger.error({ err: e }, 'Failed to send usage alert emails');
+ }
+ }
}
return true;
}, 60 * 60);
+
+/**
+ * One warning at 80% and one notice at 100% per billing cycle. The sent-at
+ * markers are cleared by the Polar webhook when a new cycle resets the usage
+ * counter (or the limit is raised), so each cycle can alert again.
+ */
+async function sendUsageAlerts(organization: Organization, count: number) {
+ const limit = organization.subscriptionPeriodEventsLimit;
+ if (!limit || limit <= 0) {
+ return;
+ }
+
+ const exceeded = count > limit && !organization.usageExceededSentAt;
+ const nearLimit =
+ !exceeded &&
+ count >= limit * USAGE_WARNING_THRESHOLD &&
+ count <= limit &&
+ !organization.usageWarningSentAt;
+
+ if (!(exceeded || nearLimit)) {
+ return;
+ }
+
+ // Claim the alert atomically BEFORE sending: session jobs for different
+ // projects of the same org can run concurrently, and both would otherwise
+ // read null markers and double-send. Marking the warning together with the
+ // exceeded notice keeps a both-thresholds-in-one-jump crossing from queueing
+ // a redundant warning afterwards. Rolled back if every send fails.
+ const claimedAt = new Date();
+ const claimed = await db.organization.updateMany({
+ where: {
+ id: organization.id,
+ ...(exceeded
+ ? { usageExceededSentAt: null }
+ : { usageWarningSentAt: null }),
+ },
+ data: exceeded
+ ? { usageExceededSentAt: claimedAt, usageWarningSentAt: claimedAt }
+ : { usageWarningSentAt: claimedAt },
+ });
+ if (claimed.count === 0) {
+ return;
+ }
+
+ try {
+ const admins = await db.member.findMany({
+ where: {
+ organizationId: organization.id,
+ role: 'org:admin',
+ user: { deletedAt: null },
+ },
+ include: { user: { select: { email: true, firstName: true } } },
+ });
+
+ const billingUrl = `${process.env.DASHBOARD_URL ?? 'https://dashboard.openpanel.dev'}/${organization.id}/billing`;
+ const recipients = new Map(
+ admins
+ .filter((member) => member.user?.email)
+ .map((member) => [
+ member.user!.email,
+ member.user!.firstName ?? undefined,
+ ])
+ );
+
+ let failedRecipients = 0;
+ for (const [email, firstName] of recipients) {
+ // Per-recipient guard: one bad address must not abort the loop or roll
+ // back the claim — that would re-email the recipients that succeeded.
+ try {
+ if (exceeded) {
+ await sendEmail('usage-limit-exceeded', {
+ to: email,
+ data: {
+ firstName,
+ organizationName: organization.name,
+ billingUrl,
+ eventsLimit: limit,
+ },
+ });
+ } else {
+ await sendEmail('usage-near-limit', {
+ to: email,
+ data: {
+ firstName,
+ organizationName: organization.name,
+ billingUrl,
+ eventsCount: count,
+ eventsLimit: limit,
+ },
+ });
+ }
+ } catch (error) {
+ failedRecipients++;
+ logger.error(
+ { err: error, organizationId: organization.id, recipient: email },
+ 'Failed to send usage alert to recipient'
+ );
+ }
+ }
+
+ logger.info(
+ {
+ organizationId: organization.id,
+ count,
+ limit,
+ kind: exceeded ? 'exceeded' : 'near-limit',
+ recipients: recipients.size,
+ failedRecipients,
+ },
+ 'Sent usage alert emails'
+ );
+ } catch (error) {
+ // Release the claim so the next usage update retries the alert.
+ await db.organization.updateMany({
+ where: { id: organization.id },
+ data: exceeded
+ ? {
+ usageExceededSentAt: null,
+ usageWarningSentAt: organization.usageWarningSentAt,
+ }
+ : { usageWarningSentAt: null },
+ });
+ throw error;
+ }
+}
diff --git a/packages/constants/index.ts b/packages/constants/index.ts
index 4a186c8d0..6f4324ebf 100644
--- a/packages/constants/index.ts
+++ b/packages/constants/index.ts
@@ -606,6 +606,11 @@ export const emailCategories = {
label: 'Weekly digest',
description: 'A weekly summary of your analytics with AI-surfaced insights',
},
+ product_alerts: {
+ label: 'Product alerts',
+ description:
+ 'Important notices about your projects: tracking stopped sending data, event limits, and alerts from your notification rules',
+ },
} as const;
export type EmailCategory = keyof typeof emailCategories;
diff --git a/packages/db/prisma/migrations/20260822090000_churn_cancel_flow_pause_discount/migration.sql b/packages/db/prisma/migrations/20260822090000_churn_cancel_flow_pause_discount/migration.sql
new file mode 100644
index 000000000..4a909a0cc
--- /dev/null
+++ b/packages/db/prisma/migrations/20260822090000_churn_cancel_flow_pause_discount/migration.sql
@@ -0,0 +1,10 @@
+-- Cancel-flow churn work: capture the customer's cancellation reason/comment,
+-- guard the one-time save-offer discount, and mirror Polar's pause state
+-- (pause-at-period-end keeps status `active` until the period ends, then the
+-- subscription flips to `paused`; `resumesAt` schedules the automatic resume).
+ALTER TABLE "organizations"
+ ADD COLUMN "subscriptionCancelReason" TEXT,
+ ADD COLUMN "subscriptionCancelComment" TEXT,
+ ADD COLUMN "subscriptionSaveDiscountAppliedAt" TIMESTAMP(3),
+ ADD COLUMN "subscriptionPauseAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
+ ADD COLUMN "subscriptionResumesAt" TIMESTAMP(3);
diff --git a/packages/db/prisma/migrations/20260822100000_subscription_first_started_at/migration.sql b/packages/db/prisma/migrations/20260822100000_subscription_first_started_at/migration.sql
new file mode 100644
index 000000000..67e08a2ca
--- /dev/null
+++ b/packages/db/prisma/migrations/20260822100000_subscription_first_started_at/migration.sql
@@ -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);
diff --git a/packages/db/prisma/migrations/20260822110000_data_health_and_usage_alerts/migration.sql b/packages/db/prisma/migrations/20260822110000_data_health_and_usage_alerts/migration.sql
new file mode 100644
index 000000000..0650394f8
--- /dev/null
+++ b/packages/db/prisma/migrations/20260822110000_data_health_and_usage_alerts/migration.sql
@@ -0,0 +1,9 @@
+-- Usage-alert dedupe markers (cleared on billing-cycle reset / limit raise)
+-- and data-health notice markers for the dataHealth cron.
+ALTER TABLE "organizations"
+ ADD COLUMN "usageWarningSentAt" TIMESTAMP(3),
+ ADD COLUMN "usageExceededSentAt" TIMESTAMP(3);
+
+ALTER TABLE "projects"
+ ADD COLUMN "noDataNotifiedAt" TIMESTAMP(3),
+ ADD COLUMN "dataStoppedNotifiedAt" TIMESTAMP(3);
diff --git a/packages/db/prisma/migrations/20260822120000_project_first_event_at/migration.sql b/packages/db/prisma/migrations/20260822120000_project_first_event_at/migration.sql
new file mode 100644
index 000000000..bc7169354
--- /dev/null
+++ b/packages/db/prisma/migrations/20260822120000_project_first_event_at/migration.sql
@@ -0,0 +1,4 @@
+-- Set once by the worker when a project's first event arrives. Powers the
+-- activation checklist and onboarding verification.
+ALTER TABLE "projects"
+ ADD COLUMN "firstEventAt" TIMESTAMP(3);
diff --git a/packages/db/prisma/migrations/20260822130000_subscription_discount/migration.sql b/packages/db/prisma/migrations/20260822130000_subscription_discount/migration.sql
new file mode 100644
index 000000000..b2bfbba5c
--- /dev/null
+++ b/packages/db/prisma/migrations/20260822130000_subscription_discount/migration.sql
@@ -0,0 +1,5 @@
+-- Compact summary of the discount applied to the subscription (synced from
+-- Polar's embedded discount object) so the dashboard can show that a discount
+-- is active — including the cancel-flow save offer.
+ALTER TABLE "organizations"
+ ADD COLUMN "subscriptionDiscount" JSONB;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 10b219161..4b4ea8f47 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -101,6 +101,25 @@ model Organization {
subscriptionPeriodEventsCountExceededAt DateTime?
subscriptionPeriodEventsLimit Int @default(0)
subscriptionInterval String?
+ /// [IPrismaCancellationReason]
+ subscriptionCancelReason String?
+ subscriptionCancelComment String?
+ // When the one-time save-offer discount was applied; guards re-offering it.
+ subscriptionSaveDiscountAppliedAt DateTime?
+ // Pause-at-period-end mirrors Polar: status stays `active` with this flag set
+ // 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?
+ // Usage-alert dedupe markers — cleared when a new billing cycle resets the
+ // usage counter (or the limit is raised), so each cycle can warn once.
+ usageWarningSentAt DateTime?
+ usageExceededSentAt DateTime?
+ /// [IPrismaSubscriptionDiscount]
+ subscriptionDiscount Json?
// When deleteAt > now(), the organization will be deleted
deleteAt DateTime?
@@ -237,6 +256,15 @@ model Project {
allowUnsafeRevenueTracking Boolean @default(false)
/// [IPrismaProjectFilters]
filters Json @default("[]")
+ // Set once when the project's first event arrives (activation checklist,
+ // onboarding). Never updated after that.
+ firstEventAt DateTime?
+ // Data-health notice markers set by the dataHealth cron. `noDataNotifiedAt`:
+ // told the org this project never received events. `dataStoppedNotifiedAt`:
+ // told them the event flow stalled — compared against the last event time, so
+ // a resume followed by a new stall notifies again without clearing.
+ noDataNotifiedAt DateTime?
+ dataStoppedNotifiedAt DateTime?
clients Client[]
reports Report[]
diff --git a/packages/db/scripts/set-subscription-state.ts b/packages/db/scripts/set-subscription-state.ts
index d7d8e8444..71362534e 100644
--- a/packages/db/scripts/set-subscription-state.ts
+++ b/packages/db/scripts/set-subscription-state.ts
@@ -23,62 +23,95 @@ type Recipe = {
subscriptionCanceledAt: Date | null;
subscriptionStartsAt: Date | null;
subscriptionEndsAt: Date | null;
+ subscriptionPauseAtPeriodEnd: boolean;
+ subscriptionResumesAt: Date | null;
};
+const base = {
+ subscriptionPauseAtPeriodEnd: false,
+ subscriptionResumesAt: null,
+} as const;
+
const recipes: Record Recipe> = {
trialing: () => ({
+ ...base,
subscriptionStatus: null,
subscriptionCanceledAt: null,
subscriptionStartsAt: null,
subscriptionEndsAt: future(),
}),
trial_expired: () => ({
+ ...base,
subscriptionStatus: null,
subscriptionCanceledAt: null,
subscriptionStartsAt: null,
subscriptionEndsAt: past(),
}),
active: () => ({
+ ...base,
subscriptionStatus: 'active',
subscriptionCanceledAt: null,
subscriptionStartsAt: new Date(),
subscriptionEndsAt: future(),
}),
canceling: () => ({
+ ...base,
subscriptionStatus: 'active',
subscriptionCanceledAt: new Date(),
subscriptionStartsAt: new Date(),
subscriptionEndsAt: future(),
}),
canceled: () => ({
+ ...base,
subscriptionStatus: 'canceled',
subscriptionCanceledAt: past(),
subscriptionStartsAt: monthAgo(),
subscriptionEndsAt: past(),
}),
past_due: () => ({
+ ...base,
subscriptionStatus: 'past_due',
subscriptionCanceledAt: null,
subscriptionStartsAt: monthAgo(),
subscriptionEndsAt: future(),
}),
unpaid: () => ({
+ ...base,
subscriptionStatus: 'unpaid',
subscriptionCanceledAt: null,
subscriptionStartsAt: monthAgo(),
subscriptionEndsAt: future(),
}),
incomplete: () => ({
+ ...base,
subscriptionStatus: 'incomplete',
subscriptionCanceledAt: null,
subscriptionStartsAt: new Date(),
subscriptionEndsAt: future(),
}),
expired: () => ({
+ ...base,
+ subscriptionStatus: 'active',
+ subscriptionCanceledAt: null,
+ subscriptionStartsAt: monthAgo(),
+ subscriptionEndsAt: past(),
+ }),
+ pausing: () => ({
+ ...base,
subscriptionStatus: 'active',
subscriptionCanceledAt: null,
+ subscriptionStartsAt: new Date(),
+ subscriptionEndsAt: future(),
+ subscriptionPauseAtPeriodEnd: true,
+ subscriptionResumesAt: new Date(Date.now() + 60 * DAY),
+ }),
+ paused: () => ({
+ ...base,
+ subscriptionStatus: 'paused',
+ subscriptionCanceledAt: null,
subscriptionStartsAt: monthAgo(),
subscriptionEndsAt: past(),
+ subscriptionResumesAt: new Date(Date.now() + 30 * DAY),
}),
};
diff --git a/packages/db/src/prisma-client.ts b/packages/db/src/prisma-client.ts
index 534b3bb67..3c3441451 100644
--- a/packages/db/src/prisma-client.ts
+++ b/packages/db/src/prisma-client.ts
@@ -7,6 +7,7 @@ const subscriptionStateNeeds = {
subscriptionStatus: true,
subscriptionCanceledAt: true,
subscriptionEndsAt: true,
+ subscriptionPauseAtPeriodEnd: true,
} as const;
const getPrismaClient = () => {
@@ -38,6 +39,8 @@ const getPrismaClient = () => {
return (
state === 'active' ||
state === 'canceling' ||
+ state === 'pausing' ||
+ state === 'paused' ||
state === 'past_due' ||
state === 'unpaid' ||
state === 'incomplete'
diff --git a/packages/db/src/services/notification.service.ts b/packages/db/src/services/notification.service.ts
index 5b357151a..9348a4849 100644
--- a/packages/db/src/services/notification.service.ts
+++ b/packages/db/src/services/notification.service.ts
@@ -50,16 +50,16 @@ export const BASE_INTEGRATIONS: Integration[] = [
},
organizationId: '',
},
- // {
- // id: EMAIL_NOTIFICATION_INTEGRATION_ID,
- // name: 'Email',
- // createdAt: new Date(),
- // updatedAt: new Date(),
- // config: {
- // type: EMAIL_NOTIFICATION_INTEGRATION_ID,
- // },
- // organizationId: '',
- // },
+ {
+ id: EMAIL_NOTIFICATION_INTEGRATION_ID,
+ name: 'Email',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ config: {
+ type: EMAIL_NOTIFICATION_INTEGRATION_ID,
+ },
+ organizationId: '',
+ },
];
export const isBaseIntegration = (id: string) =>
diff --git a/packages/db/src/services/project.service.ts b/packages/db/src/services/project.service.ts
index 1639ba1c4..66b8ad211 100644
--- a/packages/db/src/services/project.service.ts
+++ b/packages/db/src/services/project.service.ts
@@ -1,8 +1,13 @@
import { cacheable } from '@openpanel/redis';
import sqlstring from 'sqlstring';
-import { chQuery, TABLE_NAMES } from '../clickhouse/client';
-import { ClientType, type Prisma, type Project } from '../prisma-client';
-import { db } from '../prisma-client';
+import {
+ ch,
+ chQuery,
+ convertClickhouseDateToJs,
+ TABLE_NAMES,
+} from '../clickhouse/client';
+import { clix } from '../clickhouse/query-builder';
+import { db, type Prisma, type Project } from '../prisma-client';
export type IServiceProject = Project;
export type IServiceProjectWithClients = Prisma.ProjectGetPayload<{
@@ -119,6 +124,33 @@ export const getProjectEventsCount = async (projectId: string) => {
return res[0]?.count;
};
+/**
+ * Newest event timestamp per project, for the whole instance in one query.
+ * Reads the same pre-aggregated MV as getProjectEventsCount (it stores
+ * max(created_at) per (project_id, name) block), so this scans thousands of
+ * rows instead of the raw events table. Projects with no events are absent
+ * from the map.
+ */
+export const getLastEventPerProject = async (): Promise