This commit is contained in:
Turbobot
2024-03-21 13:41:16 +08:00
committed by giancarlo
commit bb58169fe9
204 changed files with 26228 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
{
"name": "@acme/stripe",
"private": true,
"version": "0.1.0",
"exports": {
".": "./src/index.ts",
"./plans": "./src/plans.ts",
"./env": "./src/env.mjs"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"dev": "stripe listen --forward-to localhost:3000/api/webhooks/stripe",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@acme/db": "^0.1.0",
"@clerk/nextjs": "^4.29.4",
"@t3-oss/env-nextjs": "^0.7.3",
"stripe": "^14.13.0"
},
"devDependencies": {
"@acme/eslint-config": "^0.2.0",
"@acme/prettier-config": "^0.1.0",
"@acme/tsconfig": "^0.1.0",
"eslint": "^8.56.0",
"prettier": "^3.2.4",
"typescript": "^5.3.3"
},
"eslintConfig": {
"extends": [
"@acme/eslint-config/base"
]
},
"prettier": "@acme/prettier-config"
}

View 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",
});

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

View 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);
}

View 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");
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@acme/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"],
}