Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch premium plan #284

Merged
merged 6 commits into from
Jan 1, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
37 changes: 37 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,27 @@ 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,
};

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,
};
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Address inconsistencies in tier mappings

The mappings between variant IDs and tiers have several issues that need attention:

  1. The LIFETIME tier is missing in variantIdToTier but present in tierToVariantId, creating an asymmetric mapping.
  2. Environment variables lack type safety, which could lead to runtime errors if they're undefined.

Apply this diff to fix the issues:

 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,
 };

+// Ensure all variant IDs are defined
+const requiredVariantIds = [
+  env.NEXT_PUBLIC_BASIC_MONTHLY_VARIANT_ID,
+  env.NEXT_PUBLIC_BASIC_ANNUALLY_VARIANT_ID,
+  env.NEXT_PUBLIC_PRO_MONTHLY_VARIANT_ID,
+  env.NEXT_PUBLIC_PRO_ANNUALLY_VARIANT_ID,
+  env.NEXT_PUBLIC_BUSINESS_MONTHLY_VARIANT_ID,
+  env.NEXT_PUBLIC_BUSINESS_ANNUALLY_VARIANT_ID,
+  env.NEXT_PUBLIC_COPILOT_MONTHLY_VARIANT_ID,
+  env.NEXT_PUBLIC_LIFETIME_VARIANT_ID,
+] as const;
+
+if (requiredVariantIds.some(id => id === undefined)) {
+  throw new Error('Missing required variant IDs in environment variables');
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
};
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,
};
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,
};
// Ensure all variant IDs are defined
const requiredVariantIds = [
env.NEXT_PUBLIC_BASIC_MONTHLY_VARIANT_ID,
env.NEXT_PUBLIC_BASIC_ANNUALLY_VARIANT_ID,
env.NEXT_PUBLIC_PRO_MONTHLY_VARIANT_ID,
env.NEXT_PUBLIC_PRO_ANNUALLY_VARIANT_ID,
env.NEXT_PUBLIC_BUSINESS_MONTHLY_VARIANT_ID,
env.NEXT_PUBLIC_BUSINESS_ANNUALLY_VARIANT_ID,
env.NEXT_PUBLIC_COPILOT_MONTHLY_VARIANT_ID,
env.NEXT_PUBLIC_LIFETIME_VARIANT_ID,
] as const;
if (requiredVariantIds.some(id => id === undefined)) {
throw new Error('Missing required variant IDs in environment variables');
}
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 +198,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
Loading