@better-auth/stripe 1.5.0-beta.1 → 1.5.0-beta.10
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/.turbo/turbo-build.log +15 -10
- package/LICENSE.md +15 -12
- package/dist/client.d.mts +47 -66
- package/dist/client.mjs +7 -3
- package/dist/client.mjs.map +1 -0
- package/dist/error-codes-CMowBCzF.mjs +30 -0
- package/dist/error-codes-CMowBCzF.mjs.map +1 -0
- package/dist/{index-DpiQGYLJ.d.mts → index-D9Pr9jIc.d.mts} +371 -188
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +720 -293
- package/dist/index.mjs.map +1 -0
- package/package.json +10 -10
- package/src/client.ts +2 -1
- package/src/error-codes.ts +17 -1
- package/src/hooks.ts +238 -57
- package/src/index.ts +149 -49
- package/src/metadata.ts +94 -0
- package/src/middleware.ts +91 -45
- package/src/routes.ts +699 -368
- package/src/schema.ts +40 -4
- package/src/types.ts +105 -20
- package/src/utils.ts +36 -1
- package/test/stripe-organization.test.ts +1993 -0
- package/{src → test}/stripe.test.ts +3775 -1393
- package/tsdown.config.ts +1 -0
- package/CHANGELOG.md +0 -22
- package/dist/error-codes-qqooUh6R.mjs +0 -16
package/src/hooks.ts
CHANGED
|
@@ -1,8 +1,41 @@
|
|
|
1
|
-
import type { GenericEndpointContext } from "better-auth";
|
|
2
|
-
import {
|
|
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
|
|
5
|
-
import {
|
|
5
|
+
import { subscriptionMetadata } from "./metadata";
|
|
6
|
+
import type { CustomerType, StripeOptions, Subscription } from "./types";
|
|
7
|
+
import {
|
|
8
|
+
getPlanByPriceInfo,
|
|
9
|
+
isActiveOrTrialing,
|
|
10
|
+
isPendingCancel,
|
|
11
|
+
isStripePendingCancel,
|
|
12
|
+
} from "./utils";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Find organization or user by stripeCustomerId.
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
async function findReferenceByStripeCustomerId(
|
|
19
|
+
ctx: GenericEndpointContext,
|
|
20
|
+
options: StripeOptions,
|
|
21
|
+
stripeCustomerId: string,
|
|
22
|
+
): Promise<{ customerType: CustomerType; referenceId: string } | null> {
|
|
23
|
+
if (options.organization?.enabled) {
|
|
24
|
+
const org = await ctx.context.adapter.findOne<Organization>({
|
|
25
|
+
model: "organization",
|
|
26
|
+
where: [{ field: "stripeCustomerId", value: stripeCustomerId }],
|
|
27
|
+
});
|
|
28
|
+
if (org) return { customerType: "organization", referenceId: org.id };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const user = await ctx.context.adapter.findOne<User>({
|
|
32
|
+
model: "user",
|
|
33
|
+
where: [{ field: "stripeCustomerId", value: stripeCustomerId }],
|
|
34
|
+
});
|
|
35
|
+
if (user) return { customerType: "user", referenceId: user.id };
|
|
36
|
+
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
6
39
|
|
|
7
40
|
export async function onCheckoutSessionCompleted(
|
|
8
41
|
ctx: GenericEndpointContext,
|
|
@@ -18,19 +51,27 @@ export async function onCheckoutSessionCompleted(
|
|
|
18
51
|
const subscription = await client.subscriptions.retrieve(
|
|
19
52
|
checkoutSession.subscription as string,
|
|
20
53
|
);
|
|
21
|
-
const
|
|
22
|
-
|
|
54
|
+
const subscriptionItem = subscription.items.data[0];
|
|
55
|
+
if (!subscriptionItem) {
|
|
56
|
+
ctx.context.logger.warn(
|
|
57
|
+
`Stripe webhook warning: Subscription ${subscription.id} has no items`,
|
|
58
|
+
);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const priceId = subscriptionItem.price.id;
|
|
63
|
+
const priceLookupKey = subscriptionItem.price.lookup_key;
|
|
23
64
|
const plan = await getPlanByPriceInfo(
|
|
24
65
|
options,
|
|
25
66
|
priceId as string,
|
|
26
67
|
priceLookupKey,
|
|
27
68
|
);
|
|
28
69
|
if (plan) {
|
|
70
|
+
const checkoutMeta = subscriptionMetadata.get(checkoutSession?.metadata);
|
|
29
71
|
const referenceId =
|
|
30
|
-
checkoutSession?.client_reference_id ||
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const seats = subscription.items.data[0]!.quantity;
|
|
72
|
+
checkoutSession?.client_reference_id || checkoutMeta.referenceId;
|
|
73
|
+
const { subscriptionId } = checkoutMeta;
|
|
74
|
+
const seats = subscriptionItem.quantity;
|
|
34
75
|
if (referenceId && subscriptionId) {
|
|
35
76
|
const trial =
|
|
36
77
|
subscription.trial_start && subscription.trial_end
|
|
@@ -40,30 +81,35 @@ export async function onCheckoutSessionCompleted(
|
|
|
40
81
|
}
|
|
41
82
|
: {};
|
|
42
83
|
|
|
43
|
-
let dbSubscription =
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
84
|
+
let dbSubscription = await ctx.context.adapter.update<Subscription>({
|
|
85
|
+
model: "subscription",
|
|
86
|
+
update: {
|
|
87
|
+
plan: plan.name.toLowerCase(),
|
|
88
|
+
status: subscription.status,
|
|
89
|
+
updatedAt: new Date(),
|
|
90
|
+
periodStart: new Date(subscriptionItem.current_period_start * 1000),
|
|
91
|
+
periodEnd: new Date(subscriptionItem.current_period_end * 1000),
|
|
92
|
+
stripeSubscriptionId: checkoutSession.subscription as string,
|
|
93
|
+
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
94
|
+
cancelAt: subscription.cancel_at
|
|
95
|
+
? new Date(subscription.cancel_at * 1000)
|
|
96
|
+
: null,
|
|
97
|
+
canceledAt: subscription.canceled_at
|
|
98
|
+
? new Date(subscription.canceled_at * 1000)
|
|
99
|
+
: null,
|
|
100
|
+
endedAt: subscription.ended_at
|
|
101
|
+
? new Date(subscription.ended_at * 1000)
|
|
102
|
+
: null,
|
|
103
|
+
seats: seats,
|
|
104
|
+
...trial,
|
|
105
|
+
},
|
|
106
|
+
where: [
|
|
107
|
+
{
|
|
108
|
+
field: "id",
|
|
109
|
+
value: subscriptionId,
|
|
59
110
|
},
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
field: "id",
|
|
63
|
-
value: subscriptionId,
|
|
64
|
-
},
|
|
65
|
-
],
|
|
66
|
-
});
|
|
111
|
+
],
|
|
112
|
+
});
|
|
67
113
|
|
|
68
114
|
if (trial.trialStart && plan.freeTrial?.onTrialStart) {
|
|
69
115
|
await plan.freeTrial.onTrialStart(dbSubscription as Subscription);
|
|
@@ -93,7 +139,120 @@ export async function onCheckoutSessionCompleted(
|
|
|
93
139
|
}
|
|
94
140
|
}
|
|
95
141
|
} catch (e: any) {
|
|
96
|
-
logger.error(`Stripe webhook failed. Error: ${e.message}`);
|
|
142
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${e.message}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function onSubscriptionCreated(
|
|
147
|
+
ctx: GenericEndpointContext,
|
|
148
|
+
options: StripeOptions,
|
|
149
|
+
event: Stripe.Event,
|
|
150
|
+
) {
|
|
151
|
+
try {
|
|
152
|
+
if (!options.subscription?.enabled) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const subscriptionCreated = event.data.object as Stripe.Subscription;
|
|
157
|
+
const stripeCustomerId = subscriptionCreated.customer?.toString();
|
|
158
|
+
if (!stripeCustomerId) {
|
|
159
|
+
ctx.context.logger.warn(
|
|
160
|
+
`Stripe webhook warning: customer.subscription.created event received without customer ID`,
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Check if subscription already exists in database
|
|
166
|
+
const { subscriptionId } = subscriptionMetadata.get(
|
|
167
|
+
subscriptionCreated.metadata,
|
|
168
|
+
);
|
|
169
|
+
const existingSubscription =
|
|
170
|
+
await ctx.context.adapter.findOne<Subscription>({
|
|
171
|
+
model: "subscription",
|
|
172
|
+
where: subscriptionId
|
|
173
|
+
? [{ field: "id", value: subscriptionId }]
|
|
174
|
+
: [{ field: "stripeSubscriptionId", value: subscriptionCreated.id }], // Probably won't match since it's not set yet
|
|
175
|
+
});
|
|
176
|
+
if (existingSubscription) {
|
|
177
|
+
ctx.context.logger.info(
|
|
178
|
+
`Stripe webhook: Subscription already exists in database (id: ${existingSubscription.id}), skipping creation`,
|
|
179
|
+
);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Find reference
|
|
184
|
+
const reference = await findReferenceByStripeCustomerId(
|
|
185
|
+
ctx,
|
|
186
|
+
options,
|
|
187
|
+
stripeCustomerId,
|
|
188
|
+
);
|
|
189
|
+
if (!reference) {
|
|
190
|
+
ctx.context.logger.warn(
|
|
191
|
+
`Stripe webhook warning: No user or organization found with stripeCustomerId: ${stripeCustomerId}`,
|
|
192
|
+
);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const { referenceId, customerType } = reference;
|
|
196
|
+
|
|
197
|
+
const subscriptionItem = subscriptionCreated.items.data[0];
|
|
198
|
+
if (!subscriptionItem) {
|
|
199
|
+
ctx.context.logger.warn(
|
|
200
|
+
`Stripe webhook warning: Subscription ${subscriptionCreated.id} has no items`,
|
|
201
|
+
);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const priceId = subscriptionItem.price.id;
|
|
206
|
+
const priceLookupKey = subscriptionItem.price.lookup_key || null;
|
|
207
|
+
const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey);
|
|
208
|
+
if (!plan) {
|
|
209
|
+
ctx.context.logger.warn(
|
|
210
|
+
`Stripe webhook warning: No matching plan found for priceId: ${priceId}`,
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const seats = subscriptionItem.quantity;
|
|
216
|
+
const periodStart = new Date(subscriptionItem.current_period_start * 1000);
|
|
217
|
+
const periodEnd = new Date(subscriptionItem.current_period_end * 1000);
|
|
218
|
+
|
|
219
|
+
const trial =
|
|
220
|
+
subscriptionCreated.trial_start && subscriptionCreated.trial_end
|
|
221
|
+
? {
|
|
222
|
+
trialStart: new Date(subscriptionCreated.trial_start * 1000),
|
|
223
|
+
trialEnd: new Date(subscriptionCreated.trial_end * 1000),
|
|
224
|
+
}
|
|
225
|
+
: {};
|
|
226
|
+
|
|
227
|
+
// Create the subscription in the database
|
|
228
|
+
const newSubscription = await ctx.context.adapter.create<Subscription>({
|
|
229
|
+
model: "subscription",
|
|
230
|
+
data: {
|
|
231
|
+
referenceId,
|
|
232
|
+
stripeCustomerId,
|
|
233
|
+
stripeSubscriptionId: subscriptionCreated.id,
|
|
234
|
+
status: subscriptionCreated.status,
|
|
235
|
+
plan: plan.name.toLowerCase(),
|
|
236
|
+
periodStart,
|
|
237
|
+
periodEnd,
|
|
238
|
+
seats,
|
|
239
|
+
...(plan.limits ? { limits: plan.limits } : {}),
|
|
240
|
+
...trial,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
ctx.context.logger.info(
|
|
245
|
+
`Stripe webhook: Created subscription ${subscriptionCreated.id} for ${customerType} ${referenceId} from dashboard`,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
await options.subscription.onSubscriptionCreated?.({
|
|
249
|
+
event,
|
|
250
|
+
subscription: newSubscription,
|
|
251
|
+
stripeSubscription: subscriptionCreated,
|
|
252
|
+
plan,
|
|
253
|
+
});
|
|
254
|
+
} catch (error: any) {
|
|
255
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
97
256
|
}
|
|
98
257
|
}
|
|
99
258
|
|
|
@@ -107,12 +266,21 @@ export async function onSubscriptionUpdated(
|
|
|
107
266
|
return;
|
|
108
267
|
}
|
|
109
268
|
const subscriptionUpdated = event.data.object as Stripe.Subscription;
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
269
|
+
const subscriptionItem = subscriptionUpdated.items.data[0];
|
|
270
|
+
if (!subscriptionItem) {
|
|
271
|
+
ctx.context.logger.warn(
|
|
272
|
+
`Stripe webhook warning: Subscription ${subscriptionUpdated.id} has no items`,
|
|
273
|
+
);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const priceId = subscriptionItem.price.id;
|
|
278
|
+
const priceLookupKey = subscriptionItem.price.lookup_key;
|
|
113
279
|
const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey);
|
|
114
280
|
|
|
115
|
-
const subscriptionId =
|
|
281
|
+
const { subscriptionId } = subscriptionMetadata.get(
|
|
282
|
+
subscriptionUpdated.metadata,
|
|
283
|
+
);
|
|
116
284
|
const customerId = subscriptionUpdated.customer?.toString();
|
|
117
285
|
let subscription = await ctx.context.adapter.findOne<Subscription>({
|
|
118
286
|
model: "subscription",
|
|
@@ -126,12 +294,11 @@ export async function onSubscriptionUpdated(
|
|
|
126
294
|
where: [{ field: "stripeCustomerId", value: customerId }],
|
|
127
295
|
});
|
|
128
296
|
if (subs.length > 1) {
|
|
129
|
-
const activeSub = subs.find(
|
|
130
|
-
(sub
|
|
131
|
-
sub.status === "active" || sub.status === "trialing",
|
|
297
|
+
const activeSub = subs.find((sub: Subscription) =>
|
|
298
|
+
isActiveOrTrialing(sub),
|
|
132
299
|
);
|
|
133
300
|
if (!activeSub) {
|
|
134
|
-
logger.warn(
|
|
301
|
+
ctx.context.logger.warn(
|
|
135
302
|
`Stripe webhook error: Multiple subscriptions found for customerId: ${customerId} and no active subscription is found`,
|
|
136
303
|
);
|
|
137
304
|
return;
|
|
@@ -142,7 +309,6 @@ export async function onSubscriptionUpdated(
|
|
|
142
309
|
}
|
|
143
310
|
}
|
|
144
311
|
|
|
145
|
-
const seats = subscriptionUpdated.items.data[0]!.quantity;
|
|
146
312
|
const updatedSubscription = await ctx.context.adapter.update<Subscription>({
|
|
147
313
|
model: "subscription",
|
|
148
314
|
update: {
|
|
@@ -154,14 +320,19 @@ export async function onSubscriptionUpdated(
|
|
|
154
320
|
: {}),
|
|
155
321
|
updatedAt: new Date(),
|
|
156
322
|
status: subscriptionUpdated.status,
|
|
157
|
-
periodStart: new Date(
|
|
158
|
-
|
|
159
|
-
),
|
|
160
|
-
periodEnd: new Date(
|
|
161
|
-
subscriptionUpdated.items.data[0]!.current_period_end * 1000,
|
|
162
|
-
),
|
|
323
|
+
periodStart: new Date(subscriptionItem.current_period_start * 1000),
|
|
324
|
+
periodEnd: new Date(subscriptionItem.current_period_end * 1000),
|
|
163
325
|
cancelAtPeriodEnd: subscriptionUpdated.cancel_at_period_end,
|
|
164
|
-
|
|
326
|
+
cancelAt: subscriptionUpdated.cancel_at
|
|
327
|
+
? new Date(subscriptionUpdated.cancel_at * 1000)
|
|
328
|
+
: null,
|
|
329
|
+
canceledAt: subscriptionUpdated.canceled_at
|
|
330
|
+
? new Date(subscriptionUpdated.canceled_at * 1000)
|
|
331
|
+
: null,
|
|
332
|
+
endedAt: subscriptionUpdated.ended_at
|
|
333
|
+
? new Date(subscriptionUpdated.ended_at * 1000)
|
|
334
|
+
: null,
|
|
335
|
+
seats: subscriptionItem.quantity,
|
|
165
336
|
stripeSubscriptionId: subscriptionUpdated.id,
|
|
166
337
|
},
|
|
167
338
|
where: [
|
|
@@ -171,11 +342,11 @@ export async function onSubscriptionUpdated(
|
|
|
171
342
|
},
|
|
172
343
|
],
|
|
173
344
|
});
|
|
174
|
-
const
|
|
345
|
+
const isNewCancellation =
|
|
175
346
|
subscriptionUpdated.status === "active" &&
|
|
176
|
-
subscriptionUpdated
|
|
177
|
-
!subscription
|
|
178
|
-
if (
|
|
347
|
+
isStripePendingCancel(subscriptionUpdated) &&
|
|
348
|
+
!isPendingCancel(subscription);
|
|
349
|
+
if (isNewCancellation) {
|
|
179
350
|
await options.subscription.onSubscriptionCancel?.({
|
|
180
351
|
subscription,
|
|
181
352
|
cancellationDetails:
|
|
@@ -205,7 +376,7 @@ export async function onSubscriptionUpdated(
|
|
|
205
376
|
}
|
|
206
377
|
}
|
|
207
378
|
} catch (error: any) {
|
|
208
|
-
logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
379
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
209
380
|
}
|
|
210
381
|
}
|
|
211
382
|
|
|
@@ -241,6 +412,16 @@ export async function onSubscriptionDeleted(
|
|
|
241
412
|
update: {
|
|
242
413
|
status: "canceled",
|
|
243
414
|
updatedAt: new Date(),
|
|
415
|
+
cancelAtPeriodEnd: subscriptionDeleted.cancel_at_period_end,
|
|
416
|
+
cancelAt: subscriptionDeleted.cancel_at
|
|
417
|
+
? new Date(subscriptionDeleted.cancel_at * 1000)
|
|
418
|
+
: null,
|
|
419
|
+
canceledAt: subscriptionDeleted.canceled_at
|
|
420
|
+
? new Date(subscriptionDeleted.canceled_at * 1000)
|
|
421
|
+
: null,
|
|
422
|
+
endedAt: subscriptionDeleted.ended_at
|
|
423
|
+
? new Date(subscriptionDeleted.ended_at * 1000)
|
|
424
|
+
: null,
|
|
244
425
|
},
|
|
245
426
|
});
|
|
246
427
|
await options.subscription.onSubscriptionDeleted?.({
|
|
@@ -249,11 +430,11 @@ export async function onSubscriptionDeleted(
|
|
|
249
430
|
subscription,
|
|
250
431
|
});
|
|
251
432
|
} else {
|
|
252
|
-
logger.warn(
|
|
433
|
+
ctx.context.logger.warn(
|
|
253
434
|
`Stripe webhook error: Subscription not found for subscriptionId: ${subscriptionId}`,
|
|
254
435
|
);
|
|
255
436
|
}
|
|
256
437
|
} catch (error: any) {
|
|
257
|
-
logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
438
|
+
ctx.context.logger.error(`Stripe webhook failed. Error: ${error}`);
|
|
258
439
|
}
|
|
259
440
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
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";
|
|
7
|
+
import { customerMetadata } from "./metadata";
|
|
5
8
|
import {
|
|
6
9
|
cancelSubscription,
|
|
7
10
|
cancelSubscriptionCallback,
|
|
@@ -18,20 +21,17 @@ import type {
|
|
|
18
21
|
StripePlan,
|
|
19
22
|
Subscription,
|
|
20
23
|
SubscriptionOptions,
|
|
24
|
+
WithStripeCustomerId,
|
|
21
25
|
} from "./types";
|
|
26
|
+
import { escapeStripeSearchValue } from "./utils";
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
});
|
|
28
|
+
declare module "@better-auth/core" {
|
|
29
|
+
interface BetterAuthPluginRegistry<AuthOptions, Options> {
|
|
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 (
|
|
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
|
-
|
|
72
|
-
|
|
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["${customerMetadata.keys.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,
|
|
@@ -114,13 +217,17 @@ export const stripe = <O extends StripeOptions>(options: O) => {
|
|
|
114
217
|
);
|
|
115
218
|
}
|
|
116
219
|
|
|
117
|
-
const params
|
|
220
|
+
const params = defu(
|
|
118
221
|
{
|
|
119
222
|
email: user.email,
|
|
120
223
|
name: user.name,
|
|
121
|
-
metadata:
|
|
122
|
-
|
|
123
|
-
|
|
224
|
+
metadata: customerMetadata.set(
|
|
225
|
+
{
|
|
226
|
+
userId: user.id,
|
|
227
|
+
customerType: "user",
|
|
228
|
+
},
|
|
229
|
+
extraCreateParams?.metadata,
|
|
230
|
+
),
|
|
124
231
|
},
|
|
125
232
|
extraCreateParams,
|
|
126
233
|
);
|
|
@@ -150,41 +257,34 @@ export const stripe = <O extends StripeOptions>(options: O) => {
|
|
|
150
257
|
},
|
|
151
258
|
},
|
|
152
259
|
update: {
|
|
153
|
-
async after(user, ctx) {
|
|
154
|
-
if (
|
|
260
|
+
async after(user: User & WithStripeCustomerId, ctx) {
|
|
261
|
+
if (
|
|
262
|
+
!ctx ||
|
|
263
|
+
!user.stripeCustomerId // Only proceed if user has a Stripe customer ID
|
|
264
|
+
)
|
|
265
|
+
return;
|
|
155
266
|
|
|
156
267
|
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
268
|
// Get the user from the database to check if email actually changed
|
|
166
269
|
// The 'user' parameter here is the freshly updated user
|
|
167
270
|
// We need to check if the Stripe customer's email matches
|
|
168
271
|
const stripeCustomer = await client.customers.retrieve(
|
|
169
|
-
|
|
272
|
+
user.stripeCustomerId,
|
|
170
273
|
);
|
|
171
274
|
|
|
172
275
|
// Check if customer was deleted
|
|
173
276
|
if (stripeCustomer.deleted) {
|
|
174
277
|
ctx.context.logger.warn(
|
|
175
|
-
`Stripe customer ${
|
|
278
|
+
`Stripe customer ${user.stripeCustomerId} was deleted, cannot update email`,
|
|
176
279
|
);
|
|
177
280
|
return;
|
|
178
281
|
}
|
|
179
282
|
|
|
180
283
|
// If Stripe customer email doesn't match the user's current email, update it
|
|
181
284
|
if (stripeCustomer.email !== user.email) {
|
|
182
|
-
await client.customers.update(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
email: user.email,
|
|
186
|
-
},
|
|
187
|
-
);
|
|
285
|
+
await client.customers.update(user.stripeCustomerId, {
|
|
286
|
+
email: user.email,
|
|
287
|
+
});
|
|
188
288
|
ctx.context.logger.info(
|
|
189
289
|
`Updated Stripe customer email from ${stripeCustomer.email} to ${user.email}`,
|
|
190
290
|
);
|