@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@better-auth/stripe",
3
3
  "author": "Bereket Engida",
4
- "version": "1.5.0-beta.1",
4
+ "version": "1.5.0-beta.3",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
7
7
  "types": "dist/index.d.mts",
@@ -50,15 +50,15 @@
50
50
  },
51
51
  "peerDependencies": {
52
52
  "stripe": "^18 || ^19 || ^20",
53
- "@better-auth/core": "1.5.0-beta.1",
54
- "better-auth": "1.5.0-beta.1"
53
+ "@better-auth/core": "1.5.0-beta.3",
54
+ "better-auth": "1.5.0-beta.3"
55
55
  },
56
56
  "devDependencies": {
57
- "better-call": "1.1.7",
57
+ "better-call": "1.1.8",
58
58
  "stripe": "^20.0.0",
59
59
  "tsdown": "^0.17.2",
60
- "@better-auth/core": "1.5.0-beta.1",
61
- "better-auth": "1.5.0-beta.1"
60
+ "@better-auth/core": "1.5.0-beta.3",
61
+ "better-auth": "1.5.0-beta.3"
62
62
  },
63
63
  "scripts": {
64
64
  "test": "vitest",
package/src/client.ts CHANGED
@@ -30,6 +30,7 @@ export const stripeClient = <
30
30
  >,
31
31
  pathMethods: {
32
32
  "/subscription/billing-portal": "POST",
33
+ "/subscription/restore": "POST",
33
34
  },
34
35
  $ERROR_CODES: STRIPE_ERROR_CODES,
35
36
  } satisfies BetterAuthClientPlugin;
@@ -1,14 +1,30 @@
1
1
  import { defineErrorCodes } from "@better-auth/core/utils";
2
2
 
3
3
  export const STRIPE_ERROR_CODES = defineErrorCodes({
4
+ UNAUTHORIZED: "Unauthorized access",
5
+ INVALID_REQUEST_BODY: "Invalid request body",
4
6
  SUBSCRIPTION_NOT_FOUND: "Subscription not found",
5
7
  SUBSCRIPTION_PLAN_NOT_FOUND: "Subscription plan not found",
6
8
  ALREADY_SUBSCRIBED_PLAN: "You're already subscribed to this plan",
9
+ REFERENCE_ID_NOT_ALLOWED: "Reference id is not allowed",
10
+ CUSTOMER_NOT_FOUND: "Stripe customer not found for this user",
7
11
  UNABLE_TO_CREATE_CUSTOMER: "Unable to create customer",
12
+ UNABLE_TO_CREATE_BILLING_PORTAL: "Unable to create billing portal session",
13
+ STRIPE_SIGNATURE_NOT_FOUND: "Stripe signature not found",
14
+ STRIPE_WEBHOOK_SECRET_NOT_FOUND: "Stripe webhook secret not found",
15
+ STRIPE_WEBHOOK_ERROR: "Stripe webhook error",
16
+ FAILED_TO_CONSTRUCT_STRIPE_EVENT: "Failed to construct Stripe event",
8
17
  FAILED_TO_FETCH_PLANS: "Failed to fetch plans",
9
18
  EMAIL_VERIFICATION_REQUIRED:
10
19
  "Email verification is required before you can subscribe to a plan",
11
20
  SUBSCRIPTION_NOT_ACTIVE: "Subscription is not active",
12
21
  SUBSCRIPTION_NOT_SCHEDULED_FOR_CANCELLATION:
13
22
  "Subscription is not scheduled for cancellation",
23
+ ORGANIZATION_NOT_FOUND: "Organization not found",
24
+ ORGANIZATION_SUBSCRIPTION_NOT_ENABLED:
25
+ "Organization subscription is not enabled",
26
+ ORGANIZATION_HAS_ACTIVE_SUBSCRIPTION:
27
+ "Cannot delete organization with active subscription",
28
+ ORGANIZATION_REFERENCE_ID_REQUIRED:
29
+ "Reference ID is required. Provide referenceId or set activeOrganizationId in session",
14
30
  });
package/src/hooks.ts CHANGED
@@ -1,8 +1,40 @@
1
- import type { GenericEndpointContext } from "better-auth";
2
- import { logger } from "better-auth";
1
+ import type { GenericEndpointContext } from "@better-auth/core";
2
+ import type { User } from "@better-auth/core/db";
3
+ import type { Organization } from "better-auth/plugins/organization";
3
4
  import type Stripe from "stripe";
4
- import type { InputSubscription, StripeOptions, Subscription } from "./types";
5
- import { getPlanByPriceInfo } from "./utils";
5
+ import type { CustomerType, StripeOptions, Subscription } from "./types";
6
+ import {
7
+ getPlanByPriceInfo,
8
+ isActiveOrTrialing,
9
+ isPendingCancel,
10
+ isStripePendingCancel,
11
+ } from "./utils";
12
+
13
+ /**
14
+ * Find organization or user by stripeCustomerId.
15
+ * @internal
16
+ */
17
+ async function findReferenceByStripeCustomerId(
18
+ ctx: GenericEndpointContext,
19
+ options: StripeOptions,
20
+ stripeCustomerId: string,
21
+ ): Promise<{ customerType: CustomerType; referenceId: string } | null> {
22
+ if (options.organization?.enabled) {
23
+ const org = await ctx.context.adapter.findOne<Organization>({
24
+ model: "organization",
25
+ where: [{ field: "stripeCustomerId", value: stripeCustomerId }],
26
+ });
27
+ if (org) return { customerType: "organization", referenceId: org.id };
28
+ }
29
+
30
+ const user = await ctx.context.adapter.findOne<User>({
31
+ model: "user",
32
+ where: [{ field: "stripeCustomerId", value: stripeCustomerId }],
33
+ });
34
+ if (user) return { customerType: "user", referenceId: user.id };
35
+
36
+ return null;
37
+ }
6
38
 
7
39
  export async function onCheckoutSessionCompleted(
8
40
  ctx: GenericEndpointContext,
@@ -18,8 +50,16 @@ export async function onCheckoutSessionCompleted(
18
50
  const subscription = await client.subscriptions.retrieve(
19
51
  checkoutSession.subscription as string,
20
52
  );
21
- const priceId = subscription.items.data[0]?.price.id;
22
- const priceLookupKey = subscription.items.data[0]?.price.lookup_key || null;
53
+ const subscriptionItem = subscription.items.data[0];
54
+ if (!subscriptionItem) {
55
+ ctx.context.logger.warn(
56
+ `Stripe webhook warning: Subscription ${subscription.id} has no items`,
57
+ );
58
+ return;
59
+ }
60
+
61
+ const priceId = subscriptionItem.price.id;
62
+ const priceLookupKey = subscriptionItem.price.lookup_key;
23
63
  const plan = await getPlanByPriceInfo(
24
64
  options,
25
65
  priceId as string,
@@ -30,7 +70,7 @@ export async function onCheckoutSessionCompleted(
30
70
  checkoutSession?.client_reference_id ||
31
71
  checkoutSession?.metadata?.referenceId;
32
72
  const subscriptionId = checkoutSession?.metadata?.subscriptionId;
33
- const seats = subscription.items.data[0]!.quantity;
73
+ const seats = subscriptionItem.quantity;
34
74
  if (referenceId && subscriptionId) {
35
75
  const trial =
36
76
  subscription.trial_start && subscription.trial_end
@@ -40,30 +80,35 @@ export async function onCheckoutSessionCompleted(
40
80
  }
41
81
  : {};
42
82
 
43
- let dbSubscription =
44
- await ctx.context.adapter.update<InputSubscription>({
45
- model: "subscription",
46
- update: {
47
- plan: plan.name.toLowerCase(),
48
- status: subscription.status,
49
- updatedAt: new Date(),
50
- periodStart: new Date(
51
- subscription.items.data[0]!.current_period_start * 1000,
52
- ),
53
- periodEnd: new Date(
54
- subscription.items.data[0]!.current_period_end * 1000,
55
- ),
56
- stripeSubscriptionId: checkoutSession.subscription as string,
57
- seats,
58
- ...trial,
83
+ let dbSubscription = await ctx.context.adapter.update<Subscription>({
84
+ model: "subscription",
85
+ update: {
86
+ plan: plan.name.toLowerCase(),
87
+ status: subscription.status,
88
+ updatedAt: new Date(),
89
+ periodStart: new Date(subscriptionItem.current_period_start * 1000),
90
+ periodEnd: new Date(subscriptionItem.current_period_end * 1000),
91
+ stripeSubscriptionId: checkoutSession.subscription as string,
92
+ cancelAtPeriodEnd: subscription.cancel_at_period_end,
93
+ cancelAt: subscription.cancel_at
94
+ ? new Date(subscription.cancel_at * 1000)
95
+ : null,
96
+ canceledAt: subscription.canceled_at
97
+ ? new Date(subscription.canceled_at * 1000)
98
+ : null,
99
+ endedAt: subscription.ended_at
100
+ ? new Date(subscription.ended_at * 1000)
101
+ : null,
102
+ seats: seats,
103
+ ...trial,
104
+ },
105
+ where: [
106
+ {
107
+ field: "id",
108
+ value: subscriptionId,
59
109
  },
60
- where: [
61
- {
62
- field: "id",
63
- value: subscriptionId,
64
- },
65
- ],
66
- });
110
+ ],
111
+ });
67
112
 
68
113
  if (trial.trialStart && plan.freeTrial?.onTrialStart) {
69
114
  await plan.freeTrial.onTrialStart(dbSubscription as Subscription);
@@ -93,7 +138,118 @@ export async function onCheckoutSessionCompleted(
93
138
  }
94
139
  }
95
140
  } catch (e: any) {
96
- logger.error(`Stripe webhook failed. Error: ${e.message}`);
141
+ ctx.context.logger.error(`Stripe webhook failed. Error: ${e.message}`);
142
+ }
143
+ }
144
+
145
+ export async function onSubscriptionCreated(
146
+ ctx: GenericEndpointContext,
147
+ options: StripeOptions,
148
+ event: Stripe.Event,
149
+ ) {
150
+ try {
151
+ if (!options.subscription?.enabled) {
152
+ return;
153
+ }
154
+
155
+ const subscriptionCreated = event.data.object as Stripe.Subscription;
156
+ const stripeCustomerId = subscriptionCreated.customer?.toString();
157
+ if (!stripeCustomerId) {
158
+ ctx.context.logger.warn(
159
+ `Stripe webhook warning: customer.subscription.created event received without customer ID`,
160
+ );
161
+ return;
162
+ }
163
+
164
+ // Check if subscription already exists in database
165
+ const subscriptionId = subscriptionCreated.metadata?.subscriptionId;
166
+ const existingSubscription =
167
+ await ctx.context.adapter.findOne<Subscription>({
168
+ model: "subscription",
169
+ where: subscriptionId
170
+ ? [{ field: "id", value: subscriptionId }]
171
+ : [{ field: "stripeSubscriptionId", value: subscriptionCreated.id }], // Probably won't match since it's not set yet
172
+ });
173
+ if (existingSubscription) {
174
+ ctx.context.logger.info(
175
+ `Stripe webhook: Subscription already exists in database (id: ${existingSubscription.id}), skipping creation`,
176
+ );
177
+ return;
178
+ }
179
+
180
+ // Find reference
181
+ const reference = await findReferenceByStripeCustomerId(
182
+ ctx,
183
+ options,
184
+ stripeCustomerId,
185
+ );
186
+ if (!reference) {
187
+ ctx.context.logger.warn(
188
+ `Stripe webhook warning: No user or organization found with stripeCustomerId: ${stripeCustomerId}`,
189
+ );
190
+ return;
191
+ }
192
+ const { referenceId, customerType } = reference;
193
+
194
+ const subscriptionItem = subscriptionCreated.items.data[0];
195
+ if (!subscriptionItem) {
196
+ ctx.context.logger.warn(
197
+ `Stripe webhook warning: Subscription ${subscriptionCreated.id} has no items`,
198
+ );
199
+ return;
200
+ }
201
+
202
+ const priceId = subscriptionItem.price.id;
203
+ const priceLookupKey = subscriptionItem.price.lookup_key || null;
204
+ const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey);
205
+ if (!plan) {
206
+ ctx.context.logger.warn(
207
+ `Stripe webhook warning: No matching plan found for priceId: ${priceId}`,
208
+ );
209
+ return;
210
+ }
211
+
212
+ const seats = subscriptionItem.quantity;
213
+ const periodStart = new Date(subscriptionItem.current_period_start * 1000);
214
+ const periodEnd = new Date(subscriptionItem.current_period_end * 1000);
215
+
216
+ const trial =
217
+ subscriptionCreated.trial_start && subscriptionCreated.trial_end
218
+ ? {
219
+ trialStart: new Date(subscriptionCreated.trial_start * 1000),
220
+ trialEnd: new Date(subscriptionCreated.trial_end * 1000),
221
+ }
222
+ : {};
223
+
224
+ // Create the subscription in the database
225
+ const newSubscription = await ctx.context.adapter.create<Subscription>({
226
+ model: "subscription",
227
+ data: {
228
+ referenceId,
229
+ stripeCustomerId,
230
+ stripeSubscriptionId: subscriptionCreated.id,
231
+ status: subscriptionCreated.status,
232
+ plan: plan.name.toLowerCase(),
233
+ periodStart,
234
+ periodEnd,
235
+ seats,
236
+ ...(plan.limits ? { limits: plan.limits } : {}),
237
+ ...trial,
238
+ },
239
+ });
240
+
241
+ ctx.context.logger.info(
242
+ `Stripe webhook: Created subscription ${subscriptionCreated.id} for ${customerType} ${referenceId} from dashboard`,
243
+ );
244
+
245
+ await options.subscription.onSubscriptionCreated?.({
246
+ event,
247
+ subscription: newSubscription,
248
+ stripeSubscription: subscriptionCreated,
249
+ plan,
250
+ });
251
+ } catch (error: any) {
252
+ ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
97
253
  }
98
254
  }
99
255
 
@@ -107,9 +263,16 @@ export async function onSubscriptionUpdated(
107
263
  return;
108
264
  }
109
265
  const subscriptionUpdated = event.data.object as Stripe.Subscription;
110
- const priceId = subscriptionUpdated.items.data[0]!.price.id;
111
- const priceLookupKey =
112
- subscriptionUpdated.items.data[0]!.price.lookup_key || null;
266
+ const subscriptionItem = subscriptionUpdated.items.data[0];
267
+ if (!subscriptionItem) {
268
+ ctx.context.logger.warn(
269
+ `Stripe webhook warning: Subscription ${subscriptionUpdated.id} has no items`,
270
+ );
271
+ return;
272
+ }
273
+
274
+ const priceId = subscriptionItem.price.id;
275
+ const priceLookupKey = subscriptionItem.price.lookup_key;
113
276
  const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey);
114
277
 
115
278
  const subscriptionId = subscriptionUpdated.metadata?.subscriptionId;
@@ -126,12 +289,11 @@ export async function onSubscriptionUpdated(
126
289
  where: [{ field: "stripeCustomerId", value: customerId }],
127
290
  });
128
291
  if (subs.length > 1) {
129
- const activeSub = subs.find(
130
- (sub: Subscription) =>
131
- sub.status === "active" || sub.status === "trialing",
292
+ const activeSub = subs.find((sub: Subscription) =>
293
+ isActiveOrTrialing(sub),
132
294
  );
133
295
  if (!activeSub) {
134
- logger.warn(
296
+ ctx.context.logger.warn(
135
297
  `Stripe webhook error: Multiple subscriptions found for customerId: ${customerId} and no active subscription is found`,
136
298
  );
137
299
  return;
@@ -142,7 +304,6 @@ export async function onSubscriptionUpdated(
142
304
  }
143
305
  }
144
306
 
145
- const seats = subscriptionUpdated.items.data[0]!.quantity;
146
307
  const updatedSubscription = await ctx.context.adapter.update<Subscription>({
147
308
  model: "subscription",
148
309
  update: {
@@ -154,14 +315,19 @@ export async function onSubscriptionUpdated(
154
315
  : {}),
155
316
  updatedAt: new Date(),
156
317
  status: subscriptionUpdated.status,
157
- periodStart: new Date(
158
- subscriptionUpdated.items.data[0]!.current_period_start * 1000,
159
- ),
160
- periodEnd: new Date(
161
- subscriptionUpdated.items.data[0]!.current_period_end * 1000,
162
- ),
318
+ periodStart: new Date(subscriptionItem.current_period_start * 1000),
319
+ periodEnd: new Date(subscriptionItem.current_period_end * 1000),
163
320
  cancelAtPeriodEnd: subscriptionUpdated.cancel_at_period_end,
164
- seats,
321
+ cancelAt: subscriptionUpdated.cancel_at
322
+ ? new Date(subscriptionUpdated.cancel_at * 1000)
323
+ : null,
324
+ canceledAt: subscriptionUpdated.canceled_at
325
+ ? new Date(subscriptionUpdated.canceled_at * 1000)
326
+ : null,
327
+ endedAt: subscriptionUpdated.ended_at
328
+ ? new Date(subscriptionUpdated.ended_at * 1000)
329
+ : null,
330
+ seats: subscriptionItem.quantity,
165
331
  stripeSubscriptionId: subscriptionUpdated.id,
166
332
  },
167
333
  where: [
@@ -171,11 +337,11 @@ export async function onSubscriptionUpdated(
171
337
  },
172
338
  ],
173
339
  });
174
- const subscriptionCanceled =
340
+ const isNewCancellation =
175
341
  subscriptionUpdated.status === "active" &&
176
- subscriptionUpdated.cancel_at_period_end &&
177
- !subscription.cancelAtPeriodEnd; //if this is true, it means the subscription was canceled before the event was triggered
178
- if (subscriptionCanceled) {
342
+ isStripePendingCancel(subscriptionUpdated) &&
343
+ !isPendingCancel(subscription);
344
+ if (isNewCancellation) {
179
345
  await options.subscription.onSubscriptionCancel?.({
180
346
  subscription,
181
347
  cancellationDetails:
@@ -205,7 +371,7 @@ export async function onSubscriptionUpdated(
205
371
  }
206
372
  }
207
373
  } catch (error: any) {
208
- logger.error(`Stripe webhook failed. Error: ${error}`);
374
+ ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
209
375
  }
210
376
  }
211
377
 
@@ -241,6 +407,16 @@ export async function onSubscriptionDeleted(
241
407
  update: {
242
408
  status: "canceled",
243
409
  updatedAt: new Date(),
410
+ cancelAtPeriodEnd: subscriptionDeleted.cancel_at_period_end,
411
+ cancelAt: subscriptionDeleted.cancel_at
412
+ ? new Date(subscriptionDeleted.cancel_at * 1000)
413
+ : null,
414
+ canceledAt: subscriptionDeleted.canceled_at
415
+ ? new Date(subscriptionDeleted.canceled_at * 1000)
416
+ : null,
417
+ endedAt: subscriptionDeleted.ended_at
418
+ ? new Date(subscriptionDeleted.ended_at * 1000)
419
+ : null,
244
420
  },
245
421
  });
246
422
  await options.subscription.onSubscriptionDeleted?.({
@@ -249,11 +425,11 @@ export async function onSubscriptionDeleted(
249
425
  subscription,
250
426
  });
251
427
  } else {
252
- logger.warn(
428
+ ctx.context.logger.warn(
253
429
  `Stripe webhook error: Subscription not found for subscriptionId: ${subscriptionId}`,
254
430
  );
255
431
  }
256
432
  } catch (error: any) {
257
- logger.error(`Stripe webhook failed. Error: ${error}`);
433
+ ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
258
434
  }
259
435
  }