This commit is contained in:
giancarlo
2024-03-24 02:23:22 +08:00
parent 648d77b430
commit bce3479368
589 changed files with 37067 additions and 9596 deletions

View File

@@ -0,0 +1,89 @@
import type { Stripe } from 'stripe';
export interface CreateStripeCheckoutParams {
returnUrl: string;
organizationUid: string;
priceId: string;
customerId?: string;
trialPeriodDays?: number | undefined;
customerEmail?: string;
embedded: boolean;
}
/**
* @name createStripeCheckout
* @description Creates a Stripe Checkout session, and returns an Object
* containing the session, which you can use to redirect the user to the
* checkout page
*/
export default async function createStripeCheckout(
stripe: Stripe,
params: CreateStripeCheckoutParams,
) {
// in MakerKit, a subscription belongs to an organization,
// rather than to a user
// if you wish to change it, use the current user ID instead
const clientReferenceId = params.organizationUid;
// we pass an optional customer ID, so we do not duplicate the Stripe
// customers if an organization subscribes multiple times
const customer = params.customerId ?? undefined;
// if it's a one-time payment
// you should change this to "payment"
// docs: https://stripe.com/docs/billing/subscriptions/build-subscription
const mode: Stripe.Checkout.SessionCreateParams.Mode = 'subscription';
const lineItem: Stripe.Checkout.SessionCreateParams.LineItem = {
quantity: 1,
price: params.priceId,
};
const subscriptionData: Stripe.Checkout.SessionCreateParams.SubscriptionData =
{
trial_period_days: params.trialPeriodDays,
metadata: {
organizationUid: params.organizationUid,
},
};
const urls = getUrls({
embedded: params.embedded,
returnUrl: params.returnUrl,
});
const uiMode = params.embedded ? 'embedded' : 'hosted';
const customerData = customer
? {
customer,
}
: {
customer_email: params.customerEmail,
};
return stripe.checkout.sessions.create({
mode,
ui_mode: uiMode,
line_items: [lineItem],
client_reference_id: clientReferenceId.toString(),
subscription_data: subscriptionData,
...customerData,
...urls,
});
}
function getUrls(params: { returnUrl: string; embedded?: boolean }) {
const successUrl = `${params.returnUrl}?success=true`;
const cancelUrl = `${params.returnUrl}?cancel=true`;
const returnUrl = `${params.returnUrl}/return?session_id={CHECKOUT_SESSION_ID}`;
return params.embedded
? {
return_url: returnUrl,
}
: {
success_url: successUrl,
cancel_url: cancelUrl,
};
}

View File

@@ -0,0 +1,20 @@
import type { Stripe } from 'stripe';
export interface CreateBillingPortalSessionParams {
customerId: string;
returnUrl: string;
}
/**
* @name createStripeBillingPortalSession
* @description Create a Stripe billing portal session for a user
*/
export async function createStripeBillingPortalSession(
stripe: Stripe,
params: CreateBillingPortalSessionParams,
) {
return stripe.billingPortal.sessions.create({
customer: params.customerId,
return_url: params.returnUrl,
});
}

View File

@@ -1,36 +0,0 @@
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

@@ -1,13 +1 @@
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,
});
export { stripe } from './stripe.service';

View File

@@ -1,41 +0,0 @@
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,328 @@
'use server';
import { RedirectType } from 'next/dist/client/components/redirect';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import type { SupabaseClient } from '@supabase/supabase-js';
import { join } from 'path';
import { z } from 'zod';
import getSupabaseServerActionClient from '@packages/supabase/action-client';
import { withSession } from '@kit/generic/actions-utils';
import getLogger from '@kit/logger';
import pathsConfig from '@/config/paths.config';
import pricingConfig from '@/config/pricing.config';
import { getUserMembershipByOrganization } from '@/lib/memberships/queries';
import {
getOrganizationByCustomerId,
getOrganizationByUid,
} from '@/lib/organizations/database/queries';
import { canChangeBilling } from '@/lib/organizations/permissions';
import requireSession from '@/lib/user/require-session';
import { stripe } from './stripe.service';
export const createCheckoutAction = withSession(
async (_, formData: FormData) => {
const logger = getLogger();
const bodyResult = await getCheckoutBodySchema().safeParseAsync(
Object.fromEntries(formData),
);
const redirectToErrorPage = (error?: string) => {
const referer = headers().get('referer')!;
const url = join(referer, `?error=true`);
logger.error({ error }, `Could not create Stripe Checkout session`);
return redirect(url);
};
// Validate the body schema
if (!bodyResult.success) {
return redirectToErrorPage(`Invalid request body`);
}
const { organizationUid, priceId, returnUrl } = bodyResult.data;
// create the Supabase client
const client = getSupabaseServerActionClient();
// require the user to be logged in
const sessionResult = await requireSession(client);
const userId = sessionResult.user.id;
const customerEmail = sessionResult.user.email;
const { error, data } = await getOrganizationByUid(client, organizationUid);
if (error) {
return redirectToErrorPage(`Organization not found`);
}
const customerId = data?.subscription?.customerId;
if (customerId) {
logger.info({ customerId }, `Customer ID found for organization`);
}
const plan = getPlanByPriceId(priceId);
// check if the plan exists in the appConfig.
if (!plan) {
console.warn(
`Plan not found for price ID "${priceId}". Did you forget to add it to the configuration? If the Price ID is incorrect, the checkout will be rejected. Please check the Stripe dashboard`,
);
}
// check the user's role has access to the checkout
const canChangeBilling = await getUserCanAccessCheckout(client, {
organizationUid,
userId,
});
// disallow if the user doesn't have permissions to change
// billing account based on its role. To change the logic, please update
// {@link canChangeBilling}
if (!canChangeBilling) {
logger.debug(
{
userId,
organizationUid,
},
`User attempted to access checkout but lacked permissions`,
);
return redirectToErrorPage(
`You do not have permission to access this page`,
);
}
const trialPeriodDays =
plan && 'trialPeriodDays' in plan
? (plan.trialPeriodDays as number)
: undefined;
// create the Stripe Checkout session
const session = await stripe
.createCheckout({
returnUrl,
organizationUid,
priceId,
customerId,
trialPeriodDays,
customerEmail,
embedded: true,
})
.catch((e) => {
logger.error(e, `Stripe Checkout error`);
});
// if there was an error, redirect to the error page
if (!session) {
return redirectToErrorPage();
}
logger.info(
{
id: session.id,
organizationUid,
},
`Created Stripe Checkout session`,
);
if (!session.client_secret) {
logger.error(
{ id: session.id },
`Stripe Checkout session missing client secret`,
);
return redirectToErrorPage();
}
// if the checkout is embedded, we need to render the checkout
// therefore, we send the clientSecret back to the client
logger.info(
{ id: session.id },
`Using embedded checkout mode. Sending client secret back to client.`,
);
return {
clientSecret: session.client_secret,
};
},
);
/**
* @name getUserCanAccessCheckout
* @description check if the user has permissions to access the checkout
* @param client
* @param params
*/
async function getUserCanAccessCheckout(
client: SupabaseClient,
params: {
organizationUid: string;
userId: string;
},
) {
try {
const { role } = await getUserMembershipByOrganization(client, params);
if (role === undefined) {
return false;
}
return canChangeBilling(role);
} catch (error) {
getLogger().error({ error }, `Could not retrieve user role`);
return false;
}
}
export const createBillingPortalSessionAction = withSession(
async (formData: FormData) => {
const body = Object.fromEntries(formData);
const bodyResult = await getBillingPortalBodySchema().safeParseAsync(body);
const referrerPath = getReferrer();
// Validate the body schema
if (!bodyResult.success) {
return redirectToErrorPage(referrerPath);
}
const { customerId } = bodyResult.data;
const client = getSupabaseServerActionClient();
const logger = getLogger();
const session = await requireSession(client);
const userId = session.user.id;
// get permissions to see if the user can access the portal
const canAccess = await getUserCanAccessCustomerPortal(client, {
customerId,
userId,
});
// validate that the user can access the portal
if (!canAccess) {
return redirectToErrorPage(referrerPath);
}
const referer = headers().get('referer');
const origin = headers().get('origin');
const returnUrl = referer || origin || pathsConfig.appHome;
// get the Stripe Billing Portal session
const { url } = await stripe
.createBillingPortalSession({
returnUrl,
customerId,
})
.catch((e) => {
logger.error(e, `Stripe Billing Portal redirect error`);
return redirectToErrorPage(referrerPath);
});
// redirect to the Stripe Billing Portal
return redirect(url, RedirectType.replace);
},
);
/**
* @name getUserCanAccessCustomerPortal
* @description Returns whether a user {@link userId} has access to the
* Stripe portal of an organization with customer ID {@link customerId}
*/
async function getUserCanAccessCustomerPortal(
client: SupabaseClient,
params: {
customerId: string;
userId: string;
},
) {
const logger = getLogger();
const { data: organization, error } = await getOrganizationByCustomerId(
client,
params.customerId,
);
if (error) {
logger.error(
{
error,
customerId: params.customerId,
},
`Could not retrieve organization by Customer ID`,
);
return false;
}
try {
const organizationUid = organization.uuid;
const { role } = await getUserMembershipByOrganization(client, {
organizationUid,
userId: params.userId,
});
if (role === undefined) {
return false;
}
return canChangeBilling(role);
} catch (error) {
logger.error({ error }, `Could not retrieve user role`);
return false;
}
}
function getBillingPortalBodySchema() {
return z.object({
customerId: z.string().min(1),
});
}
function getCheckoutBodySchema() {
return z.object({
organizationUid: z.string().uuid(),
priceId: z.string().min(1),
returnUrl: z.string().min(1),
});
}
function getPlanByPriceId(priceId: string) {
const products = pricingConfig.products;
type Plan = (typeof products)[0]['plans'][0];
return products.reduce<Maybe<Plan>>((acc, product) => {
if (acc) {
return acc;
}
return product.plans.find(({ stripePriceId }) => stripePriceId === priceId);
}, undefined);
}
function redirectToErrorPage(referrerPath: string) {
const url = join(referrerPath, `?error=true`);
return redirect(url);
}
function getReferrer() {
const referer = headers().get('referer');
const origin = headers().get('origin');
return referer || origin || pathsConfig.appHome;
}

View File

@@ -0,0 +1,20 @@
import { invariant } from '@epic-web/invariant';
import 'server-only';
const STRIPE_API_VERSION = '2023-10-16';
/**
* @description returns a Stripe instance
*/
export async function createStripeClient() {
const { default: Stripe } = await import('stripe');
invariant(
process.env.STRIPE_SECRET_KEY,
`'STRIPE_SECRET_KEY' environment variable was not provided`,
);
return new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: STRIPE_API_VERSION,
});
}

View File

@@ -0,0 +1,37 @@
import 'server-only';
import type { Stripe } from 'stripe';
import createStripeCheckout, {
CreateStripeCheckoutParams,
} from './create-checkout';
import {
CreateBillingPortalSessionParams,
createStripeBillingPortalSession,
} from './create-stripe-billing-portal-session';
import { createStripeClient } from './stripe-sdk';
class StripeService {
constructor(private readonly stripeProvider: () => Promise<Stripe>) {}
async createCheckout(params: CreateStripeCheckoutParams) {
const stripe = await this.stripeProvider();
return createStripeCheckout(stripe, params);
}
async createBillingPortalSession(params: CreateBillingPortalSessionParams) {
const stripe = await this.stripeProvider();
return createStripeBillingPortalSession(stripe, params);
}
async cancelSubscription(subscriptionId: string) {
const stripe = await this.stripeProvider();
return stripe.subscriptions.cancel(subscriptionId, {
invoice_now: true,
});
}
}
export const stripe = new StripeService(createStripeClient);

View File

@@ -0,0 +1,9 @@
enum StripeWebhooks {
AsyncPaymentSuccess = 'checkout.session.async_payment_succeeded',
Completed = 'checkout.session.completed',
AsyncPaymentFailed = 'checkout.session.async_payment_failed',
SubscriptionDeleted = 'customer.subscription.deleted',
SubscriptionUpdated = 'customer.subscription.updated',
}
export default StripeWebhooks;

View File

@@ -1,153 +0,0 @@
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");
}