From a6762b90caf9aded001686a9633bd3db521bd2b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 1 Apr 2025 21:27:11 +0200 Subject: [PATCH] improve(payments): handling free products and subscriptions --- .../organization/current-subscription.tsx | 69 ++++++++++--------- .../[projectId]/side-effects.tsx | 54 ++++++++++++--- packages/payments/src/polar.ts | 27 +++++--- packages/payments/src/prices.ts | 5 ++ 4 files changed, 106 insertions(+), 49 deletions(-) diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/current-subscription.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/current-subscription.tsx index a40c73c2..2f85beda 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/current-subscription.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/organization/current-subscription.tsx @@ -22,7 +22,7 @@ import Confirm from '@/modals/Confirm'; import { api } from '@/trpc/client'; import { cn } from '@/utils/cn'; import type { IServiceOrganization } from '@openpanel/db'; -import type { IPolarPrice } from '@openpanel/payments'; +import { FREE_PRODUCT_IDS, type IPolarPrice } from '@openpanel/payments'; import { format } from 'date-fns'; import { Loader2Icon } from 'lucide-react'; import { useRouter } from 'next/navigation'; @@ -152,41 +152,44 @@ export default function CurrentSubscription({ organization }: Props) { )} -
- {organization.isWillBeCanceled || organization.isCanceled ? ( - - ) : ( - + }} + > + Reactivate subscription + + ) : ( + + )} +
)} - ); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/side-effects.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/side-effects.tsx index 497080af..0efbc9c3 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/side-effects.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/side-effects.tsx @@ -8,6 +8,7 @@ import { Dialog, DialogContent } from '@/components/ui/dialog'; import { ModalHeader } from '@/modals/Modal/Container'; import type { IServiceOrganization } from '@openpanel/db'; import { useOpenPanel } from '@openpanel/nextjs'; +import { FREE_PRODUCT_IDS } from '@openpanel/payments'; import Billing from './settings/organization/organization/billing'; interface SideEffectsProps { @@ -20,22 +21,36 @@ export default function SideEffects({ organization }: SideEffectsProps) { const willEndInHours = organization.subscriptionEndsAt ? differenceInHours(organization.subscriptionEndsAt, new Date()) : null; - const [isTrialDialogOpen, setIsTrialDialogOpen] = useState( - willEndInHours !== null && - organization.subscriptionStatus === 'trialing' && - organization.subscriptionEndsAt !== null && - willEndInHours <= 48, - ); + const [isTrialDialogOpen, setIsTrialDialogOpen] = useState(false); + const [isFreePlan, setIsFreePlan] = useState(false); useEffect(() => { if (!mounted) { setMounted(true); } + }, []); - if (isTrialDialogOpen) { + useEffect(() => { + if ( + willEndInHours !== null && + organization.subscriptionStatus === 'trialing' && + organization.subscriptionEndsAt !== null && + willEndInHours <= 48 + ) { + setIsTrialDialogOpen(true); op.track('trial_expires_soon'); } - }, [isTrialDialogOpen]); + }, [mounted, organization]); + + useEffect(() => { + if ( + organization.subscriptionProductId && + FREE_PRODUCT_IDS.includes(organization.subscriptionProductId) + ) { + setIsFreePlan(true); + op.track('free_plan_removed'); + } + }, [mounted, organization]); // Avoids hydration errors if (!mounted) { @@ -71,6 +86,29 @@ export default function SideEffects({ organization }: SideEffectsProps) { + + + setIsFreePlan(false)} + title={'Free plan has been removed'} + text={ + <> + Please upgrade your plan to continue using OpenPanel. Select a + tier which is appropriate for your needs or{' '} + + manage billing + + + } + /> +
+ +
+
+
); } diff --git a/packages/payments/src/polar.ts b/packages/payments/src/polar.ts index 15b67709..7265785a 100644 --- a/packages/payments/src/polar.ts +++ b/packages/payments/src/polar.ts @@ -84,14 +84,25 @@ export async function createCheckout({ }); } -export function cancelSubscription(subscriptionId: string) { - return polar.subscriptions.update({ - id: subscriptionId, - subscriptionUpdate: { - cancelAtPeriodEnd: true, - revoke: null, - }, - }); +export async function cancelSubscription(subscriptionId: string) { + try { + return await polar.subscriptions.update({ + id: subscriptionId, + subscriptionUpdate: { + cancelAtPeriodEnd: true, + revoke: null, + }, + }); + } catch (error) { + if (error instanceof Error) { + // Don't throw an error if the subscription is already canceled + if (error.name === 'AlreadyCanceledSubscription') { + return polar.subscriptions.get({ id: subscriptionId }); + } + } + + throw error; + } } export function reactivateSubscription(subscriptionId: string) { diff --git a/packages/payments/src/prices.ts b/packages/payments/src/prices.ts index c42f3e46..242840b7 100644 --- a/packages/payments/src/prices.ts +++ b/packages/payments/src/prices.ts @@ -16,3 +16,8 @@ export const PRICING: IPrice[] = [ // { price: 650, events: 20_000_000 }, // { price: 900, events: 30_000_000 }, ]; + +export const FREE_PRODUCT_IDS = [ + 'a18b4bee-d3db-4404-be6f-fba2f042d9ed', // Prod + '036efa2a-b3b4-4c75-b24a-9cac6bb8893b', // Sandbox +];