From e3bb04948ddc32d2be8d2cda902de19e6b9adbc3 Mon Sep 17 00:00:00 2001 From: Taesu Date: Fri, 14 Nov 2025 08:35:32 +0900 Subject: [PATCH] feat(stripe): stripe customer for organization Co-authored-by: Cmion --- packages/stripe/src/error-codes.ts | 9 + packages/stripe/src/index.ts | 653 +++++++++++++++++++++++------ packages/stripe/src/schema.ts | 22 + packages/stripe/src/types.ts | 37 +- packages/stripe/src/utils.ts | 61 ++- 5 files changed, 655 insertions(+), 127 deletions(-) diff --git a/packages/stripe/src/error-codes.ts b/packages/stripe/src/error-codes.ts index f2f6e698ab..05c31f5709 100644 --- a/packages/stripe/src/error-codes.ts +++ b/packages/stripe/src/error-codes.ts @@ -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", }); diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts index 0f8b8c1790..1e345bafe1 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -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 = (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 = (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 = (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({ + 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({ + 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 = (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 = (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 = (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({ model: "subscription", @@ -861,8 +1019,11 @@ export const stripe = (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({ @@ -989,12 +1150,17 @@ export const stripe = (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({ model: "subscription", where: [ { field: "referenceId", - value: ctx.query?.referenceId || ctx.context.session.user.id, + value: referenceId, }, ], }); @@ -1027,7 +1193,7 @@ export const stripe = (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 = (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({ + 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({ @@ -1294,133 +1488,344 @@ export const stripe = (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 = + {}; + 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 = - {}; - 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 = ReturnType< typeof stripe >; -export type { Subscription, StripePlan }; +export type { OrganizationWithStripe, Subscription, StripePlan }; diff --git a/packages/stripe/src/schema.ts b/packages/stripe/src/schema.ts index 848caf2e69..6acd30d803 100644 --- a/packages/stripe/src/schema.ts +++ b/packages/stripe/src/schema.ts @@ -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 && diff --git a/packages/stripe/src/types.ts b/packages/stripe/src/types.ts index f2c89320d4..6969968425 100644 --- a/packages/stripe/src/types.ts +++ b/packages/stripe/src/types.ts @@ -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) + | 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) diff --git a/packages/stripe/src/utils.ts b/packages/stripe/src/utils.ts index 81abb3f321..6664897865 100644 --- a/packages/stripe/src/utils.ts +++ b/packages/stripe/src/utils.ts @@ -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)) {