mirror of
https://github.com/better-auth/better-auth.git
synced 2026-06-05 13:56:30 -05:00
feat(stripe): stripe customer for organization
Co-authored-by: Cmion <cmion@users.noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user