diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts
index 4f6ace1b1..401e45aca 100644
--- a/apps/api/src/controllers/webhook.controller.ts
+++ b/apps/api/src/controllers/webhook.controller.ts
@@ -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);
@@ -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' &&
@@ -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':
diff --git a/apps/start/src/components/organization/billing-plan-picker.tsx b/apps/start/src/components/organization/billing-plan-picker.tsx
index 488119b84..9bdc4c61a 100644
--- a/apps/start/src/components/organization/billing-plan-picker.tsx
+++ b/apps/start/src/components/organization/billing-plan-picker.tsx
@@ -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';
@@ -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', {
@@ -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 = () => {
@@ -294,7 +268,9 @@ export default function BillingPlanPicker({
{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)
diff --git a/apps/start/src/components/organization/billing-prompt.tsx b/apps/start/src/components/organization/billing-prompt.tsx
index 92a6db788..33392a3f4 100644
--- a/apps/start/src/components/organization/billing-prompt.tsx
+++ b/apps/start/src/components/organization/billing-prompt.tsx
@@ -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';
@@ -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: {
@@ -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)',
@@ -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
@@ -150,6 +175,25 @@ export default function BillingPrompt({
}, [type]);
const renderCta = () => {
+ if (copy.action === 'resume') {
+ return (
+
+ );
+ }
+
if (copy.action === 'portal') {
return (
- {organization.subscriptionEndsAt && (
-
-
- {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 (
+
+
+ {formatDate(date)}
+
+
+ {copy.dateLabel}
+
-
- {copy.dateLabel}
-
-
- )}
+ );
+ })()}
diff --git a/apps/start/src/components/organization/billing.tsx b/apps/start/src/components/organization/billing.tsx
index 47ebf29b5..c5ea3dd1b 100644
--- a/apps/start/src/components/organization/billing.tsx
+++ b/apps/start/src/components/organization/billing.tsx
@@ -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;
@@ -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) {
@@ -173,19 +197,37 @@ export default function Billing({ organization }: Props) {
Customer portal
-
+
+ {isPauseState && (
+
+ )}
+
+
diff --git a/apps/start/src/modals/cancel-subscription.tsx b/apps/start/src/modals/cancel-subscription.tsx
new file mode 100644
index 000000000..1dc41b9ef
--- /dev/null
+++ b/apps/start/src/modals/cancel-subscription.tsx
@@ -0,0 +1,285 @@
+import type { IServiceOrganization } from '@openpanel/db';
+import type { ICancellationReason } from '@openpanel/validation';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { popModal } from '.';
+import { ModalContent, ModalHeader } from './Modal/Container';
+import { ButtonContainer } from '@/components/button-container';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
+import { Textarea } from '@/components/ui/textarea';
+import { useTRPC } from '@/integrations/trpc/react';
+import { cn } from '@/utils/cn';
+import { formatDate } from '@/utils/date';
+import { op } from '@/utils/op';
+
+export interface CancelSubscriptionProps {
+ organization: IServiceOrganization;
+ // Runs after a successful outcome (pause, discount, or cancel) so the modal
+ // that opened the flow (e.g. SelectBillingPlan) can close itself too.
+ 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 CancelSubscription({
+ organization,
+ onComplete,
+}: CancelSubscriptionProps) {
+ 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;
+
+ const invalidate = () => {
+ queryClient.invalidateQueries(trpc.organization.pathFilter());
+ queryClient.invalidateQueries(trpc.subscription.pathFilter());
+ };
+
+ const finish = () => {
+ popModal('CancelSubscription');
+ onComplete?.();
+ };
+
+ const pauseMutation = useMutation(
+ trpc.subscription.pauseSubscription.mutationOptions({
+ onSuccess(data) {
+ 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.`,
+ });
+ finish();
+ },
+ onError(error) {
+ toast.error(error.message);
+ },
+ })
+ );
+
+ const discountMutation = useMutation(
+ trpc.subscription.applySaveDiscount.mutationOptions({
+ onSuccess() {
+ invalidate();
+ toast.success('Discount applied', {
+ description:
+ '30% off your next 12 invoices, starting with the next billing cycle.',
+ });
+ finish();
+ },
+ onError(error) {
+ toast.error(error.message);
+ },
+ })
+ );
+
+ const cancelMutation = useMutation(
+ trpc.subscription.cancelSubscription.mutationOptions({
+ onSuccess() {
+ invalidate();
+ toast.success('Subscription canceled', {
+ description: organization.subscriptionEndsAt
+ ? `Your subscription stays active until ${formatDate(organization.subscriptionEndsAt)}.`
+ : 'It might take a few seconds to update',
+ });
+ finish();
+ },
+ 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 (
+
+
+ setReason(value as ICancellationReason)}
+ value={reason ?? undefined}
+ >
+ {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 (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/start/src/modals/index.tsx b/apps/start/src/modals/index.tsx
index 0708a1056..bd040ba84 100644
--- a/apps/start/src/modals/index.tsx
+++ b/apps/start/src/modals/index.tsx
@@ -9,6 +9,7 @@ import AddNotificationRule from './add-notification-rule';
import AddProject from './add-project';
import AddReference from './add-reference';
import BillingSuccess from './billing-success';
+import CancelSubscription from './cancel-subscription';
import type { ConfirmProps } from './confirm';
import Confirm from './confirm';
import ConfirmDeleteAccount from './confirm-delete-account';
@@ -86,6 +87,7 @@ const modals = {
CreateInvite,
SelectBillingPlan,
BillingSuccess,
+ CancelSubscription,
SetupTwoFactor,
DisableTwoFactor,
RegenerateRecoveryCodes,
diff --git a/apps/start/src/routes/_app.$organizationId.tsx b/apps/start/src/routes/_app.$organizationId.tsx
index 0bcb911a6..0a442a72f 100644
--- a/apps/start/src/routes/_app.$organizationId.tsx
+++ b/apps/start/src/routes/_app.$organizationId.tsx
@@ -122,6 +122,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;
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/schema.prisma b/packages/db/prisma/schema.prisma
index 10b219161..bd6f593cc 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -101,6 +101,15 @@ 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 deleteAt > now(), the organization will be deleted
deleteAt DateTime?
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/types.ts b/packages/db/src/types.ts
index aacd6d1e7..452777151 100644
--- a/packages/db/src/types.ts
+++ b/packages/db/src/types.ts
@@ -40,6 +40,17 @@ declare global {
| 'active'
| 'past_due'
| 'canceled'
- | 'unpaid';
+ | 'unpaid'
+ | 'paused';
+ // Mirrors Polar's CustomerCancellationReason enum.
+ type IPrismaCancellationReason =
+ | 'too_expensive'
+ | 'missing_features'
+ | 'switched_service'
+ | 'unused'
+ | 'customer_service'
+ | 'low_quality'
+ | 'too_complex'
+ | 'other';
}
}
diff --git a/packages/payments/package.json b/packages/payments/package.json
index 21da6b5c9..1d6dd0655 100644
--- a/packages/payments/package.json
+++ b/packages/payments/package.json
@@ -13,7 +13,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@polar-sh/sdk": "^0.48.1",
+ "@polar-sh/sdk": "^0.49.0",
"date-fns": "^3.3.1"
},
"devDependencies": {
diff --git a/packages/payments/scripts/create-save-discount.ts b/packages/payments/scripts/create-save-discount.ts
new file mode 100644
index 000000000..373e7c1f8
--- /dev/null
+++ b/packages/payments/scripts/create-save-discount.ts
@@ -0,0 +1,97 @@
+import { Polar } from '@polar-sh/sdk';
+import inquirer from 'inquirer';
+
+// Creates the reusable save-offer discount used by the cancel flow: 30% off,
+// repeating for the next 12 billing cycles. Run once per environment (sandbox +
+// production) and put the printed ID in POLAR_SAVE_DISCOUNT_ID.
+const DISCOUNT_NAME = 'Save offer — 30% off for 12 months';
+const BASIS_POINTS = 3000;
+const DURATION_IN_MONTHS = 12;
+
+interface Answers {
+ isProduction: boolean;
+ polarOrganizationId: string;
+ polarApiKey: string;
+}
+
+async function promptForInput() {
+ const answers = await inquirer.prompt([
+ {
+ type: 'list',
+ name: 'isProduction',
+ message: 'Is this for production?',
+ choices: [
+ { name: 'Yes', value: true },
+ { name: 'No', value: false },
+ ],
+ default: true,
+ },
+ {
+ type: 'string',
+ name: 'polarOrganizationId',
+ message: 'Enter your Polar organization ID:',
+ },
+ {
+ type: 'password',
+ mask: '*',
+ name: 'polarApiKey',
+ message: 'Enter your Polar API key:',
+ validate: (input: string) => {
+ if (!input) {
+ return 'API key is required';
+ }
+ return true;
+ },
+ },
+ ]);
+
+ return answers;
+}
+
+async function main() {
+ const input = await promptForInput();
+
+ const polar = new Polar({
+ accessToken: input.polarApiKey!,
+ server: input.isProduction ? 'production' : 'sandbox',
+ });
+
+ // The list response is a page iterator — walk every page so an existing
+ // discount beyond the first page doesn't get duplicated.
+ let match: { id: string; name: string } | undefined;
+ const pages = await polar.discounts.list({ limit: 100 });
+ for await (const page of pages) {
+ match = page.result.items.find(
+ (discount) => discount.name === DISCOUNT_NAME,
+ );
+ if (match) {
+ break;
+ }
+ }
+
+ if (match) {
+ console.log('Save discount already exists:');
+ console.log(' - ID:', match.id);
+ console.log(' - Name:', match.name);
+ console.log(`\nSet POLAR_SAVE_DISCOUNT_ID=${match.id}`);
+ return;
+ }
+
+ const discount = await polar.discounts.create({
+ organizationId: input.polarApiKey.includes('_oat_')
+ ? undefined
+ : input.polarOrganizationId,
+ name: DISCOUNT_NAME,
+ type: 'percentage',
+ basisPoints: BASIS_POINTS,
+ duration: 'repeating',
+ durationInMonths: DURATION_IN_MONTHS,
+ });
+
+ console.log('Save discount created:');
+ console.log(' - ID:', discount.id);
+ console.log(' - Name:', discount.name);
+ console.log(`\nSet POLAR_SAVE_DISCOUNT_ID=${discount.id}`);
+}
+
+main();
diff --git a/packages/payments/src/polar.ts b/packages/payments/src/polar.ts
index da77ecc5d..89da90b87 100644
--- a/packages/payments/src/polar.ts
+++ b/packages/payments/src/polar.ts
@@ -76,12 +76,30 @@ export async function createCheckout({
});
}
-export async function cancelSubscription(subscriptionId: string) {
+export type ICancellationReason =
+ | 'too_expensive'
+ | 'missing_features'
+ | 'switched_service'
+ | 'unused'
+ | 'customer_service'
+ | 'low_quality'
+ | 'too_complex'
+ | 'other';
+
+export async function cancelSubscription(
+ subscriptionId: string,
+ cancellation?: {
+ reason?: ICancellationReason;
+ comment?: string;
+ }
+) {
try {
return await polar.subscriptions.update({
id: subscriptionId,
subscriptionUpdate: {
cancelAtPeriodEnd: true,
+ customerCancellationReason: cancellation?.reason ?? null,
+ customerCancellationComment: cancellation?.comment ?? null,
},
});
} catch (error) {
@@ -96,6 +114,61 @@ export async function cancelSubscription(subscriptionId: string) {
}
}
+/**
+ * Pause an active subscription at the end of the current period. Billing stops
+ * but the subscription (and its payment method) is kept, so the customer can
+ * come back without a new checkout. `resumesAt` must be after the current
+ * period end; omitted means paused until manually resumed.
+ */
+export function pauseSubscription(subscriptionId: string, resumesAt?: Date) {
+ return polar.subscriptions.update({
+ id: subscriptionId,
+ subscriptionUpdate: {
+ pauseAtPeriodEnd: true,
+ resumesAt: resumesAt ?? null,
+ },
+ });
+}
+
+/** Cancel a scheduled pause while the subscription is still active. */
+export function unpauseSubscription(subscriptionId: string) {
+ return polar.subscriptions.update({
+ id: subscriptionId,
+ subscriptionUpdate: {
+ pauseAtPeriodEnd: false,
+ },
+ });
+}
+
+/**
+ * Resume an already-paused subscription immediately — starts a new billing
+ * period and charges the customer.
+ */
+export function resumeSubscription(subscriptionId: string) {
+ return polar.subscriptions.update({
+ id: subscriptionId,
+ subscriptionUpdate: {
+ resume: true,
+ },
+ });
+}
+
+/**
+ * Attach a discount to an active subscription. Polar applies it starting with
+ * the next billing cycle.
+ */
+export function applySubscriptionDiscount(
+ subscriptionId: string,
+ discountId: string
+) {
+ return polar.subscriptions.update({
+ id: subscriptionId,
+ subscriptionUpdate: {
+ discountId,
+ },
+ });
+}
+
export function reactivateSubscription(subscriptionId: string) {
return polar.subscriptions.update({
id: subscriptionId,
diff --git a/packages/payments/src/subscription-state-meta.ts b/packages/payments/src/subscription-state-meta.ts
index 632715ada..ff6dd5def 100644
--- a/packages/payments/src/subscription-state-meta.ts
+++ b/packages/payments/src/subscription-state-meta.ts
@@ -22,7 +22,7 @@ export type SubscriptionStateMeta = {
/** Inline status line on the billing page, or null. */
statusLine: { text: string; tone: Tone } | null;
/** BillingPrompt copy key when this state blocks the dashboard. */
- blockType: 'expired' | 'trialEnded' | 'unpaid' | null;
+ blockType: 'expired' | 'trialEnded' | 'unpaid' | 'paused' | null;
};
const fmt = (date: Date | null | undefined) =>
@@ -35,10 +35,15 @@ const fmt = (date: Date | null | undefined) =>
*/
export function getSubscriptionStateMeta(
state: SubscriptionState,
- opts: { endsAt?: Date | null; canceledAt?: Date | null }
+ opts: {
+ endsAt?: Date | null;
+ canceledAt?: Date | null;
+ resumesAt?: Date | null;
+ }
): SubscriptionStateMeta {
const endsAt = fmt(opts.endsAt);
const canceledAt = fmt(opts.canceledAt);
+ const resumesAt = fmt(opts.resumesAt);
switch (state) {
case 'self_hosted':
@@ -123,6 +128,49 @@ export function getSubscriptionStateMeta(
blockType: 'expired',
};
+ case 'pausing':
+ return {
+ badge: { label: 'Pausing', variant: 'warning' },
+ banner: {
+ title: 'Subscription will be paused',
+ description: endsAt
+ ? `Your subscription pauses on ${endsAt}. We keep collecting your events while it's paused${resumesAt ? `, and billing resumes on ${resumesAt}` : ''}.`
+ : `Your subscription is scheduled to pause at the end of the current period. We keep collecting your events while it's paused.`,
+ cta: 'Keep subscription',
+ tone: 'warning',
+ },
+ statusLine: endsAt
+ ? {
+ text: `Your subscription will be paused on ${endsAt}`,
+ tone: 'warning',
+ }
+ : {
+ text: 'Your subscription will be paused at the end of the current period',
+ tone: 'warning',
+ },
+ blockType: null,
+ };
+
+ case 'paused':
+ return {
+ badge: { label: 'Paused', variant: 'warning' },
+ banner: {
+ title: 'Subscription paused',
+ description: resumesAt
+ ? `Your subscription is paused and resumes on ${resumesAt}. Your events are still being collected.`
+ : 'Your subscription is paused. Your events are still being collected — resume any time to pick up where you left off.',
+ cta: 'Resume subscription',
+ tone: 'warning',
+ },
+ statusLine: {
+ text: resumesAt
+ ? `Your subscription is paused until ${resumesAt}`
+ : 'Your subscription is paused',
+ tone: 'warning',
+ },
+ blockType: 'paused',
+ };
+
case 'past_due':
return {
badge: { label: 'Past due', variant: 'warning' },
diff --git a/packages/payments/src/subscription-state.test.ts b/packages/payments/src/subscription-state.test.ts
index 0c0e4d10d..1a9a4a38c 100644
--- a/packages/payments/src/subscription-state.test.ts
+++ b/packages/payments/src/subscription-state.test.ts
@@ -14,6 +14,7 @@ type Case = {
status: string | null;
canceledAt: Date | null;
endsAt: Date | null;
+ pauseAtPeriodEnd?: boolean;
expected: SubscriptionState;
};
@@ -55,6 +56,16 @@ describe('getSubscriptionState', () => {
{ name: 'incomplete', status: 'incomplete', canceledAt: null, endsAt: future, expected: 'incomplete' },
{ name: 'incomplete_expired', status: 'incomplete_expired', canceledAt: null, endsAt: past, expected: 'expired' },
+ // Pause-at-period-end keeps status active while the flag is set
+ { name: 'active + pause scheduled (pausing)', status: 'active', canceledAt: null, endsAt: future, pauseAtPeriodEnd: true, expected: 'pausing' },
+ { name: 'cancel wins over scheduled pause', status: 'active', canceledAt: past, endsAt: future, pauseAtPeriodEnd: true, expected: 'canceling' },
+ // Stale data: period ended but status wasn't flipped (missed webhook).
+ // Must resolve to paused (blocks dashboard), never a lingering pausing.
+ { name: 'pause scheduled, period already ended', status: 'active', canceledAt: null, endsAt: past, pauseAtPeriodEnd: true, expected: 'paused' },
+ { name: 'pause scheduled, no end date', status: 'active', canceledAt: null, endsAt: null, pauseAtPeriodEnd: true, expected: 'paused' },
+ { name: 'paused', status: 'paused', canceledAt: null, endsAt: past, expected: 'paused' },
+ { name: 'paused ignores stale pause flag', status: 'paused', canceledAt: null, endsAt: past, pauseAtPeriodEnd: true, expected: 'paused' },
+
// Fully canceled / revoked
{ name: 'canceled', status: 'canceled', canceledAt: past, endsAt: past, expected: 'canceled' },
@@ -69,6 +80,7 @@ describe('getSubscriptionState', () => {
subscriptionStatus: c.status,
subscriptionCanceledAt: c.canceledAt,
subscriptionEndsAt: c.endsAt,
+ subscriptionPauseAtPeriodEnd: c.pauseAtPeriodEnd ?? false,
})
).toBe(c.expected);
});
@@ -81,6 +93,7 @@ describe('getSubscriptionState', () => {
subscriptionStatus: 'canceled',
subscriptionCanceledAt: past,
subscriptionEndsAt: past,
+ subscriptionPauseAtPeriodEnd: false,
})
).toBe('self_hosted');
});
@@ -92,12 +105,14 @@ describe('subscriptionBlocksDashboard', () => {
'expired',
'unpaid',
'canceled',
+ 'paused',
];
const allowed: SubscriptionState[] = [
'self_hosted',
'trialing',
'active',
'canceling',
+ 'pausing',
'past_due',
'incomplete',
];
diff --git a/packages/payments/src/subscription-state.ts b/packages/payments/src/subscription-state.ts
index f95943318..a40d99c2e 100644
--- a/packages/payments/src/subscription-state.ts
+++ b/packages/payments/src/subscription-state.ts
@@ -14,6 +14,8 @@ export type SubscriptionState =
| 'active' // paid, renewing
| 'canceling' // active but scheduled to cancel at period end
| 'canceled' // fully canceled / revoked (access ended)
+ | 'pausing' // active but scheduled to pause at period end
+ | 'paused' // paused — billing stopped, data still collected
| 'past_due' // payment failed, in dunning (still has access)
| 'unpaid' // payment failed terminally
| 'incomplete' // initial checkout not completed
@@ -23,6 +25,7 @@ export interface SubscriptionStateInput {
subscriptionStatus: string | null;
subscriptionCanceledAt: Date | null;
subscriptionEndsAt: Date | null;
+ subscriptionPauseAtPeriodEnd: boolean;
}
export function getSubscriptionState(
@@ -33,7 +36,12 @@ export function getSubscriptionState(
}
const now = new Date();
- const { subscriptionStatus, subscriptionCanceledAt, subscriptionEndsAt } = org;
+ const {
+ subscriptionStatus,
+ subscriptionCanceledAt,
+ subscriptionEndsAt,
+ subscriptionPauseAtPeriodEnd,
+ } = org;
const endsInFuture = Boolean(subscriptionEndsAt && subscriptionEndsAt > now);
switch (subscriptionStatus) {
@@ -43,12 +51,24 @@ export function getSubscriptionState(
case 'active':
// Cancel-at-period-end keeps the Polar status as `active` while
// `canceledAt` is set; the subscription stays usable until it expires.
+ // Cancellation wins over a scheduled pause — Polar clears the pause flag
+ // on cancel, but a stale combination must not hide that it's ending.
if (subscriptionCanceledAt) {
return 'canceling';
}
+ // Pause-at-period-end works the same way: status stays `active` with the
+ // flag set until the period ends, then Polar flips it to `paused`. If the
+ // period already ended and our status is stale (missed webhook), fail
+ // safe to `paused` — `pausing` would keep dashboard access open past the
+ // paid period.
+ if (subscriptionPauseAtPeriodEnd) {
+ return endsInFuture ? 'pausing' : 'paused';
+ }
return subscriptionEndsAt && subscriptionEndsAt <= now
? 'expired'
: 'active';
+ case 'paused':
+ return 'paused';
case 'past_due':
return 'past_due';
case 'unpaid':
@@ -75,6 +95,9 @@ export function subscriptionBlocksDashboard(state: SubscriptionState): boolean {
case 'expired':
case 'unpaid':
case 'canceled':
+ // Paused blocks the dashboard too (billing has stopped), but with its own
+ // prompt: data is still collected and one click resumes the subscription.
+ case 'paused':
return true;
default:
return false;
diff --git a/packages/trpc/src/routers/subscription.ts b/packages/trpc/src/routers/subscription.ts
index 893551ed9..f63a55efe 100644
--- a/packages/trpc/src/routers/subscription.ts
+++ b/packages/trpc/src/routers/subscription.ts
@@ -5,18 +5,26 @@ import {
getOrganizationById,
} from '@openpanel/db';
import {
+ applySubscriptionDiscount,
cancelSubscription,
changeSubscription,
createCheckout,
createPortal,
getProduct,
getProducts,
+ pauseSubscription,
reactivateSubscription,
+ resumeSubscription,
+ unpauseSubscription,
} from '@openpanel/payments';
-import { zCheckout } from '@openpanel/validation';
+import {
+ zCancelSubscription,
+ zCheckout,
+ zPauseSubscription,
+} from '@openpanel/validation';
import { getCache } from '@openpanel/redis';
-import { subDays } from 'date-fns';
+import { addMonths, subDays } from 'date-fns';
import { z } from 'zod';
import { TRPCForbiddenError, TRPCBadRequestError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
@@ -58,6 +66,19 @@ export const subscriptionRouter = createTRPCRouter({
}),
]);
+ // A paused (or pause-scheduled) subscription still exists in Polar — a
+ // checkout here would create a second one, and a plan change would race
+ // the pending pause. Resume first, then change plans.
+ if (
+ organization.subscriptionId &&
+ (organization.subscriptionStatus === 'paused' ||
+ organization.subscriptionPauseAtPeriodEnd)
+ ) {
+ throw new TRPCBadRequestError(
+ 'Your subscription is paused or scheduled to pause — resume it before changing plans',
+ );
+ }
+
// An organization has at most one Polar subscription (we have no free
// tier in Polar — the free plan is handled on our side). So an upgrade or
// downgrade is an in-place product change, never a cancel + re-subscribe.
@@ -154,7 +175,7 @@ export const subscriptionRouter = createTRPCRouter({
}),
cancelSubscription: protectedProcedure
- .input(z.object({ organizationId: z.string() }))
+ .input(zCancelSubscription)
.mutation(async ({ input, ctx }) => {
await requireAdmin(ctx.session.userId, input.organizationId);
const organization = await getOrganizationById(input.organizationId);
@@ -162,11 +183,141 @@ export const subscriptionRouter = createTRPCRouter({
throw new TRPCBadRequestError('Organization has no subscription');
}
- const res = await cancelSubscription(organization.subscriptionId);
+ const res = await cancelSubscription(organization.subscriptionId, {
+ reason: input.reason,
+ comment: input.comment,
+ });
+
+ // The webhook echoes these back, but persist immediately so the reason
+ // is never lost to a missed/delayed webhook delivery.
+ await db.organization.update({
+ where: { id: input.organizationId },
+ data: {
+ subscriptionCancelReason: input.reason,
+ subscriptionCancelComment: input.comment ?? null,
+ },
+ });
return res;
}),
+ pauseSubscription: protectedProcedure
+ .input(zPauseSubscription)
+ .mutation(async ({ input, ctx }) => {
+ await requireAdmin(ctx.session.userId, input.organizationId);
+ const organization = await getOrganizationById(input.organizationId);
+ if (!organization.subscriptionId) {
+ throw new TRPCBadRequestError('Organization has no subscription');
+ }
+ // Only a plain active subscription can be paused — this rejects paused,
+ // pause-scheduled (pausing), canceling, canceled, unpaid, etc. before we
+ // hit Polar with a nonsensical update.
+ if (organization.subscriptionState !== 'active') {
+ throw new TRPCBadRequestError(
+ 'Only an active subscription can be paused',
+ );
+ }
+ if (!organization.subscriptionEndsAt) {
+ throw new TRPCBadRequestError('Subscription has no current period end');
+ }
+
+ // Polar pauses at period end; the resume date counts from there.
+ const resumesAt = addMonths(
+ organization.subscriptionEndsAt,
+ input.months,
+ );
+
+ await pauseSubscription(organization.subscriptionId, resumesAt);
+
+ // Optimistic mirror — the subscription.updated webhook confirms it.
+ await db.organization.update({
+ where: { id: input.organizationId },
+ data: {
+ subscriptionPauseAtPeriodEnd: true,
+ subscriptionResumesAt: resumesAt,
+ },
+ });
+
+ return { resumesAt };
+ }),
+
+ resumeSubscription: protectedProcedure
+ .input(z.object({ organizationId: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ await requireAdmin(ctx.session.userId, input.organizationId);
+ const organization = await getOrganizationById(input.organizationId);
+ if (!organization.subscriptionId) {
+ throw new TRPCBadRequestError('Organization has no subscription');
+ }
+
+ if (organization.subscriptionStatus === 'paused') {
+ // Already paused — resuming starts a new billing period immediately.
+ await resumeSubscription(organization.subscriptionId);
+ } else if (organization.subscriptionPauseAtPeriodEnd) {
+ // Pause is only scheduled — just clear it.
+ await unpauseSubscription(organization.subscriptionId);
+ } else {
+ throw new TRPCBadRequestError('Subscription is not paused');
+ }
+
+ await db.organization.update({
+ where: { id: input.organizationId },
+ data: {
+ subscriptionPauseAtPeriodEnd: false,
+ subscriptionResumesAt: null,
+ },
+ });
+
+ return { success: true };
+ }),
+
+ applySaveDiscount: protectedProcedure
+ .input(z.object({ organizationId: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ await requireAdmin(ctx.session.userId, input.organizationId);
+ const discountId = process.env.POLAR_SAVE_DISCOUNT_ID;
+ if (!discountId) {
+ throw new TRPCBadRequestError('Save discount is not configured');
+ }
+
+ const organization = await getOrganizationById(input.organizationId);
+ if (!organization.subscriptionId) {
+ throw new TRPCBadRequestError('Organization has no subscription');
+ }
+
+ // Claim the one-time offer atomically BEFORE calling Polar: a
+ // conditional update lets exactly one concurrent request through. Roll
+ // the claim back if Polar rejects, so a transient failure doesn't burn
+ // the offer.
+ const claimed = await db.organization.updateMany({
+ where: {
+ id: input.organizationId,
+ subscriptionSaveDiscountAppliedAt: null,
+ },
+ data: { subscriptionSaveDiscountAppliedAt: new Date() },
+ });
+ if (claimed.count === 0) {
+ throw new TRPCBadRequestError(
+ 'The save discount has already been used',
+ );
+ }
+
+ try {
+ await applySubscriptionDiscount(
+ organization.subscriptionId,
+ discountId,
+ );
+ } catch (error) {
+ await db.organization.updateMany({
+ where: { id: input.organizationId },
+ data: { subscriptionSaveDiscountAppliedAt: null },
+ });
+ throw error;
+ }
+
+ return { success: true };
+ }),
+
portal: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.mutation(async ({ input, ctx }) => {
diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts
index 03f85682b..c334c61d4 100644
--- a/packages/validation/src/index.ts
+++ b/packages/validation/src/index.ts
@@ -634,6 +634,33 @@ export const zCheckout = z.object({
});
export type ICheckout = z.infer;
+// Mirrors Polar's CustomerCancellationReason enum.
+export const zCancellationReason = z.enum([
+ 'too_expensive',
+ 'missing_features',
+ 'switched_service',
+ 'unused',
+ 'customer_service',
+ 'low_quality',
+ 'too_complex',
+ 'other',
+]);
+export type ICancellationReason = z.infer;
+
+export const zCancelSubscription = z.object({
+ organizationId: z.string(),
+ reason: zCancellationReason,
+ comment: z.string().trim().max(1000).optional(),
+});
+export type ICancelSubscription = z.infer;
+
+export const zPauseSubscription = z.object({
+ organizationId: z.string(),
+ // Months after the current period end before billing automatically resumes.
+ months: z.union([z.literal(1), z.literal(2), z.literal(3)]),
+});
+export type IPauseSubscription = z.infer;
+
export const zGroupId = z
.string()
.min(1)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6fa0eb71c..8816924c6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1582,8 +1582,8 @@ importers:
packages/payments:
dependencies:
'@polar-sh/sdk':
- specifier: ^0.48.1
- version: 0.48.1
+ specifier: ^0.49.0
+ version: 0.49.0
date-fns:
specifier: ^3.3.1
version: 3.3.1
@@ -6905,8 +6905,8 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
- '@polar-sh/sdk@0.48.1':
- resolution: {integrity: sha512-FmU6eLJRXJ6Zau0IkqscfkFta8xxYbFV1VD9zzS2Ki2btCuFmUyPZOkHOXU77R892h2zyBegLnHS7jrOhMN1Sw==}
+ '@polar-sh/sdk@0.49.0':
+ resolution: {integrity: sha512-9UYb70iKjJCtWYlu0OF5HLYBLmkxHwqr2RlXwuxXQgRGqq56IQWlVG+NO7e1YJ7I5GW0CBHhGIjRbQ9hYM6ycQ==}
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -9998,6 +9998,7 @@ packages:
'@ungap/structured-clone@1.2.0':
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
+ deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@unhead/vue@2.0.19':
resolution: {integrity: sha512-7BYjHfOaoZ9+ARJkT10Q2TjnTUqDXmMpfakIAsD/hXiuff1oqWg1xeXT5+MomhNcC15HbiABpbbBmITLSHxdKg==}
@@ -26889,7 +26890,7 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
- '@polar-sh/sdk@0.48.1':
+ '@polar-sh/sdk@0.49.0':
dependencies:
standardwebhooks: 1.0.0
zod: 4.3.6