improve(payments): handling free products and subscriptions
This commit is contained in:
@@ -22,7 +22,7 @@ import Confirm from '@/modals/Confirm';
|
|||||||
import { api } from '@/trpc/client';
|
import { api } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import type { IServiceOrganization } from '@openpanel/db';
|
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 { format } from 'date-fns';
|
||||||
import { Loader2Icon } from 'lucide-react';
|
import { Loader2Icon } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@@ -152,41 +152,44 @@ export default function CurrentSubscription({ organization }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="col gap-2">
|
{organization.subscriptionProductId &&
|
||||||
{organization.isWillBeCanceled || organization.isCanceled ? (
|
!FREE_PRODUCT_IDS.includes(organization.subscriptionProductId) && (
|
||||||
<Button
|
<div className="col gap-2">
|
||||||
loading={checkout.isLoading}
|
{organization.isWillBeCanceled || organization.isCanceled ? (
|
||||||
onClick={() => {
|
<Button
|
||||||
checkout.mutate({
|
loading={checkout.isLoading}
|
||||||
projectId,
|
onClick={() => {
|
||||||
organizationId: organization.id,
|
checkout.mutate({
|
||||||
productPriceId: price!.id,
|
projectId,
|
||||||
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({
|
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
|
productPriceId: price!.id,
|
||||||
|
productId: price.productId,
|
||||||
});
|
});
|
||||||
},
|
}}
|
||||||
});
|
>
|
||||||
}}
|
Reactivate subscription
|
||||||
>
|
</Button>
|
||||||
Cancel 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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Dialog, DialogContent } from '@/components/ui/dialog';
|
|||||||
import { ModalHeader } from '@/modals/Modal/Container';
|
import { ModalHeader } from '@/modals/Modal/Container';
|
||||||
import type { IServiceOrganization } from '@openpanel/db';
|
import type { IServiceOrganization } from '@openpanel/db';
|
||||||
import { useOpenPanel } from '@openpanel/nextjs';
|
import { useOpenPanel } from '@openpanel/nextjs';
|
||||||
|
import { FREE_PRODUCT_IDS } from '@openpanel/payments';
|
||||||
import Billing from './settings/organization/organization/billing';
|
import Billing from './settings/organization/organization/billing';
|
||||||
|
|
||||||
interface SideEffectsProps {
|
interface SideEffectsProps {
|
||||||
@@ -20,22 +21,36 @@ export default function SideEffects({ organization }: SideEffectsProps) {
|
|||||||
const willEndInHours = organization.subscriptionEndsAt
|
const willEndInHours = organization.subscriptionEndsAt
|
||||||
? differenceInHours(organization.subscriptionEndsAt, new Date())
|
? differenceInHours(organization.subscriptionEndsAt, new Date())
|
||||||
: null;
|
: null;
|
||||||
const [isTrialDialogOpen, setIsTrialDialogOpen] = useState<boolean>(
|
const [isTrialDialogOpen, setIsTrialDialogOpen] = useState<boolean>(false);
|
||||||
willEndInHours !== null &&
|
const [isFreePlan, setIsFreePlan] = useState<boolean>(false);
|
||||||
organization.subscriptionStatus === 'trialing' &&
|
|
||||||
organization.subscriptionEndsAt !== null &&
|
|
||||||
willEndInHours <= 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (isTrialDialogOpen) {
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
willEndInHours !== null &&
|
||||||
|
organization.subscriptionStatus === 'trialing' &&
|
||||||
|
organization.subscriptionEndsAt !== null &&
|
||||||
|
willEndInHours <= 48
|
||||||
|
) {
|
||||||
|
setIsTrialDialogOpen(true);
|
||||||
op.track('trial_expires_soon');
|
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
|
// Avoids hydration errors
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
@@ -71,6 +86,29 @@ export default function SideEffects({ organization }: SideEffectsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,14 +84,25 @@ export async function createCheckout({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelSubscription(subscriptionId: string) {
|
export async function cancelSubscription(subscriptionId: string) {
|
||||||
return polar.subscriptions.update({
|
try {
|
||||||
id: subscriptionId,
|
return await polar.subscriptions.update({
|
||||||
subscriptionUpdate: {
|
id: subscriptionId,
|
||||||
cancelAtPeriodEnd: true,
|
subscriptionUpdate: {
|
||||||
revoke: null,
|
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) {
|
export function reactivateSubscription(subscriptionId: string) {
|
||||||
|
|||||||
@@ -16,3 +16,8 @@ export const PRICING: IPrice[] = [
|
|||||||
// { price: 650, events: 20_000_000 },
|
// { price: 650, events: 20_000_000 },
|
||||||
// { price: 900, events: 30_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
|
||||||
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user