Cleanup
This commit is contained in:
89
packages/stripe/src/create-checkout.ts
Normal file
89
packages/stripe/src/create-checkout.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
20
packages/stripe/src/create-stripe-billing-portal-session.ts
Normal file
20
packages/stripe/src/create-stripe-billing-portal-session.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
328
packages/stripe/src/server-actions.ts
Normal file
328
packages/stripe/src/server-actions.ts
Normal 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;
|
||||
}
|
||||
20
packages/stripe/src/stripe-sdk.ts
Normal file
20
packages/stripe/src/stripe-sdk.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
37
packages/stripe/src/stripe.service.ts
Normal file
37
packages/stripe/src/stripe.service.ts
Normal 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);
|
||||
9
packages/stripe/src/types/stripe-webhooks.enum.ts
Normal file
9
packages/stripe/src/types/stripe-webhooks.enum.ts
Normal 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;
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user