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

Feature: Remove resend, stripe, cron env variable dependency #217

Merged
merged 10 commits into from
Mar 21, 2024
Prev Previous commit
feat: resolved TS issues related to making stripe optional
ousszizou committed Mar 18, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
commit 527e24fddf8449ba6f96274c500644e3d70372ce
16 changes: 7 additions & 9 deletions apps/www/actions/generate-user-stripe.ts
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
import { api } from "@/trpc/server";
import { currentUser } from "@clerk/nextjs";

import { stripe } from "@projectx/stripe";
import { withStripe } from "@projectx/stripe";

import { absoluteUrl } from "@/lib/utils";

@@ -18,10 +18,10 @@ const billingUrl = absoluteUrl("/pricing");

export async function generateUserStripe(
priceId: string,
): Promise<responseAction> {
let redirectUrl: string = "";
): Promise<responseAction | null> {
return withStripe<responseAction>(async (stripe) => {
let redirectUrl = "";

try {
const user = await currentUser();

if (!user || !user.emailAddresses) {
@@ -60,10 +60,8 @@ export async function generateUserStripe(

redirectUrl = stripeSession.url as string;
}
} catch (error) {
throw new Error("Failed to generate user stripe session");
}

// no revalidatePath because redirect
redirect(redirectUrl);
// no revalidatePath because redirect
redirect(redirectUrl);
});
}
9 changes: 5 additions & 4 deletions apps/www/app/(dashboard)/_components/workspace-switcher.tsx
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@ import { toDecimal } from "dinero.js";
import { Check, ChevronDown, ChevronsUpDown, PlusCircle } from "lucide-react";

import { env } from "@projectx/stripe/env";
import type { ExtendedPlanInfo, PlansResponse } from "@projectx/stripe/plans";
import type { PurchaseOrg } from "@projectx/validators";
import { purchaseOrgSchema } from "@projectx/validators";

@@ -247,9 +248,9 @@ export function WorkspaceSwitcher({ isCollapsed }: WorkspaceSwitcherProps) {
function NewOrganizationDialog(props: { closeDialog: () => void }) {
const useStripe = env.USE_STRIPE === "true";

let plans: any = null;
let plans: any | null = null;
if (useStripe) {
plans = React.use(api.stripe.plans.query());
plans = api.stripe.plans.query();
}

const form = useZodForm({ schema: purchaseOrgSchema });
@@ -261,7 +262,7 @@ function NewOrganizationDialog(props: { closeDialog: () => void }) {
.mutate(data)
.catch(() => ({ success: false as const }));

if (response.success) window.location.href = response.url;
if (response?.success) window.location.href = response.url as string;
else
toaster.toast({
title: "Error",
@@ -324,7 +325,7 @@ function NewOrganizationDialog(props: { closeDialog: () => void }) {
</SelectTrigger>
</FormControl>
<SelectContent>
{plans.map((plan) => (
{plans?.map((plan: ExtendedPlanInfo) => (
<SelectItem key={plan.priceId} value={plan.priceId}>
<span className="font-medium">{plan.name}</span> -{" "}
<span className="text-muted-foreground">
14 changes: 5 additions & 9 deletions apps/www/app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

import { handleEvent, stripe } from "@projectx/stripe";
import { handleEvent, withStripe } from "@projectx/stripe";

import { env } from "@/env";

export async function POST(req: NextRequest) {
const payload = await req.text();
const signature = req.headers.get("Stripe-Signature")!;
const signature = req.headers.get("Stripe-Signature") as string;

try {
return withStripe(async (stripe) => {
const event = stripe.webhooks.constructEvent(
payload,
signature,
env.STRIPE_WEBHOOK_SECRET,
env.STRIPE_WEBHOOK_SECRET as string,
);

await handleEvent(event);

console.log("βœ… Handled Stripe Event", event.type);
return NextResponse.json({ received: true }, { status: 200 });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.log(`❌ Error when handling Stripe Event: ${message}`);
return NextResponse.json({ error: message }, { status: 400 });
}
});
}
2 changes: 1 addition & 1 deletion apps/www/types/index.d.ts
Original file line number Diff line number Diff line change
@@ -46,6 +46,6 @@ export type SubscriptionPlan = {
monthly: number;
};
stripeIds: {
monthly: string | null;
monthly: string | null | undefined;
};
};
196 changes: 98 additions & 98 deletions packages/api/src/router/stripe.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { currentUser } from "@clerk/nextjs";
// import * as currencies from "@dinero.js/currencies";
import { USD } from "@dinero.js/currencies";
import { dinero } from "dinero.js";
import * as z from "zod";

import { eq, schema } from "@projectx/db";
import { PLANS, stripe } from "@projectx/stripe";
import { PLANS, withStripe, type PlansResponse } from "@projectx/stripe";
import { purchaseOrgSchema } from "@projectx/validators";

import { env } from "../env.mjs";
@@ -14,119 +14,119 @@ export const stripeRouter = createTRPCRouter({
createSession: protectedProcedure
.input(z.object({ planId: z.string() }))
.mutation(async (opts) => {
const { userId } = opts.ctx.auth;
return withStripe<{ success: boolean; url?: string }>(async (stripe) => {
const { userId } = opts.ctx.auth;

const customer = await opts.ctx.db
.select({
id: schema.customer.id,
plan: schema.customer.plan,
stripeId: schema.customer.stripeId,
})
.from(schema.customer)
.where(eq(schema.customer.clerkUserId, userId));

const returnUrl = `${env.NEXTJS_URL}/dashboard`;

if (customer?.[0] && customer[0].plan !== "FREE") {
/**
* User is subscribed, create a billing portal session
*/
const session = await stripe.billingPortal.sessions.create({
customer: customer[0].stripeId,
return_url: returnUrl,
});
return {
success: true as const,
url: session.url,
};
}

const customer = await opts.ctx.db
.select({
id: schema.customer.id,
plan: schema.customer.plan,
stripeId: schema.customer.stripeId,
})
.from(schema.customer)
.where(eq(schema.customer.clerkUserId, userId));

const returnUrl = `${env.NEXTJS_URL}/dashboard`;

if (customer?.[0] && customer[0].plan !== "FREE") {
/**
* User is subscribed, create a billing portal session
* User is not subscribed, create a checkout session
* Use existing email address if available
*/
const session = await stripe.billingPortal.sessions.create({
customer: customer[0].stripeId,
return_url: returnUrl,

const user = await currentUser();
const email = user?.emailAddresses.find(
(addr) => addr.id === user?.primaryEmailAddressId,
)?.emailAddress;

const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
customer_email: email,
client_reference_id: userId,
subscription_data: { metadata: { userId } },
cancel_url: returnUrl,
success_url: returnUrl,
line_items: [{ price: PLANS.PRO?.priceId, quantity: 1 }],
});

if (!session.url) return { success: false as const };
return {
success: true as const,
url: session.url,
};
}

/**
* User is not subscribed, create a checkout session
* Use existing email address if available
*/

const user = await currentUser();
const email = user?.emailAddresses.find(
(addr) => addr.id === user?.primaryEmailAddressId,
)?.emailAddress;

const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
customer_email: email,
client_reference_id: userId,
subscription_data: { metadata: { userId } },
cancel_url: returnUrl,
success_url: returnUrl,
line_items: [{ price: PLANS.PRO?.priceId, quantity: 1 }],
});

if (!session.url) return { success: false as const };
return {
success: true as const,
url: session.url,
};
}),

plans: publicProcedure.query(async () => {
const proPrice = await stripe.prices.retrieve(PLANS.PRO.priceId);
const stdPrice = await stripe.prices.retrieve(PLANS.STANDARD.priceId);

return [
{
...PLANS.STANDARD,
price: dinero({
amount: stdPrice.unit_amount!,
currency: {
code: "USD",
base: 10,
exponent: 2,
},
// currencies[stdPrice.currency as keyof typeof currencies] ??
// currencies.EUR,
}),
},
{
...PLANS.PRO,
price: dinero({
amount: proPrice.unit_amount!,
currency: {
code: "USD",
base: 10,
exponent: 2,
},
// currencies[proPrice.currency as keyof typeof currencies] ??
// currencies.EUR,
}),
},
];
withStripe<PlansResponse>(async (stripe) => {
const proPrice = await stripe.prices.retrieve(PLANS.PRO?.priceId || "");
const stdPrice = await stripe.prices.retrieve(
PLANS.STANDARD?.priceId || "",
);

return [
PLANS.STANDARD
? {
...PLANS.STANDARD,
price: dinero({
amount: stdPrice.unit_amount || 0,
currency: USD,
}),
}
: undefined,
PLANS.PRO
? {
...PLANS.PRO,
price: dinero({
amount: proPrice.unit_amount || 0,
currency: USD,
}),
}
: undefined,
].filter(Boolean) as PlansResponse;
});
}),

purchaseOrg: protectedProcedure
.input(purchaseOrgSchema)
.mutation(async (opts) => {
const { userId } = opts.ctx.auth;
const { orgName, planId } = opts.input;

const baseUrl = new URL(opts.ctx.req?.nextUrl ?? env.NEXTJS_URL).origin;

const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
client_reference_id: userId,
subscription_data: {
metadata: { userId, organizationName: orgName },
},
success_url: `${baseUrl}/onboarding`,
cancel_url: baseUrl,
line_items: [{ price: planId, quantity: 1 }],
});
return withStripe<{ success: boolean; url?: string }>(async (stripe) => {
const { userId } = opts.ctx.auth;
const { orgName, planId } = opts.input;

const baseUrl = new URL(opts.ctx.req?.nextUrl ?? env.NEXTJS_URL).origin;

const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
client_reference_id: userId,
subscription_data: {
metadata: { userId, organizationName: orgName },
},
success_url: `${baseUrl}/onboarding`,
cancel_url: baseUrl,
line_items: [{ price: planId, quantity: 1 }],
});

if (!session.url) return { success: false as const };
return {
success: true as const,
url: session.url,
};
if (!session.url) return { success: false as const };
return {
success: true as const,
url: session.url,
};
});
}),
});
23 changes: 12 additions & 11 deletions packages/stripe/src/index.ts
Original file line number Diff line number Diff line change
@@ -4,20 +4,21 @@ import { env } from "./env.mjs";

export * from "./plans";
export * from "./webhooks";
export * from "./utils";

export type { Stripe };

let stripe: Stripe | undefined;

const useStripe = env.USE_STRIPE === "true";

if (useStripe && env.STRIPE_API_KEY) {
stripe = new Stripe(env.STRIPE_API_KEY, {
apiVersion: "2023-10-16",
typescript: true,
});
} else {
export const initializeStripe = (): Stripe | undefined => {
if (env.USE_STRIPE === "true" && env.STRIPE_API_KEY) {
return new Stripe(env.STRIPE_API_KEY, {
apiVersion: "2023-10-16",
typescript: true,
});
}
console.log("Stripe integration is disabled or not properly configured.");
}
return undefined;
};

const stripe = initializeStripe();

export { stripe };
Loading