feat(stripe): stripe customer for organization

Co-authored-by: Cmion <cmion@users.noreply.github.com>
This commit is contained in:
Taesu
2025-11-14 08:35:32 +09:00
parent 1f37481d09
commit e3bb04948d
5 changed files with 655 additions and 127 deletions

View File

@@ -22,4 +22,13 @@ export const STRIPE_ERROR_CODES = defineErrorCodes({
STRIPE_WEBHOOK_ERROR: "Stripe webhook error",
REFERENCE_ID_NOT_ALLOWED:
"Reference id is not allowed. Read server logs for more details.",
ORGANIZATION_HAS_ACTIVE_SUBSCRIPTION:
"Cannot delete organization with active subscriptions",
ORGANIZATION_STRIPE_CUSTOMER_NOT_FOUND:
"No Stripe customer found for this organization",
ORGANIZATION_NOT_FOUND: "Organization not found",
FAILED_TO_CREATE_ORGANIZATION_CUSTOMER:
"Failed to create Stripe customer for organization",
FAILED_TO_UPDATE_ORGANIZATION_CUSTOMER:
"Failed to update organization with Stripe customer ID",
});

View File

@@ -3,6 +3,7 @@ import {
createAuthEndpoint,
createAuthMiddleware,
} from "@better-auth/core/api";
import type { Session, User } from "@better-auth/core/db";
import { logger } from "better-auth";
import {
APIError,
@@ -12,7 +13,7 @@ import {
} from "better-auth/api";
import { defu } from "defu";
import Stripe, { type Stripe as StripeType } from "stripe";
import * as z from "zod/v4";
import { z } from "zod";
import { STRIPE_ERROR_CODES } from "./error-codes";
import {
onCheckoutSessionCompleted,
@@ -22,15 +23,20 @@ import {
import { getSchema } from "./schema";
import type {
InputSubscription,
OrganizationWithStripe,
StripeOptions,
StripePlan,
Subscription,
SubscriptionOptions,
WithActiveOrganizationId,
WithStripeCustomerId,
} from "./types";
import {
getOrganizationPlugin,
getPlanByName,
getPlanByPriceInfo,
getPlans,
getReferenceId,
getUrl,
resolvePriceIdFromLookupKey,
} from "./utils";
@@ -215,7 +221,11 @@ export const stripe = <O extends StripeOptions>(options: O) => {
],
},
async (ctx) => {
const { user, session } = ctx.context.session;
const ctxSession = ctx.context.session as {
session: Session & WithActiveOrganizationId;
user: User & WithStripeCustomerId;
};
const { user, session } = ctxSession;
if (
!user.emailVerified &&
subscriptionOptions.requireEmailVerification
@@ -224,7 +234,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
message: STRIPE_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED,
});
}
const referenceId = ctx.body.referenceId || user.id;
const referenceId = getReferenceId(ctx.body.referenceId, ctxSession);
const plan = await getPlanByName(options, ctx.body.plan);
if (!plan) {
throw new APIError("BAD_REQUEST", {
@@ -263,6 +273,147 @@ export const stripe = <O extends StripeOptions>(options: O) => {
let customerId =
subscriptionToUpdate?.stripeCustomerId || user.stripeCustomerId;
/**
* If enableOrganizationCustomer is enabled and referenceId is not userId,
* try to get organization customer
*/
if (options.enableOrganizationCustomer && referenceId !== user.id) {
try {
const organization =
await ctx.context.adapter.findOne<OrganizationWithStripe>({
model: "organization",
where: [
{
field: "id",
value: referenceId,
},
],
});
if (organization) {
if (organization.stripeCustomerId) {
customerId = organization.stripeCustomerId;
} else {
let stripeCustomer: Stripe.Customer | null = null;
try {
stripeCustomer = await client.customers.create({
name: organization.name,
email: user.email,
metadata: {
organizationId: organization.id,
organizationName: organization.name,
adminUserId: organization.stripeAdminUserId || user.id,
},
});
// Check one more time before updating
const currentOrg =
await ctx.context.adapter.findOne<OrganizationWithStripe>({
model: "organization",
where: [
{
field: "id",
value: organization.id,
},
],
});
if (currentOrg?.stripeCustomerId) {
customerId = currentOrg.stripeCustomerId;
ctx.context.logger.info(
`organization already has customer ${currentOrg.stripeCustomerId}, deleting duplicate ${stripeCustomer.id}`,
);
await client.customers
.del(stripeCustomer.id)
.catch((e) =>
ctx.context.logger.error(
`Failed to delete duplicate Stripe customer: ${e.message}`,
),
);
} else {
const updateResult = await ctx.context.adapter.update({
model: "organization",
update: {
stripeCustomerId: stripeCustomer.id,
stripeAdminUserId:
organization.stripeAdminUserId || user.id,
},
where: [
{
field: "id",
value: organization.id,
},
],
});
if (!updateResult) {
ctx.context.logger.error(
`Failed to update organization ${organization.id} with stripeCustomerId`,
);
throw new APIError("BAD_REQUEST", {
message:
STRIPE_ERROR_CODES.FAILED_TO_UPDATE_ORGANIZATION_CUSTOMER,
});
}
await options.onOrganizationCustomerCreate?.(
{
stripeCustomer,
organization: {
...organization,
stripeCustomerId: stripeCustomer.id,
stripeAdminUserId:
organization.stripeAdminUserId || user.id,
},
adminUser: user,
},
ctx,
);
customerId = stripeCustomer.id;
ctx.context.logger.info(
`Created Stripe customer ${stripeCustomer.id} for organization ${organization.id}`,
);
}
} catch (error: any) {
// Rollback: Delete Stripe customer if we created one
if (stripeCustomer) {
await client.customers
.del(stripeCustomer.id)
.catch((e) =>
ctx.context.logger.error(
`Rollback failed - could not delete Stripe customer: ${e.message}`,
),
);
}
// Always throw APIError to prevent fallback
if (error instanceof APIError) {
throw error;
}
// Wrap other errors
throw new APIError("BAD_REQUEST", {
message:
STRIPE_ERROR_CODES.FAILED_TO_CREATE_ORGANIZATION_CUSTOMER,
});
}
}
}
} catch (e: any) {
// APIError should be thrown immediately (no fallback)
if (e instanceof APIError) {
throw e;
}
ctx.context.logger.error(
`Failed to get/create organization customer: ${e.message}`,
);
}
}
/**
* If still no customerId, try to get or create user customer
*/
if (!customerId) {
try {
// Try to find existing Stripe customer by email
@@ -545,20 +696,24 @@ export const stripe = <O extends StripeOptions>(options: O) => {
);
}
}
// Check if this is an organization subscription
const isOrganizationSubscription =
options.enableOrganizationCustomer && referenceId !== user.id;
// Prepare customer data
const customerData = customerId
? {
customer: customerId,
customer_update: isOrganizationSubscription
? ({ address: "auto" } as const) // managed by organization name
: ({ name: "auto", address: "auto" } as const),
}
: { customer_email: user.email };
const checkoutSession = await client.checkout.sessions
.create(
{
...(customerId
? {
customer: customerId,
customer_update: {
name: "auto",
address: "auto",
},
}
: {
customer_email: session.user.email,
}),
...customerData,
success_url: getUrl(
ctx,
`${
@@ -606,7 +761,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
{
method: "GET",
query: z.record(z.string(), z.any()).optional(),
use: [originCheck((ctx) => ctx.query.callbackURL)],
use: [originCheck((ctx) => ctx.query.callbackURL)], // sessionMiddleware not used for redirect
},
async (ctx) => {
if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) {
@@ -725,8 +880,11 @@ export const stripe = <O extends StripeOptions>(options: O) => {
],
},
async (ctx) => {
const referenceId =
ctx.body?.referenceId || ctx.context.session.user.id;
const ctxSession = ctx.context.session as {
session: Session & WithActiveOrganizationId;
user: User & WithStripeCustomerId;
};
const referenceId = getReferenceId(ctx.body?.referenceId, ctxSession);
const subscription = ctx.body.subscriptionId
? await ctx.context.adapter.findOne<Subscription>({
model: "subscription",
@@ -861,8 +1019,11 @@ export const stripe = <O extends StripeOptions>(options: O) => {
use: [sessionMiddleware, referenceMiddleware("restore-subscription")],
},
async (ctx) => {
const referenceId =
ctx.body?.referenceId || ctx.context.session.user.id;
const ctxSession = ctx.context.session as {
session: Session & WithActiveOrganizationId;
user: User & WithStripeCustomerId;
};
const referenceId = getReferenceId(ctx.body?.referenceId, ctxSession);
const subscription = ctx.body.subscriptionId
? await ctx.context.adapter.findOne<Subscription>({
@@ -989,12 +1150,17 @@ export const stripe = <O extends StripeOptions>(options: O) => {
use: [sessionMiddleware, referenceMiddleware("list-subscription")],
},
async (ctx) => {
const ctxSession = ctx.context.session as {
session: Session & WithActiveOrganizationId;
user: User & WithStripeCustomerId;
};
const referenceId = getReferenceId(ctx.query?.referenceId, ctxSession);
const subscriptions = await ctx.context.adapter.findMany<Subscription>({
model: "subscription",
where: [
{
field: "referenceId",
value: ctx.query?.referenceId || ctx.context.session.user.id,
value: referenceId,
},
],
});
@@ -1027,7 +1193,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
{
method: "GET",
query: z.record(z.string(), z.any()).optional(),
use: [originCheck((ctx) => ctx.query.callbackURL)],
use: [originCheck((ctx) => ctx.query.callbackURL)], // sessionMiddleware not used for redirect
},
async (ctx) => {
if (!ctx.query || !ctx.query.callbackURL || !ctx.query.subscriptionId) {
@@ -1144,11 +1310,39 @@ export const stripe = <O extends StripeOptions>(options: O) => {
],
},
async (ctx) => {
const { user } = ctx.context.session;
const referenceId = ctx.body.referenceId || user.id;
const ctxSession = ctx.context.session as {
session: Session & WithActiveOrganizationId;
user: User & WithStripeCustomerId;
};
const { user } = ctxSession;
const referenceId = getReferenceId(ctx.body.referenceId, ctxSession);
let customerId = user.stripeCustomerId;
// If enableOrganizationCustomer is enabled and referenceId is not user.id, try to get organization customer
if (options.enableOrganizationCustomer && referenceId !== user.id) {
try {
const organization =
await ctx.context.adapter.findOne<OrganizationWithStripe>({
model: "organization",
where: [
{
field: "id",
value: referenceId,
},
],
});
if (organization?.stripeCustomerId) {
customerId = organization.stripeCustomerId;
}
} catch (e: any) {
ctx.context.logger.error(
`Failed to get organization customer: ${e.message}`,
);
}
}
// Fallback to subscription customer if still no customerId
if (!customerId) {
const subscription = await ctx.context.adapter
.findMany<Subscription>({
@@ -1294,133 +1488,344 @@ export const stripe = <O extends StripeOptions>(options: O) => {
? typeof subscriptionEndpoints
: {}),
},
init(ctx) {
if (options.enableOrganizationCustomer && ctx.options?.plugins) {
const orgPlugin = getOrganizationPlugin(ctx.options.plugins);
if (!orgPlugin) {
logger.error(`Organization plugin not found`);
return;
}
const existingHooks = orgPlugin.options.organizationHooks || {};
const afterCreateStripeOrg = async (data: {
organization: OrganizationWithStripe;
user: User;
}) => {
const { organization, user } = data;
if (organization.stripeCustomerId) return;
try {
const stripeCustomer = await client.customers.create({
name: organization.name,
email: user.email,
metadata: {
organizationId: organization.id,
organizationName: organization.name,
adminUserId: user.id,
},
});
await ctx.adapter.update({
model: "organization",
update: {
stripeCustomerId: stripeCustomer.id,
stripeAdminUserId: user.id,
},
where: [{ field: "id", value: organization.id }],
});
} catch (e: any) {
logger.error(
`[Stripe Sync] Failed to create Stripe customer: ${e.message}`,
);
// Don't throw
// Allow organization creation to succeed even if Stripe fails -> user customer
}
};
const afterUpdateStripeOrg = async (data: {
organization: OrganizationWithStripe | null;
user: User;
}) => {
const { organization } = data;
logger.info(
`[Stripe Sync] afterUpdateStripeOrg called - orgId: ${organization?.id}, stripeCustomerId: ${organization?.stripeCustomerId}`,
);
if (!organization?.stripeCustomerId) {
logger.warn(
`[Stripe Sync] Skipping - no stripeCustomerId for org: ${organization?.id}`,
);
return;
}
try {
const stripeCustomer = await client.customers.retrieve(
organization.stripeCustomerId,
);
if (!stripeCustomer.deleted) {
logger.info(
`[Stripe Sync] Retrieved customer ${organization.stripeCustomerId}`,
);
// Check if this update is coming from a recent Stripe webhook sync
const lastSynced = stripeCustomer.metadata?.lastSyncedAt;
if (lastSynced) {
const lastSyncTime = new Date(lastSynced).getTime();
const now = Date.now();
const timeDiff = now - lastSyncTime;
logger.info(
`[Stripe Sync] lastSyncedAt: ${lastSynced}, timeDiff: ${timeDiff}ms`,
);
if (timeDiff < 2000) {
logger.debug(
`[Stripe Sync] Skipping - recently synced (${timeDiff}ms ago)`,
);
return;
}
}
const stripeName = stripeCustomer.deleted
? null
: (stripeCustomer as any).name;
const needsUpdate =
organization.name !== stripeName ||
organization.name !== stripeCustomer.metadata?.organizationName;
logger.info(
`[Stripe Sync] needsUpdate: ${needsUpdate}, orgName: "${organization.name}", stripeName: "${stripeName}", metadataOrgName: "${stripeCustomer.metadata?.organizationName}"`,
);
if (needsUpdate) {
await client.customers.update(organization.stripeCustomerId, {
name: organization.name,
metadata: {
...stripeCustomer.metadata,
organizationName: organization.name,
lastSyncedAt: new Date().toISOString(),
},
});
logger.info(
`[Stripe Sync] Successfully updated customer ${organization.stripeCustomerId} with name: "${organization.name}"`,
);
} else {
logger.info(`[Stripe Sync] No update needed - names match`);
}
} else {
logger.warn(
`[Stripe Sync] Customer ${organization.stripeCustomerId} was deleted`,
);
}
} catch (e: any) {
logger.error(
`[Stripe Sync] Failed to update Stripe customer: ${e.message}`,
);
}
};
const beforeDeleteStripeOrg = async (data: {
organization: OrganizationWithStripe;
user: User;
}) => {
const { organization } = data;
if (!organization.stripeCustomerId) return;
try {
const stripeSubscriptions = await client.subscriptions.list({
customer: organization.stripeCustomerId,
status: "all",
limit: 100,
});
const activeSubscriptions = stripeSubscriptions.data.filter(
(sub) =>
sub.status === "active" ||
sub.status === "trialing" ||
sub.status === "past_due" ||
sub.status === "unpaid" ||
sub.status === "paused",
);
if (activeSubscriptions.length > 0) {
throw new APIError("BAD_REQUEST", {
message:
STRIPE_ERROR_CODES.ORGANIZATION_HAS_ACTIVE_SUBSCRIPTION,
});
}
} catch (error: any) {
logger.error(
`[Stripe Sync] Error checking subscriptions: ${error.message}`,
);
throw error;
}
};
const afterDeleteStripeOrg = async (data: {
organization: OrganizationWithStripe;
user: User;
}) => {
const { organization } = data;
if (!organization.stripeCustomerId) return;
try {
await client.customers.del(organization.stripeCustomerId);
} catch (e: any) {
logger.error(
`[Stripe Sync] Failed to delete Stripe customer: ${e.message}`,
);
}
};
orgPlugin.options.organizationHooks = {
afterCreateOrganization: existingHooks.afterCreateOrganization
? async (data) => {
await existingHooks.afterCreateOrganization!(data);
await afterCreateStripeOrg(data);
}
: async (data) => {
await afterCreateStripeOrg(data);
},
afterUpdateOrganization: existingHooks.afterUpdateOrganization
? async (data) => {
await existingHooks.afterUpdateOrganization!(data);
await afterUpdateStripeOrg(data);
}
: async (data) => {
await afterUpdateStripeOrg(data);
},
beforeDeleteOrganization: existingHooks.beforeDeleteOrganization
? async (data) => {
await existingHooks.beforeDeleteOrganization!(data);
await beforeDeleteStripeOrg(data);
}
: beforeDeleteStripeOrg,
afterDeleteOrganization: existingHooks.afterDeleteOrganization
? async (data) => {
await existingHooks.afterDeleteOrganization!(data);
await afterDeleteStripeOrg(data);
}
: afterDeleteStripeOrg,
};
}
return {
options: {
databaseHooks: {
user: {
create: {
async after(user, ctx) {
if (!ctx || !options.createCustomerOnSignUp) return;
// Only register create hook when createCustomerOnSignUp is enabled
...(options.createCustomerOnSignUp
? {
create: {
async after(user: User & WithStripeCustomerId, ctx) {
if (!ctx || user.stripeCustomerId) {
return;
}
try {
const userWithStripe = user as typeof user & {
stripeCustomerId?: string;
};
try {
// Check if customer already exists in Stripe by email
const existingCustomers = await client.customers.list(
{
email: user.email,
limit: 1,
},
);
// Skip if user already has a Stripe customer ID
if (userWithStripe.stripeCustomerId) return;
let stripeCustomer = existingCustomers.data[0];
// Check if customer already exists in Stripe by email
const existingCustomers = await client.customers.list({
email: user.email,
limit: 1,
});
// If customer exists, link it to prevent duplicate creation
if (stripeCustomer) {
await ctx.context.internalAdapter.updateUser(
user.id,
{
stripeCustomerId: stripeCustomer.id,
},
);
await options.onCustomerCreate?.(
{
stripeCustomer,
user: {
...user,
stripeCustomerId: stripeCustomer.id,
},
},
ctx,
);
ctx.context.logger.info(
`Linked existing Stripe customer ${stripeCustomer.id} to user ${user.id}`,
);
return;
}
let stripeCustomer = existingCustomers.data[0];
// Create new Stripe customer
let extraCreateParams: Partial<Stripe.CustomerCreateParams> =
{};
if (options.getCustomerCreateParams) {
extraCreateParams =
await options.getCustomerCreateParams(user, ctx);
}
// If customer exists, link it to prevent duplicate creation
if (stripeCustomer) {
await ctx.context.internalAdapter.updateUser(user.id, {
stripeCustomerId: stripeCustomer.id,
});
await options.onCustomerCreate?.(
{
stripeCustomer,
user: {
...user,
stripeCustomerId: stripeCustomer.id,
},
},
ctx,
);
ctx.context.logger.info(
`Linked existing Stripe customer ${stripeCustomer.id} to user ${user.id}`,
);
return;
}
// Create new Stripe customer
let extraCreateParams: Partial<Stripe.CustomerCreateParams> =
{};
if (options.getCustomerCreateParams) {
extraCreateParams = await options.getCustomerCreateParams(
user,
ctx,
);
}
const params: Stripe.CustomerCreateParams = defu(
{
email: user.email,
name: user.name,
metadata: {
userId: user.id,
},
const params: Stripe.CustomerCreateParams = defu(
{
email: user.email,
name: user.name,
metadata: {
userId: user.id,
},
},
extraCreateParams,
);
stripeCustomer =
await client.customers.create(params);
await ctx.context.internalAdapter.updateUser(
user.id,
{
stripeCustomerId: stripeCustomer.id,
},
);
await options.onCustomerCreate?.(
{
stripeCustomer,
user: {
...user,
stripeCustomerId: stripeCustomer.id,
},
},
ctx,
);
ctx.context.logger.info(
`Created new Stripe customer ${stripeCustomer.id} for user ${user.id}`,
);
} catch (e: any) {
ctx.context.logger.error(
`Failed to create or link Stripe customer: ${e.message}`,
e,
);
}
},
extraCreateParams,
);
stripeCustomer = await client.customers.create(params);
await ctx.context.internalAdapter.updateUser(user.id, {
stripeCustomerId: stripeCustomer.id,
});
await options.onCustomerCreate?.(
{
stripeCustomer,
user: {
...user,
stripeCustomerId: stripeCustomer.id,
},
},
ctx,
);
ctx.context.logger.info(
`Created new Stripe customer ${stripeCustomer.id} for user ${user.id}`,
);
} catch (e: any) {
ctx.context.logger.error(
`Failed to create or link Stripe customer: ${e.message}`,
e,
);
},
}
},
},
: {}),
// Always register update hook to sync email changes
update: {
async after(user, ctx) {
if (!ctx) return;
async after(user: User & WithStripeCustomerId, ctx) {
if (
!ctx ||
!user.stripeCustomerId // Only proceed if user has a Stripe customer ID
) {
return;
}
try {
// Cast user to include stripeCustomerId (added by the stripe plugin schema)
const userWithStripe = user as typeof user & {
stripeCustomerId?: string;
};
// Only proceed if user has a Stripe customer ID
if (!userWithStripe.stripeCustomerId) return;
// Get the user from the database to check if email actually changed
// The 'user' parameter here is the freshly updated user
// We need to check if the Stripe customer's email matches
const stripeCustomer = await client.customers.retrieve(
userWithStripe.stripeCustomerId,
user.stripeCustomerId,
);
// Check if customer was deleted
if (stripeCustomer.deleted) {
ctx.context.logger.warn(
`Stripe customer ${userWithStripe.stripeCustomerId} was deleted, cannot update email`,
`Stripe customer ${user.stripeCustomerId} was deleted, cannot update email`,
);
return;
}
// If Stripe customer email doesn't match the user's current email, update it
if (stripeCustomer.email !== user.email) {
await client.customers.update(
userWithStripe.stripeCustomerId,
{
email: user.email,
},
);
await client.customers.update(user.stripeCustomerId, {
email: user.email,
});
ctx.context.logger.info(
`Updated Stripe customer email from ${stripeCustomer.email} to ${user.email}`,
);
@@ -1449,4 +1854,4 @@ export type StripePlugin<O extends StripeOptions> = ReturnType<
typeof stripe<O>
>;
export type { Subscription, StripePlan };
export type { OrganizationWithStripe, Subscription, StripePlan };

View File

@@ -65,6 +65,21 @@ export const user = {
},
} satisfies BetterAuthPluginDBSchema;
export const organization = {
organization: {
fields: {
stripeCustomerId: {
type: "string",
required: false,
},
stripeAdminUserId: {
type: "string",
required: false,
},
},
},
} satisfies BetterAuthPluginDBSchema;
export const getSchema = (options: StripeOptions) => {
let baseSchema = {};
@@ -79,6 +94,13 @@ export const getSchema = (options: StripeOptions) => {
};
}
if (options.enableOrganizationCustomer) {
baseSchema = {
...baseSchema,
...organization,
};
}
if (
options.schema &&
!options.subscription?.enabled &&

View File

@@ -1,9 +1,24 @@
import type { GenericEndpointContext } from "@better-auth/core";
import type { Session, User } from "@better-auth/core/db";
import type { Organization } from "better-auth/plugins";
import type { InferOptionSchema } from "better-auth/types";
import type Stripe from "stripe";
import type { subscriptions, user } from "./schema";
export type WithStripeCustomerId = {
stripeCustomerId?: string;
};
export type WithStripeAdminUserId = {
stripeAdminUserId?: string;
};
export type WithActiveOrganizationId = {
activeOrganizationId?: string;
};
export type OrganizationWithStripe = Organization &
WithStripeCustomerId &
WithStripeAdminUserId;
export type StripePlan = {
/**
* Monthly price id
@@ -306,6 +321,10 @@ export interface StripeOptions {
* Enable customer creation when a user signs up
*/
createCustomerOnSignUp?: boolean | undefined;
/**
* Enable organization customer support
*/
enableOrganizationCustomer?: boolean | undefined;
/**
* A callback to run after a customer has been created
* @param customer - Customer Data
@@ -316,7 +335,23 @@ export interface StripeOptions {
| ((
data: {
stripeCustomer: Stripe.Customer;
user: User & { stripeCustomerId: string };
user: User & WithStripeCustomerId;
},
ctx: GenericEndpointContext,
) => Promise<void>)
| undefined;
/**
* A callback to run after an organization customer has been created
* @param data - Organization customer data
* @param ctx - Context
* @returns
*/
onOrganizationCustomerCreate?:
| ((
data: {
stripeCustomer: Stripe.Customer;
organization: OrganizationWithStripe;
adminUser: User;
},
ctx: GenericEndpointContext,
) => Promise<void>)

View File

@@ -1,6 +1,63 @@
import type { GenericEndpointContext } from "@better-auth/core";
import type {
BetterAuthPlugin,
GenericEndpointContext,
} from "@better-auth/core";
import type { Session, User } from "better-auth";
import type { OrganizationOptions } from "better-auth/plugins/organization";
import type Stripe from "stripe";
import type { StripeOptions } from "./types";
import type { StripeOptions, WithActiveOrganizationId } from "./types";
/**
* Type guard to check if a plugin is an organization plugin with valid options
* @internal
*/
function isOrganizationPlugin(
plugin: BetterAuthPlugin,
): plugin is BetterAuthPlugin & { options: OrganizationOptions } {
// Organization plugin always has options object (even if empty)
// We don't check for organizationHooks because Stripe plugin will inject it
return (
plugin.id === "organization" &&
!!plugin.options &&
typeof plugin.options === "object"
);
}
/**
* Get organization plugin from plugins array with type safety
* Returns null if plugin not found or doesn't have valid options
*/
export function getOrganizationPlugin(
plugins: BetterAuthPlugin[] | undefined,
): (BetterAuthPlugin & { options: OrganizationOptions }) | null {
if (!plugins) return null;
const orgPlugin = plugins.find((p) => p.id === "organization");
if (!orgPlugin) return null;
if (!isOrganizationPlugin(orgPlugin)) {
return null;
}
return orgPlugin;
}
/**
* Get reference ID from request body, session, or user ID
*
* Priority: ctx.body.referenceId -> session.activeOrganizationId -> user.id
*/
export function getReferenceId(
bodyReferenceId: string | undefined,
session: {
user: User;
session: Session & WithActiveOrganizationId;
},
): string {
return (
bodyReferenceId || session.session.activeOrganizationId || session.user.id
);
}
export function getUrl(ctx: GenericEndpointContext, url: string) {
if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(url)) {