@better-auth/stripe 1.5.0-beta.1 → 1.5.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { defineErrorCodes } from "@better-auth/core/utils";
2
- import type { BetterAuthPlugin } from "better-auth";
1
+ import type { BetterAuthPlugin, User } from "better-auth";
2
+ import { APIError } from "better-auth";
3
+ import type { Organization } from "better-auth/plugins/organization";
3
4
  import { defu } from "defu";
4
5
  import type Stripe from "stripe";
6
+ import { STRIPE_ERROR_CODES } from "./error-codes";
5
7
  import {
6
8
  cancelSubscription,
7
9
  cancelSubscriptionCallback,
@@ -18,20 +20,18 @@ import type {
18
20
  StripePlan,
19
21
  Subscription,
20
22
  SubscriptionOptions,
23
+ WithStripeCustomerId,
21
24
  } from "./types";
25
+ import { escapeStripeSearchValue } from "./utils";
22
26
 
23
- const STRIPE_ERROR_CODES = defineErrorCodes({
24
- SUBSCRIPTION_NOT_FOUND: "Subscription not found",
25
- SUBSCRIPTION_PLAN_NOT_FOUND: "Subscription plan not found",
26
- ALREADY_SUBSCRIBED_PLAN: "You're already subscribed to this plan",
27
- UNABLE_TO_CREATE_CUSTOMER: "Unable to create customer",
28
- FAILED_TO_FETCH_PLANS: "Failed to fetch plans",
29
- EMAIL_VERIFICATION_REQUIRED:
30
- "Email verification is required before you can subscribe to a plan",
31
- SUBSCRIPTION_NOT_ACTIVE: "Subscription is not active",
32
- SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION:
33
- "Subscription is not scheduled for cancellation",
34
- });
27
+ declare module "@better-auth/core" {
28
+ // biome-ignore lint/correctness/noUnusedVariables: Auth and Context need to be same as declared in the module
29
+ interface BetterAuthPluginRegistry<Auth, Context> {
30
+ stripe: {
31
+ creator: typeof stripe;
32
+ };
33
+ }
34
+ }
35
35
 
36
36
  export const stripe = <O extends StripeOptions>(options: O) => {
37
37
  const client = options.stripeClient;
@@ -59,31 +59,134 @@ export const stripe = <O extends StripeOptions>(options: O) => {
59
59
  : {}),
60
60
  },
61
61
  init(ctx) {
62
+ if (options.organization?.enabled) {
63
+ const orgPlugin = ctx.getPlugin("organization");
64
+ if (!orgPlugin) {
65
+ ctx.logger.error(`Organization plugin not found`);
66
+ return;
67
+ }
68
+
69
+ const existingHooks = orgPlugin.options.organizationHooks ?? {};
70
+
71
+ /**
72
+ * Sync organization name to Stripe customer
73
+ */
74
+ const afterUpdateStripeOrg = async (data: {
75
+ organization: (Organization & WithStripeCustomerId) | null;
76
+ user: User;
77
+ }) => {
78
+ const { organization } = data;
79
+ if (!organization?.stripeCustomerId) return;
80
+
81
+ try {
82
+ const stripeCustomer = await client.customers.retrieve(
83
+ organization.stripeCustomerId,
84
+ );
85
+
86
+ if (stripeCustomer.deleted) {
87
+ ctx.logger.warn(
88
+ `Stripe customer ${organization.stripeCustomerId} was deleted`,
89
+ );
90
+ return;
91
+ }
92
+
93
+ // Update Stripe customer if name changed
94
+ if (organization.name !== stripeCustomer.name) {
95
+ await client.customers.update(organization.stripeCustomerId, {
96
+ name: organization.name,
97
+ });
98
+ ctx.logger.info(
99
+ `Synced organization name to Stripe: "${stripeCustomer.name}" → "${organization.name}"`,
100
+ );
101
+ }
102
+ } catch (e: any) {
103
+ ctx.logger.error(
104
+ `Failed to sync organization to Stripe: ${e.message}`,
105
+ );
106
+ }
107
+ };
108
+
109
+ /**
110
+ * Block deletion if organization has active subscriptions
111
+ */
112
+ const beforeDeleteStripeOrg = async (data: {
113
+ organization: Organization & WithStripeCustomerId;
114
+ user: User;
115
+ }) => {
116
+ const { organization } = data;
117
+ if (!organization.stripeCustomerId) return;
118
+
119
+ try {
120
+ // Check if organization has any active subscriptions
121
+ const subscriptions = await client.subscriptions.list({
122
+ customer: organization.stripeCustomerId,
123
+ status: "all",
124
+ limit: 100, // 1 ~ 100
125
+ });
126
+ for (const sub of subscriptions.data) {
127
+ if (
128
+ sub.status !== "canceled" &&
129
+ sub.status !== "incomplete" &&
130
+ sub.status !== "incomplete_expired"
131
+ ) {
132
+ throw APIError.from(
133
+ "BAD_REQUEST",
134
+ STRIPE_ERROR_CODES.ORGANIZATION_HAS_ACTIVE_SUBSCRIPTION,
135
+ );
136
+ }
137
+ }
138
+ } catch (error: any) {
139
+ if (error instanceof APIError) {
140
+ throw error;
141
+ }
142
+ ctx.logger.error(
143
+ `Failed to check organization subscriptions: ${error.message}`,
144
+ );
145
+ throw error;
146
+ }
147
+ };
148
+
149
+ orgPlugin.options.organizationHooks = {
150
+ ...existingHooks,
151
+ afterUpdateOrganization: existingHooks.afterUpdateOrganization
152
+ ? async (data) => {
153
+ await existingHooks.afterUpdateOrganization!(data);
154
+ await afterUpdateStripeOrg(data);
155
+ }
156
+ : afterUpdateStripeOrg,
157
+ beforeDeleteOrganization: existingHooks.beforeDeleteOrganization
158
+ ? async (data) => {
159
+ await existingHooks.beforeDeleteOrganization!(data);
160
+ await beforeDeleteStripeOrg(data);
161
+ }
162
+ : beforeDeleteStripeOrg,
163
+ };
164
+ }
165
+
62
166
  return {
63
167
  options: {
64
168
  databaseHooks: {
65
169
  user: {
66
170
  create: {
67
- async after(user, ctx) {
68
- if (!ctx || !options.createCustomerOnSignUp) return;
171
+ async after(user: User & WithStripeCustomerId, ctx) {
172
+ if (
173
+ !ctx ||
174
+ !options.createCustomerOnSignUp ||
175
+ user.stripeCustomerId // Skip if user already has a Stripe customer ID
176
+ ) {
177
+ return;
178
+ }
69
179
 
70
180
  try {
71
- const userWithStripe = user as typeof user & {
72
- stripeCustomerId?: string;
73
- };
74
-
75
- // Skip if user already has a Stripe customer ID
76
- if (userWithStripe.stripeCustomerId) return;
77
-
78
- // Check if customer already exists in Stripe by email
79
- const existingCustomers = await client.customers.list({
80
- email: user.email,
181
+ // Check if user customer already exists in Stripe by email
182
+ const existingCustomers = await client.customers.search({
183
+ query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["customerType"]:"organization"`,
81
184
  limit: 1,
82
185
  });
83
186
 
84
187
  let stripeCustomer = existingCustomers.data[0];
85
188
 
86
- // If customer exists, link it to prevent duplicate creation
189
+ // If user customer exists, link it to prevent duplicate creation
87
190
  if (stripeCustomer) {
88
191
  await ctx.context.internalAdapter.updateUser(user.id, {
89
192
  stripeCustomerId: stripeCustomer.id,
@@ -120,6 +223,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
120
223
  name: user.name,
121
224
  metadata: {
122
225
  userId: user.id,
226
+ customerType: "user",
123
227
  },
124
228
  },
125
229
  extraCreateParams,
@@ -150,41 +254,34 @@ export const stripe = <O extends StripeOptions>(options: O) => {
150
254
  },
151
255
  },
152
256
  update: {
153
- async after(user, ctx) {
154
- if (!ctx) return;
257
+ async after(user: User & WithStripeCustomerId, ctx) {
258
+ if (
259
+ !ctx ||
260
+ !user.stripeCustomerId // Only proceed if user has a Stripe customer ID
261
+ )
262
+ return;
155
263
 
156
264
  try {
157
- // Cast user to include stripeCustomerId (added by the stripe plugin schema)
158
- const userWithStripe = user as typeof user & {
159
- stripeCustomerId?: string;
160
- };
161
-
162
- // Only proceed if user has a Stripe customer ID
163
- if (!userWithStripe.stripeCustomerId) return;
164
-
165
265
  // Get the user from the database to check if email actually changed
166
266
  // The 'user' parameter here is the freshly updated user
167
267
  // We need to check if the Stripe customer's email matches
168
268
  const stripeCustomer = await client.customers.retrieve(
169
- userWithStripe.stripeCustomerId,
269
+ user.stripeCustomerId,
170
270
  );
171
271
 
172
272
  // Check if customer was deleted
173
273
  if (stripeCustomer.deleted) {
174
274
  ctx.context.logger.warn(
175
- `Stripe customer ${userWithStripe.stripeCustomerId} was deleted, cannot update email`,
275
+ `Stripe customer ${user.stripeCustomerId} was deleted, cannot update email`,
176
276
  );
177
277
  return;
178
278
  }
179
279
 
180
280
  // If Stripe customer email doesn't match the user's current email, update it
181
281
  if (stripeCustomer.email !== user.email) {
182
- await client.customers.update(
183
- userWithStripe.stripeCustomerId,
184
- {
185
- email: user.email,
186
- },
187
- );
282
+ await client.customers.update(user.stripeCustomerId, {
283
+ email: user.email,
284
+ });
188
285
  ctx.context.logger.info(
189
286
  `Updated Stripe customer email from ${stripeCustomer.email} to ${user.email}`,
190
287
  );
package/src/middleware.ts CHANGED
@@ -1,58 +1,104 @@
1
1
  import { createAuthMiddleware } from "@better-auth/core/api";
2
- import { logger } from "better-auth";
3
- import { APIError } from "better-auth/api";
4
- import type { SubscriptionOptions } from "./types";
2
+ import { APIError } from "@better-auth/core/error";
3
+ import { sessionMiddleware } from "better-auth/api";
4
+ import { STRIPE_ERROR_CODES } from "./error-codes";
5
+ import type {
6
+ AuthorizeReferenceAction,
7
+ CustomerType,
8
+ StripeCtxSession,
9
+ SubscriptionOptions,
10
+ } from "./types";
11
+
12
+ export const stripeSessionMiddleware = createAuthMiddleware(
13
+ {
14
+ use: [sessionMiddleware],
15
+ },
16
+ async (ctx) => {
17
+ const session = ctx.context.session as StripeCtxSession;
18
+ return {
19
+ session,
20
+ };
21
+ },
22
+ );
5
23
 
6
24
  export const referenceMiddleware = (
7
25
  subscriptionOptions: SubscriptionOptions,
8
- action:
9
- | "upgrade-subscription"
10
- | "list-subscription"
11
- | "cancel-subscription"
12
- | "restore-subscription"
13
- | "billing-portal",
26
+ action: AuthorizeReferenceAction,
14
27
  ) =>
15
28
  createAuthMiddleware(async (ctx) => {
16
- const session = ctx.context.session;
17
- if (!session) {
18
- throw new APIError("UNAUTHORIZED");
29
+ const ctxSession = ctx.context.session as StripeCtxSession;
30
+ if (!ctxSession) {
31
+ throw APIError.from("UNAUTHORIZED", STRIPE_ERROR_CODES.UNAUTHORIZED);
32
+ }
33
+
34
+ const customerType: CustomerType =
35
+ ctx.body?.customerType || ctx.query?.customerType;
36
+ const explicitReferenceId = ctx.body?.referenceId || ctx.query?.referenceId;
37
+
38
+ if (customerType === "organization") {
39
+ // Organization subscriptions always require authorizeReference
40
+ if (!subscriptionOptions.authorizeReference) {
41
+ ctx.context.logger.error(
42
+ `Organization subscriptions require authorizeReference to be defined in your stripe plugin config.`,
43
+ );
44
+ throw APIError.from(
45
+ "BAD_REQUEST",
46
+ STRIPE_ERROR_CODES.ORGANIZATION_SUBSCRIPTION_NOT_ENABLED,
47
+ );
48
+ }
49
+
50
+ const referenceId =
51
+ explicitReferenceId || ctxSession.session.activeOrganizationId;
52
+ if (!referenceId) {
53
+ throw APIError.from(
54
+ "BAD_REQUEST",
55
+ STRIPE_ERROR_CODES.ORGANIZATION_REFERENCE_ID_REQUIRED,
56
+ );
57
+ }
58
+ const isAuthorized = await subscriptionOptions.authorizeReference(
59
+ {
60
+ user: ctxSession.user,
61
+ session: ctxSession.session,
62
+ referenceId,
63
+ action,
64
+ },
65
+ ctx,
66
+ );
67
+ if (!isAuthorized) {
68
+ throw APIError.from("UNAUTHORIZED", STRIPE_ERROR_CODES.UNAUTHORIZED);
69
+ }
70
+ return;
19
71
  }
20
- const referenceId =
21
- ctx.body?.referenceId || ctx.query?.referenceId || session.user.id;
22
-
23
- if (
24
- referenceId !== session.user.id &&
25
- !subscriptionOptions.authorizeReference
26
- ) {
27
- logger.error(
72
+
73
+ // User subscriptions - pass if no explicit referenceId
74
+ if (!explicitReferenceId) {
75
+ return;
76
+ }
77
+
78
+ // Pass if referenceId is user id
79
+ if (explicitReferenceId === ctxSession.user.id) {
80
+ return;
81
+ }
82
+
83
+ if (!subscriptionOptions.authorizeReference) {
84
+ ctx.context.logger.error(
28
85
  `Passing referenceId into a subscription action isn't allowed if subscription.authorizeReference isn't defined in your stripe plugin config.`,
29
86
  );
30
- throw new APIError("BAD_REQUEST", {
31
- message:
32
- "Reference id is not allowed. Read server logs for more details.",
33
- });
87
+ throw APIError.from(
88
+ "BAD_REQUEST",
89
+ STRIPE_ERROR_CODES.REFERENCE_ID_NOT_ALLOWED,
90
+ );
34
91
  }
35
- /**
36
- * if referenceId is the same as the active session user's id
37
- */
38
- const sameReference =
39
- ctx.query?.referenceId === session.user.id ||
40
- ctx.body?.referenceId === session.user.id;
41
- const isAuthorized =
42
- ctx.body?.referenceId || ctx.query?.referenceId
43
- ? (await subscriptionOptions.authorizeReference?.(
44
- {
45
- user: session.user,
46
- session: session.session,
47
- referenceId,
48
- action,
49
- },
50
- ctx,
51
- )) || sameReference
52
- : true;
92
+ const isAuthorized = await subscriptionOptions.authorizeReference(
93
+ {
94
+ user: ctxSession.user,
95
+ session: ctxSession.session,
96
+ referenceId: explicitReferenceId,
97
+ action,
98
+ },
99
+ ctx,
100
+ );
53
101
  if (!isAuthorized) {
54
- throw new APIError("UNAUTHORIZED", {
55
- message: "Unauthorized",
56
- });
102
+ throw APIError.from("UNAUTHORIZED", STRIPE_ERROR_CODES.UNAUTHORIZED);
57
103
  }
58
104
  });