@better-auth/stripe 1.5.0-beta.2 → 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/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +15 -13
- package/LICENSE.md +15 -12
- package/dist/client.d.mts +105 -1
- package/dist/client.mjs +1 -1
- package/dist/error-codes-Bkj5yJMT.mjs +29 -0
- package/dist/{index-SbT5j9k6.d.mts → index-BnHmwMru.d.mts} +269 -154
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +449 -194
- package/package.json +6 -6
- package/src/error-codes.ts +16 -0
- package/src/hooks.ts +98 -71
- package/src/index.ts +142 -45
- package/src/middleware.ts +89 -42
- package/src/routes.ts +502 -224
- package/src/schema.ts +18 -0
- package/src/types.ts +75 -19
- package/src/utils.ts +11 -0
- package/test/stripe-organization.test.ts +1993 -0
- package/{src → test}/stripe.test.ts +821 -18
- package/dist/error-codes-qqooUh6R.mjs +0 -16
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.
|
|
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.
|
|
54
|
-
"better-auth": "1.5.0-beta.
|
|
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.
|
|
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.
|
|
61
|
-
"better-auth": "1.5.0-beta.
|
|
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/error-codes.ts
CHANGED
|
@@ -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,7 +1,8 @@
|
|
|
1
1
|
import type { GenericEndpointContext } from "@better-auth/core";
|
|
2
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 {
|
|
5
|
+
import type { CustomerType, StripeOptions, Subscription } from "./types";
|
|
5
6
|
import {
|
|
6
7
|
getPlanByPriceInfo,
|
|
7
8
|
isActiveOrTrialing,
|
|
@@ -9,6 +10,32 @@ import {
|
|
|
9
10
|
isStripePendingCancel,
|
|
10
11
|
} from "./utils";
|
|
11
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
|
+
}
|
|
38
|
+
|
|
12
39
|
export async function onCheckoutSessionCompleted(
|
|
13
40
|
ctx: GenericEndpointContext,
|
|
14
41
|
options: StripeOptions,
|
|
@@ -23,8 +50,16 @@ export async function onCheckoutSessionCompleted(
|
|
|
23
50
|
const subscription = await client.subscriptions.retrieve(
|
|
24
51
|
checkoutSession.subscription as string,
|
|
25
52
|
);
|
|
26
|
-
const
|
|
27
|
-
|
|
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;
|
|
28
63
|
const plan = await getPlanByPriceInfo(
|
|
29
64
|
options,
|
|
30
65
|
priceId as string,
|
|
@@ -35,7 +70,7 @@ export async function onCheckoutSessionCompleted(
|
|
|
35
70
|
checkoutSession?.client_reference_id ||
|
|
36
71
|
checkoutSession?.metadata?.referenceId;
|
|
37
72
|
const subscriptionId = checkoutSession?.metadata?.subscriptionId;
|
|
38
|
-
const seats =
|
|
73
|
+
const seats = subscriptionItem.quantity;
|
|
39
74
|
if (referenceId && subscriptionId) {
|
|
40
75
|
const trial =
|
|
41
76
|
subscription.trial_start && subscription.trial_end
|
|
@@ -45,40 +80,35 @@ export async function onCheckoutSessionCompleted(
|
|
|
45
80
|
}
|
|
46
81
|
: {};
|
|
47
82
|
|
|
48
|
-
let dbSubscription =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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,
|
|
74
109
|
},
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
field: "id",
|
|
78
|
-
value: subscriptionId,
|
|
79
|
-
},
|
|
80
|
-
],
|
|
81
|
-
});
|
|
110
|
+
],
|
|
111
|
+
});
|
|
82
112
|
|
|
83
113
|
if (trial.trialStart && plan.freeTrial?.onTrialStart) {
|
|
84
114
|
await plan.freeTrial.onTrialStart(dbSubscription as Subscription);
|
|
@@ -132,39 +162,34 @@ export async function onSubscriptionCreated(
|
|
|
132
162
|
}
|
|
133
163
|
|
|
134
164
|
// Check if subscription already exists in database
|
|
165
|
+
const subscriptionId = subscriptionCreated.metadata?.subscriptionId;
|
|
135
166
|
const existingSubscription =
|
|
136
167
|
await ctx.context.adapter.findOne<Subscription>({
|
|
137
168
|
model: "subscription",
|
|
138
|
-
where:
|
|
139
|
-
{
|
|
140
|
-
|
|
141
|
-
value: subscriptionCreated.id,
|
|
142
|
-
},
|
|
143
|
-
],
|
|
169
|
+
where: subscriptionId
|
|
170
|
+
? [{ field: "id", value: subscriptionId }]
|
|
171
|
+
: [{ field: "stripeSubscriptionId", value: subscriptionCreated.id }], // Probably won't match since it's not set yet
|
|
144
172
|
});
|
|
145
173
|
if (existingSubscription) {
|
|
146
174
|
ctx.context.logger.info(
|
|
147
|
-
`Stripe webhook: Subscription
|
|
175
|
+
`Stripe webhook: Subscription already exists in database (id: ${existingSubscription.id}), skipping creation`,
|
|
148
176
|
);
|
|
149
177
|
return;
|
|
150
178
|
}
|
|
151
179
|
|
|
152
|
-
// Find
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
},
|
|
160
|
-
],
|
|
161
|
-
});
|
|
162
|
-
if (!user) {
|
|
180
|
+
// Find reference
|
|
181
|
+
const reference = await findReferenceByStripeCustomerId(
|
|
182
|
+
ctx,
|
|
183
|
+
options,
|
|
184
|
+
stripeCustomerId,
|
|
185
|
+
);
|
|
186
|
+
if (!reference) {
|
|
163
187
|
ctx.context.logger.warn(
|
|
164
|
-
`Stripe webhook warning: No user found with stripeCustomerId: ${stripeCustomerId}`,
|
|
188
|
+
`Stripe webhook warning: No user or organization found with stripeCustomerId: ${stripeCustomerId}`,
|
|
165
189
|
);
|
|
166
190
|
return;
|
|
167
191
|
}
|
|
192
|
+
const { referenceId, customerType } = reference;
|
|
168
193
|
|
|
169
194
|
const subscriptionItem = subscriptionCreated.items.data[0];
|
|
170
195
|
if (!subscriptionItem) {
|
|
@@ -200,8 +225,8 @@ export async function onSubscriptionCreated(
|
|
|
200
225
|
const newSubscription = await ctx.context.adapter.create<Subscription>({
|
|
201
226
|
model: "subscription",
|
|
202
227
|
data: {
|
|
203
|
-
referenceId
|
|
204
|
-
stripeCustomerId
|
|
228
|
+
referenceId,
|
|
229
|
+
stripeCustomerId,
|
|
205
230
|
stripeSubscriptionId: subscriptionCreated.id,
|
|
206
231
|
status: subscriptionCreated.status,
|
|
207
232
|
plan: plan.name.toLowerCase(),
|
|
@@ -214,10 +239,10 @@ export async function onSubscriptionCreated(
|
|
|
214
239
|
});
|
|
215
240
|
|
|
216
241
|
ctx.context.logger.info(
|
|
217
|
-
`Stripe webhook: Created subscription ${subscriptionCreated.id} for
|
|
242
|
+
`Stripe webhook: Created subscription ${subscriptionCreated.id} for ${customerType} ${referenceId} from dashboard`,
|
|
218
243
|
);
|
|
219
244
|
|
|
220
|
-
await options.subscription
|
|
245
|
+
await options.subscription.onSubscriptionCreated?.({
|
|
221
246
|
event,
|
|
222
247
|
subscription: newSubscription,
|
|
223
248
|
stripeSubscription: subscriptionCreated,
|
|
@@ -238,9 +263,16 @@ export async function onSubscriptionUpdated(
|
|
|
238
263
|
return;
|
|
239
264
|
}
|
|
240
265
|
const subscriptionUpdated = event.data.object as Stripe.Subscription;
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
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;
|
|
244
276
|
const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey);
|
|
245
277
|
|
|
246
278
|
const subscriptionId = subscriptionUpdated.metadata?.subscriptionId;
|
|
@@ -272,7 +304,6 @@ export async function onSubscriptionUpdated(
|
|
|
272
304
|
}
|
|
273
305
|
}
|
|
274
306
|
|
|
275
|
-
const seats = subscriptionUpdated.items.data[0]!.quantity;
|
|
276
307
|
const updatedSubscription = await ctx.context.adapter.update<Subscription>({
|
|
277
308
|
model: "subscription",
|
|
278
309
|
update: {
|
|
@@ -284,12 +315,8 @@ export async function onSubscriptionUpdated(
|
|
|
284
315
|
: {}),
|
|
285
316
|
updatedAt: new Date(),
|
|
286
317
|
status: subscriptionUpdated.status,
|
|
287
|
-
periodStart: new Date(
|
|
288
|
-
|
|
289
|
-
),
|
|
290
|
-
periodEnd: new Date(
|
|
291
|
-
subscriptionUpdated.items.data[0]!.current_period_end * 1000,
|
|
292
|
-
),
|
|
318
|
+
periodStart: new Date(subscriptionItem.current_period_start * 1000),
|
|
319
|
+
periodEnd: new Date(subscriptionItem.current_period_end * 1000),
|
|
293
320
|
cancelAtPeriodEnd: subscriptionUpdated.cancel_at_period_end,
|
|
294
321
|
cancelAt: subscriptionUpdated.cancel_at
|
|
295
322
|
? new Date(subscriptionUpdated.cancel_at * 1000)
|
|
@@ -300,7 +327,7 @@ export async function onSubscriptionUpdated(
|
|
|
300
327
|
endedAt: subscriptionUpdated.ended_at
|
|
301
328
|
? new Date(subscriptionUpdated.ended_at * 1000)
|
|
302
329
|
: null,
|
|
303
|
-
seats:
|
|
330
|
+
seats: subscriptionItem.quantity,
|
|
304
331
|
stripeSubscriptionId: subscriptionUpdated.id,
|
|
305
332
|
},
|
|
306
333
|
where: [
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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";
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 (
|
|
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["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 (
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
);
|