improve(payments): handling free products and subscriptions

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-04-01 21:27:11 +02:00
parent 1e99c1843a
commit a6762b90ca
4 changed files with 106 additions and 49 deletions

View File

@@ -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) {
</div>
)}
</div>
<div className="col gap-2">
{organization.isWillBeCanceled || organization.isCanceled ? (
<Button
loading={checkout.isLoading}
onClick={() => {
checkout.mutate({
projectId,
organizationId: organization.id,
productPriceId: price!.id,
productId: price.productId,
});
}}
>
Reactivate subscription
</Button>
) : (
<Button
variant="destructive"
loading={cancelSubscription.isLoading}
onClick={() => {
showConfirm({
title: 'Cancel subscription',
text: 'Are you sure you want to cancel your subscription?',
onConfirm() {
cancelSubscription.mutate({
{organization.subscriptionProductId &&
!FREE_PRODUCT_IDS.includes(organization.subscriptionProductId) && (
<div className="col gap-2">
{organization.isWillBeCanceled || organization.isCanceled ? (
<Button
loading={checkout.isLoading}
onClick={() => {
checkout.mutate({
projectId,
organizationId: organization.id,
productPriceId: price!.id,
productId: price.productId,
});
},
});
}}
>
Cancel subscription
</Button>
}}
>
Reactivate subscription
</Button>
) : (
<Button
variant="destructive"
loading={cancelSubscription.isLoading}
onClick={() => {
showConfirm({
title: 'Cancel subscription',
text: 'Are you sure you want to cancel your subscription?',
onConfirm() {
cancelSubscription.mutate({
organizationId: organization.id,
});
},
});
}}
>
Cancel subscription
</Button>
)}
</div>
)}
</div>
</>
);
}

View File

@@ -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<boolean>(
willEndInHours !== null &&
organization.subscriptionStatus === 'trialing' &&
organization.subscriptionEndsAt !== null &&
willEndInHours <= 48,
);
const [isTrialDialogOpen, setIsTrialDialogOpen] = useState<boolean>(false);
const [isFreePlan, setIsFreePlan] = useState<boolean>(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) {
</div>
</DialogContent>
</Dialog>
<Dialog open={isFreePlan} onOpenChange={setIsFreePlan}>
<DialogContent className="max-w-xl">
<ModalHeader
onClose={() => 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{' '}
<ProjectLink
href="/settings/organization?tab=billing"
className="underline text-foreground"
>
manage billing
</ProjectLink>
</>
}
/>
<div className="-mx-4 mt-4">
<Billing organization={organization} />
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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) {

View File

@@ -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
];