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