Skip to content

Commit

Permalink
Merge pull request #284 from elie222/lemon-plans
Browse files Browse the repository at this point in the history
Switch premium plan
  • Loading branch information
elie222 authored Jan 1, 2025
2 parents bd0ec3d + 1ac6d36 commit 6724fbe
Show file tree
Hide file tree
Showing 9 changed files with 262 additions and 66 deletions.
38 changes: 34 additions & 4 deletions apps/web/app/(app)/premium/Pricing.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState } from "react";
import { toast } from "sonner";
import { useSearchParams } from "next/navigation";
import { Label, Radio, RadioGroup } from "@headlessui/react";
import { CheckIcon, CreditCardIcon, SparklesIcon } from "lucide-react";
Expand All @@ -23,6 +24,8 @@ import {
import { AlertWithButton } from "@/components/Alert";
import { usePricingVariant } from "@/hooks/useFeatureFlags";
import { PremiumTier } from "@prisma/client";
import { switchPremiumPlanAction } from "@/utils/actions/premium";
import { isActionError } from "@/utils/error";

function attachUserInfo(
url: string,
Expand Down Expand Up @@ -171,7 +174,7 @@ export function Pricing(props: { header?: React.ReactNode }) {

<Layout className="isolate mx-auto mt-10 grid max-w-md grid-cols-1 gap-y-8">
{tiers.map((tier, tierIdx) => {
const isCurrentPlan = tier.tiers?.[frequency.value] === premiumTier;
const isCurrentPlan = tier.tiers[frequency.value] === premiumTier;

const user = session.data?.user;

Expand Down Expand Up @@ -254,8 +257,31 @@ export function Pricing(props: { header?: React.ReactNode }) {
</div>

<a
href={href}
target={href.startsWith("http") ? "_blank" : undefined}
href={!premiumTier ? href : "#"}
onClick={() => {
if (premiumTier) {
toast.promise(
async () => {
const result = await switchPremiumPlanAction(
tier.tiers[frequency.value],
);
if (isActionError(result))
throw new Error(result.error);
},
{
loading: "Switching to plan...",
success: "Switched to plan",
error: (e) =>
`There was an error switching to plan: ${e.message}`,
},
);
}
}}
target={
!premiumTier && href.startsWith("http")
? "_blank"
: undefined
}
aria-describedby={tier.name}
className={clsx(
tier.mostPopular
Expand All @@ -264,7 +290,11 @@ export function Pricing(props: { header?: React.ReactNode }) {
"mt-8 block rounded-md px-3 py-2 text-center text-sm font-semibold leading-6 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600",
)}
>
{isCurrentPlan ? "Current plan" : tier.cta}
{isCurrentPlan
? "Current plan"
: premiumTier
? "Switch to this plan"
: tier.cta}
</a>
</Item>
);
Expand Down
38 changes: 38 additions & 0 deletions apps/web/app/(app)/premium/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,28 @@ export const pricingAdditonalEmail: Record<PremiumTier, number> = {
[PremiumTier.LIFETIME]: 99,
};

const variantIdToTier: Record<number, PremiumTier> = {
[env.NEXT_PUBLIC_BASIC_MONTHLY_VARIANT_ID]: PremiumTier.BASIC_MONTHLY,
[env.NEXT_PUBLIC_BASIC_ANNUALLY_VARIANT_ID]: PremiumTier.BASIC_ANNUALLY,
[env.NEXT_PUBLIC_PRO_MONTHLY_VARIANT_ID]: PremiumTier.PRO_MONTHLY,
[env.NEXT_PUBLIC_PRO_ANNUALLY_VARIANT_ID]: PremiumTier.PRO_ANNUALLY,
[env.NEXT_PUBLIC_BUSINESS_MONTHLY_VARIANT_ID]: PremiumTier.BUSINESS_MONTHLY,
[env.NEXT_PUBLIC_BUSINESS_ANNUALLY_VARIANT_ID]: PremiumTier.BUSINESS_ANNUALLY,
[env.NEXT_PUBLIC_COPILOT_MONTHLY_VARIANT_ID]: PremiumTier.COPILOT_MONTHLY,
[env.NEXT_PUBLIC_LIFETIME_VARIANT_ID]: PremiumTier.LIFETIME,
};

const tierToVariantId: Record<PremiumTier, number> = {
[PremiumTier.BASIC_MONTHLY]: env.NEXT_PUBLIC_BASIC_MONTHLY_VARIANT_ID,
[PremiumTier.BASIC_ANNUALLY]: env.NEXT_PUBLIC_BASIC_ANNUALLY_VARIANT_ID,
[PremiumTier.PRO_MONTHLY]: env.NEXT_PUBLIC_PRO_MONTHLY_VARIANT_ID,
[PremiumTier.PRO_ANNUALLY]: env.NEXT_PUBLIC_PRO_ANNUALLY_VARIANT_ID,
[PremiumTier.BUSINESS_MONTHLY]: env.NEXT_PUBLIC_BUSINESS_MONTHLY_VARIANT_ID,
[PremiumTier.BUSINESS_ANNUALLY]: env.NEXT_PUBLIC_BUSINESS_ANNUALLY_VARIANT_ID,
[PremiumTier.COPILOT_MONTHLY]: env.NEXT_PUBLIC_COPILOT_MONTHLY_VARIANT_ID,
[PremiumTier.LIFETIME]: env.NEXT_PUBLIC_LIFETIME_VARIANT_ID,
};

function discount(monthly: number, annually: number) {
return ((monthly - annually) / monthly) * 100;
}
Expand Down Expand Up @@ -177,3 +199,19 @@ const copilotTier = {
};

export const allTiers: Tier[] = [basicTier, businessTier, copilotTier];

export function getSubscriptionTier({
variantId,
}: {
variantId: number;
}): PremiumTier {
const tier = variantIdToTier[variantId];
if (!tier) throw new Error(`Unknown variant id: ${variantId}`);
return tier;
}

export function getVariantId({ tier }: { tier: PremiumTier }): number {
const variantId = tierToVariantId[tier];
if (!variantId) throw new Error(`Unknown tier: ${tier}`);
return variantId;
}
15 changes: 15 additions & 0 deletions apps/web/app/api/lemon-squeezy/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
updateSubscriptionItem,
getCustomer,
activateLicense,
updateSubscription,
} from "@lemonsqueezy/lemonsqueezy.js";
import { createScopedLogger } from "@/utils/logger";

const logger = createScopedLogger("Lemon Squeezy");

let isSetUp = false;

Expand All @@ -22,6 +26,7 @@ export async function updateSubscriptionItemQuantity(options: {
quantity: number;
}) {
setUpLemon();
logger.info("Updating subscription item quantity", options);
return updateSubscriptionItem(options.id, {
quantity: options.quantity,
invoiceImmediately: true,
Expand All @@ -38,5 +43,15 @@ export async function activateLemonLicenseKey(
name: string,
) {
setUpLemon();
logger.info("Activating license key", { licenseKey, name });
return activateLicense(licenseKey, name);
}

export async function switchPremiumPlan(
subscriptionId: number,
variantId: number,
) {
setUpLemon();
logger.info("Switching premium plan", { subscriptionId, variantId });
return updateSubscription(subscriptionId, { variantId });
}
156 changes: 105 additions & 51 deletions apps/web/app/api/lemon-squeezy/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,16 @@ import {
} from "@/utils/premium/server";
import type { Payload } from "@/app/api/lemon-squeezy/webhook/types";
import { PremiumTier } from "@prisma/client";
import { cancelledPremium, upgradedToPremium } from "@inboxzero/loops";
import {
cancelledPremium,
switchedPremiumPlan,
upgradedToPremium,
} from "@inboxzero/loops";
import { SafeError } from "@/utils/error";
import { getSubscriptionTier } from "@/app/(app)/premium/config";
import { createScopedLogger } from "@/utils/logger";

const logger = createScopedLogger("Lemon Squeezy Webhook");

export const POST = withError(async (request: Request) => {
const payload = await getPayload(request);
Expand Down Expand Up @@ -73,12 +81,25 @@ export const POST = withError(async (request: Request) => {
return await subscriptionUpdated({ payload, premiumId });
}

// changed plan
if (payload.meta.event_name === "subscription_plan_changed") {
if (!userId) {
logger.error("No userId provided", {
webhookId: payload.data.id,
event: payload.meta.event_name,
});
throw new SafeError("No userId provided");
}
return await subscriptionPlanChanged({ payload, userId });
}

// cancellation
if (payload.data.attributes.ends_at) {
return await subscriptionCancelled({
payload,
premiumId,
endsAt: payload.data.attributes.ends_at,
variantId: payload.data.attributes.variant_id,
});
}

Expand All @@ -88,6 +109,7 @@ export const POST = withError(async (request: Request) => {
payload,
premiumId,
endsAt: new Date().toISOString(),
variantId: payload.data.attributes.variant_id,
});
}

Expand Down Expand Up @@ -128,6 +150,82 @@ async function subscriptionCreated({
payload: Payload;
userId: string;
}) {
const { updatedPremium, tier } = await handleSubscriptionCreated(
payload,
userId,
);

const email = getEmailFromPremium(updatedPremium);
if (email) {
try {
await Promise.allSettled([
posthogCaptureEvent(
email,
payload.data.attributes.status === "on_trial"
? "Premium trial started"
: "Upgraded to premium",
{
...payload.data.attributes,
$set: {
premium: true,
premiumTier: "subscription",
premiumStatus: payload.data.attributes.status,
},
},
),
upgradedToPremium(email, tier),
]);
} catch (error) {
logger.error("Error capturing event", {
error,
webhookId: payload.data.id,
event: payload.meta.event_name,
});
}
}

return NextResponse.json({ ok: true });
}

async function subscriptionPlanChanged({
payload,
userId,
}: {
payload: Payload;
userId: string;
}) {
const { updatedPremium, tier } = await handleSubscriptionCreated(
payload,
userId,
);

const email = getEmailFromPremium(updatedPremium);
if (email) {
try {
await Promise.allSettled([
posthogCaptureEvent(email, "Switched premium plan", {
...payload.data.attributes,
$set: {
premium: true,
premiumTier: "subscription",
premiumStatus: payload.data.attributes.status,
},
}),
switchedPremiumPlan(email, tier),
]);
} catch (error) {
logger.error("Error capturing event", {
error,
webhookId: payload.data.id,
event: payload.meta.event_name,
});
}
}

return NextResponse.json({ ok: true });
}

async function handleSubscriptionCreated(payload: Payload, userId: string) {
if (!payload.data.attributes.renews_at)
throw new Error("No renews_at provided");

Expand All @@ -154,28 +252,7 @@ async function subscriptionCreated({
lemonSqueezyVariantId: payload.data.attributes.variant_id,
});

const email = getEmailFromPremium(updatedPremium);
if (email) {
await Promise.allSettled([
posthogCaptureEvent(
email,
payload.data.attributes.status === "on_trial"
? "Premium trial started"
: "Upgraded to premium",
{
...payload.data.attributes,
$set: {
premium: true,
premiumTier: "subscription",
premiumStatus: payload.data.attributes.status,
},
},
),
upgradedToPremium(email, tier),
]);
}

return NextResponse.json({ ok: true });
return { updatedPremium, tier };
}

async function lifetimeOrder({
Expand Down Expand Up @@ -280,16 +357,21 @@ async function subscriptionCancelled({
payload,
premiumId,
endsAt,
variantId,
}: {
payload: Payload;
premiumId: string;
endsAt: NonNullable<Payload["data"]["attributes"]["ends_at"]>;
variantId: NonNullable<Payload["data"]["attributes"]["variant_id"]>;
}) {
const updatedPremium = await cancelPremium({
premiumId,
variantId,
lemonSqueezyEndsAt: new Date(endsAt),
});

if (!updatedPremium) return NextResponse.json({ ok: true });

const email = getEmailFromPremium(updatedPremium);
if (email) {
await Promise.allSettled([
Expand Down Expand Up @@ -344,31 +426,3 @@ function getEmailFromPremium(premium: {
}) {
return premium.users?.[0]?.email;
}

function getSubscriptionTier({
variantId,
}: {
variantId: number;
}): PremiumTier {
switch (variantId) {
case env.NEXT_PUBLIC_BASIC_MONTHLY_VARIANT_ID:
return PremiumTier.BASIC_MONTHLY;
case env.NEXT_PUBLIC_BASIC_ANNUALLY_VARIANT_ID:
return PremiumTier.BASIC_ANNUALLY;

case env.NEXT_PUBLIC_PRO_MONTHLY_VARIANT_ID:
return PremiumTier.PRO_MONTHLY;
case env.NEXT_PUBLIC_PRO_ANNUALLY_VARIANT_ID:
return PremiumTier.PRO_ANNUALLY;

case env.NEXT_PUBLIC_BUSINESS_MONTHLY_VARIANT_ID:
return PremiumTier.BUSINESS_MONTHLY;
case env.NEXT_PUBLIC_BUSINESS_ANNUALLY_VARIANT_ID:
return PremiumTier.BUSINESS_ANNUALLY;

case env.NEXT_PUBLIC_COPILOT_MONTHLY_VARIANT_ID:
return PremiumTier.COPILOT_MONTHLY;
}

throw new Error(`Unknown variant id: ${variantId}`);
}
3 changes: 2 additions & 1 deletion apps/web/app/api/lemon-squeezy/webhook/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export type EventName =
| "subscription_unpaused"
| "subscription_payment_failed"
| "subscription_payment_success"
| "subscription_payment_recovered";
| "subscription_payment_recovered"
| "subscription_plan_changed";

export interface Meta {
test_mode: boolean;
Expand Down
Loading

1 comment on commit 6724fbe

@vercel
Copy link

@vercel vercel bot commented on 6724fbe Jan 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.