feat(create-turbo): create https://github.com/juliusmarminge/acme-corp
This commit is contained in:
36
packages/stripe/src/env.mjs
Normal file
36
packages/stripe/src/env.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import * as z from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
shared: {},
|
||||
server: {
|
||||
NEXTJS_URL: z.preprocess(
|
||||
(str) =>
|
||||
process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : str,
|
||||
process.env.VERCEL_URL ? z.string().min(1) : z.string().url(),
|
||||
),
|
||||
|
||||
STRIPE_API_KEY: z.string(),
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_STRIPE_STD_PRODUCT_ID: z.string(),
|
||||
NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID: z.string(),
|
||||
NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID: z.string(),
|
||||
NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID: z.string(),
|
||||
},
|
||||
// Client side variables gets destructured here due to Next.js static analysis
|
||||
// Shared ones are also included here for good measure since the behavior has been inconsistent
|
||||
experimental__runtimeEnv: {
|
||||
NEXT_PUBLIC_STRIPE_STD_PRODUCT_ID:
|
||||
process.env.NEXT_PUBLIC_STRIPE_STD_PRODUCT_ID,
|
||||
NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID:
|
||||
process.env.NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID,
|
||||
NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID:
|
||||
process.env.NEXT_PUBLIC_STRIPE_PRO_PRODUCT_ID,
|
||||
NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID:
|
||||
process.env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID,
|
||||
},
|
||||
skipValidation:
|
||||
!!process.env.SKIP_ENV_VALIDATION ||
|
||||
process.env.npm_lifecycle_event === "lint",
|
||||
});
|
||||
13
packages/stripe/src/index.ts
Normal file
13
packages/stripe/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Stripe } from "stripe";
|
||||
|
||||
import { env } from "./env.mjs";
|
||||
|
||||
export * from "./plans";
|
||||
export * from "./webhooks";
|
||||
|
||||
export type { Stripe };
|
||||
|
||||
export const stripe = new Stripe(env.STRIPE_API_KEY, {
|
||||
apiVersion: "2023-10-16",
|
||||
typescript: true,
|
||||
});
|
||||
41
packages/stripe/src/plans.ts
Normal file
41
packages/stripe/src/plans.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { SubscriptionPlan } from "@acme/db";
|
||||
|
||||
import { env } from "./env.mjs";
|
||||
|
||||
interface PlanInfo {
|
||||
key: SubscriptionPlan;
|
||||
name: string;
|
||||
description: string;
|
||||
preFeatures?: string;
|
||||
features: string[];
|
||||
priceId: string;
|
||||
}
|
||||
|
||||
export const PLANS: Record<SubscriptionPlan, PlanInfo> = {
|
||||
STANDARD: {
|
||||
key: SubscriptionPlan.STANDARD,
|
||||
name: "Standard",
|
||||
description: "For individuals",
|
||||
features: ["Invite up to 1 team member", "Lorem ipsum dolor sit amet"],
|
||||
priceId: env.NEXT_PUBLIC_STRIPE_STD_MONTHLY_PRICE_ID,
|
||||
},
|
||||
PRO: {
|
||||
key: SubscriptionPlan.PRO,
|
||||
name: "Pro",
|
||||
description: "For teams",
|
||||
preFeatures: "Everything in standard, plus",
|
||||
features: ["Invite up to 5 team members", "Unlimited projects"],
|
||||
priceId: env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID,
|
||||
},
|
||||
FREE: {
|
||||
key: SubscriptionPlan.FREE,
|
||||
name: "Free",
|
||||
description: "For individuals",
|
||||
features: ["Invite up to 1 team member", "Lorem ipsum dolor sit amet"],
|
||||
priceId: "no-id-necessary",
|
||||
},
|
||||
};
|
||||
|
||||
export function stripePriceToSubscriptionPlan(priceId: string | undefined) {
|
||||
return Object.values(PLANS).find((plan) => plan.priceId === priceId);
|
||||
}
|
||||
153
packages/stripe/src/webhooks.ts
Normal file
153
packages/stripe/src/webhooks.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { clerkClient } from "@clerk/nextjs";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
import { db, genId } from "@acme/db";
|
||||
|
||||
import { stripe } from ".";
|
||||
import { stripePriceToSubscriptionPlan } from "./plans";
|
||||
|
||||
export async function handleEvent(event: Stripe.Event) {
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
const session = event.data.object;
|
||||
if (typeof session.subscription !== "string") {
|
||||
throw new Error("Missing or invalid subscription id");
|
||||
}
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
session.subscription,
|
||||
);
|
||||
|
||||
const customerId =
|
||||
typeof subscription.customer === "string"
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
const { userId, organizationName } = subscription.metadata;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("Missing user id");
|
||||
}
|
||||
|
||||
const customer = await db
|
||||
.selectFrom("Customer")
|
||||
.select("id")
|
||||
.where("stripeId", "=", customerId)
|
||||
.executeTakeFirst();
|
||||
|
||||
const subscriptionPlan = stripePriceToSubscriptionPlan(
|
||||
subscription.items.data[0]?.price.id,
|
||||
);
|
||||
|
||||
/**
|
||||
* User is already subscribed, update their info
|
||||
*/
|
||||
if (customer) {
|
||||
return await db
|
||||
.updateTable("Customer")
|
||||
.where("id", "=", customer.id)
|
||||
.set({
|
||||
stripeId: customerId,
|
||||
subscriptionId: subscription.id,
|
||||
paidUntil: new Date(subscription.current_period_end * 1000),
|
||||
plan: subscriptionPlan?.key,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* User is not subscribed, create a new customer and org
|
||||
*/
|
||||
const organization = await clerkClient.organizations.createOrganization({
|
||||
createdBy: userId,
|
||||
name: organizationName!,
|
||||
});
|
||||
|
||||
// TODO: SET ACTIVE ORG WHEN CLERK CAN BOTHER TO LET ME DO TAHT SERVERSIDE!!!
|
||||
|
||||
await db
|
||||
.insertInto("Customer")
|
||||
.values({
|
||||
id: genId(),
|
||||
clerkUserId: userId,
|
||||
clerkOrganizationId: organization.id,
|
||||
stripeId: customerId,
|
||||
subscriptionId: subscription.id,
|
||||
plan: subscriptionPlan?.key,
|
||||
paidUntil: new Date(subscription.current_period_end * 1000),
|
||||
endsAt: new Date(subscription.current_period_end * 1000),
|
||||
})
|
||||
.execute();
|
||||
break;
|
||||
}
|
||||
case "invoice.payment_succeeded": {
|
||||
const invoice = event.data.object;
|
||||
if (typeof invoice.subscription !== "string") {
|
||||
throw new Error("Missing or invalid subscription id");
|
||||
}
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
invoice.subscription,
|
||||
);
|
||||
|
||||
const subscriptionPlan = stripePriceToSubscriptionPlan(
|
||||
subscription.items.data[0]?.price.id,
|
||||
);
|
||||
|
||||
await db
|
||||
.updateTable("Customer")
|
||||
.where("subscriptionId", "=", subscription.id)
|
||||
.set({
|
||||
plan: subscriptionPlan?.key,
|
||||
paidUntil: new Date(subscription.current_period_end * 1000),
|
||||
})
|
||||
.execute();
|
||||
break;
|
||||
}
|
||||
case "invoice.payment_failed": {
|
||||
// TODO: Handle failed payments
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.deleted": {
|
||||
const subscription = event.data.object;
|
||||
const customerId =
|
||||
typeof subscription.customer === "string"
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
await db
|
||||
.updateTable("Customer")
|
||||
.where("stripeId", "=", customerId)
|
||||
.set({
|
||||
subscriptionId: null,
|
||||
plan: "FREE",
|
||||
paidUntil: null,
|
||||
})
|
||||
.execute();
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.updated": {
|
||||
const subscription = event.data.object;
|
||||
const customerId =
|
||||
typeof subscription.customer === "string"
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
const subscriptionPlan = stripePriceToSubscriptionPlan(
|
||||
subscription.items.data[0]?.price.id,
|
||||
);
|
||||
|
||||
await db
|
||||
.updateTable("Customer")
|
||||
.where("stripeId", "=", customerId)
|
||||
.set({
|
||||
plan: subscriptionPlan?.key,
|
||||
paidUntil: new Date(subscription.current_period_end * 1000),
|
||||
})
|
||||
.execute();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.log("🆗 Stripe Webhook Unhandled Event Type: ", event.type);
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.log("✅ Stripe Webhook Processed");
|
||||
}
|
||||
Reference in New Issue
Block a user