@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
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { runWithEndpointContext } from "@better-auth/core/context";
|
|
2
2
|
import type { Auth, User } from "better-auth";
|
|
3
3
|
import { memoryAdapter } from "better-auth/adapters/memory";
|
|
4
|
+
import { organization } from "better-auth/plugins/organization";
|
|
4
5
|
import { getTestInstance } from "better-auth/test";
|
|
5
6
|
import type Stripe from "stripe";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
import {
|
|
8
|
+
assert,
|
|
9
|
+
beforeEach,
|
|
10
|
+
describe,
|
|
11
|
+
expect,
|
|
12
|
+
expectTypeOf,
|
|
13
|
+
it,
|
|
14
|
+
vi,
|
|
15
|
+
} from "vitest";
|
|
16
|
+
import type { StripePlugin } from "../src";
|
|
17
|
+
import { stripe } from "../src";
|
|
18
|
+
import { stripeClient } from "../src/client";
|
|
19
|
+
import type { StripeOptions, Subscription } from "../src/types";
|
|
11
20
|
|
|
12
21
|
describe("stripe type", () => {
|
|
13
22
|
it("should api endpoint exists", () => {
|
|
@@ -59,6 +68,7 @@ describe("stripe", () => {
|
|
|
59
68
|
customers: {
|
|
60
69
|
create: vi.fn().mockResolvedValue({ id: "cus_mock123" }),
|
|
61
70
|
list: vi.fn().mockResolvedValue({ data: [] }),
|
|
71
|
+
search: vi.fn().mockResolvedValue({ data: [] }),
|
|
62
72
|
retrieve: vi.fn().mockResolvedValue({
|
|
63
73
|
id: "cus_mock123",
|
|
64
74
|
email: "test@email.com",
|
|
@@ -329,6 +339,71 @@ describe("stripe", () => {
|
|
|
329
339
|
expect(mockStripe.subscriptions.update).not.toHaveBeenCalled();
|
|
330
340
|
});
|
|
331
341
|
|
|
342
|
+
it("should pass metadata to subscription when upgrading", async () => {
|
|
343
|
+
const { client, sessionSetter } = await getTestInstance(
|
|
344
|
+
{
|
|
345
|
+
database: memory,
|
|
346
|
+
plugins: [stripe(stripeOptions)],
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
disableTestUser: true,
|
|
350
|
+
clientOptions: {
|
|
351
|
+
plugins: [
|
|
352
|
+
stripeClient({
|
|
353
|
+
subscription: true,
|
|
354
|
+
}),
|
|
355
|
+
],
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
await client.signUp.email(
|
|
361
|
+
{
|
|
362
|
+
...testUser,
|
|
363
|
+
email: "metadata-test@email.com",
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
throw: true,
|
|
367
|
+
},
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const headers = new Headers();
|
|
371
|
+
await client.signIn.email(
|
|
372
|
+
{
|
|
373
|
+
...testUser,
|
|
374
|
+
email: "metadata-test@email.com",
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
throw: true,
|
|
378
|
+
onSuccess: sessionSetter(headers),
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const customMetadata = {
|
|
383
|
+
customField: "customValue",
|
|
384
|
+
organizationId: "org_123",
|
|
385
|
+
projectId: "proj_456",
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
await client.subscription.upgrade({
|
|
389
|
+
plan: "starter",
|
|
390
|
+
metadata: customMetadata,
|
|
391
|
+
fetchOptions: {
|
|
392
|
+
headers,
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(mockStripe.checkout.sessions.create).toHaveBeenCalledWith(
|
|
397
|
+
expect.objectContaining({
|
|
398
|
+
subscription_data: expect.objectContaining({
|
|
399
|
+
metadata: expect.objectContaining(customMetadata),
|
|
400
|
+
}),
|
|
401
|
+
metadata: expect.objectContaining(customMetadata),
|
|
402
|
+
}),
|
|
403
|
+
undefined,
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
332
407
|
it("should list active subscriptions", async () => {
|
|
333
408
|
const { client, auth, sessionSetter } = await getTestInstance(
|
|
334
409
|
{
|
|
@@ -1153,6 +1228,137 @@ describe("stripe", () => {
|
|
|
1153
1228
|
expect(onSubscriptionCreatedCallback).not.toHaveBeenCalled();
|
|
1154
1229
|
});
|
|
1155
1230
|
|
|
1231
|
+
it("should skip creating subscription when metadata.subscriptionId exists", async () => {
|
|
1232
|
+
const stripeForTest = {
|
|
1233
|
+
...stripeOptions.stripeClient,
|
|
1234
|
+
webhooks: {
|
|
1235
|
+
constructEventAsync: vi.fn(),
|
|
1236
|
+
},
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
const testOptions = {
|
|
1240
|
+
...stripeOptions,
|
|
1241
|
+
stripeClient: stripeForTest as unknown as Stripe,
|
|
1242
|
+
stripeWebhookSecret: "test_secret",
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
const {
|
|
1246
|
+
auth: testAuth,
|
|
1247
|
+
client,
|
|
1248
|
+
sessionSetter,
|
|
1249
|
+
} = await getTestInstance(
|
|
1250
|
+
{
|
|
1251
|
+
database: memory,
|
|
1252
|
+
plugins: [stripe(testOptions)],
|
|
1253
|
+
},
|
|
1254
|
+
{
|
|
1255
|
+
disableTestUser: true,
|
|
1256
|
+
clientOptions: {
|
|
1257
|
+
plugins: [stripeClient({ subscription: true })],
|
|
1258
|
+
},
|
|
1259
|
+
},
|
|
1260
|
+
);
|
|
1261
|
+
const testCtx = await testAuth.$context;
|
|
1262
|
+
|
|
1263
|
+
// Create user and sign in
|
|
1264
|
+
const userRes = await client.signUp.email(
|
|
1265
|
+
{
|
|
1266
|
+
...testUser,
|
|
1267
|
+
},
|
|
1268
|
+
{ throw: true },
|
|
1269
|
+
);
|
|
1270
|
+
const userId = userRes.user.id;
|
|
1271
|
+
|
|
1272
|
+
const headers = new Headers();
|
|
1273
|
+
await client.signIn.email(
|
|
1274
|
+
{
|
|
1275
|
+
...testUser,
|
|
1276
|
+
},
|
|
1277
|
+
{
|
|
1278
|
+
throw: true,
|
|
1279
|
+
onSuccess: sessionSetter(headers),
|
|
1280
|
+
},
|
|
1281
|
+
);
|
|
1282
|
+
|
|
1283
|
+
// User upgrades to paid plan - this creates an "incomplete" subscription
|
|
1284
|
+
await client.subscription.upgrade({
|
|
1285
|
+
plan: "starter",
|
|
1286
|
+
fetchOptions: { headers },
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
// Verify the incomplete subscription was created
|
|
1290
|
+
const incompleteSubscription = await testCtx.adapter.findOne<Subscription>({
|
|
1291
|
+
model: "subscription",
|
|
1292
|
+
where: [{ field: "referenceId", value: userId }],
|
|
1293
|
+
});
|
|
1294
|
+
assert(
|
|
1295
|
+
incompleteSubscription,
|
|
1296
|
+
"Expected incomplete subscription to be created",
|
|
1297
|
+
);
|
|
1298
|
+
expect(incompleteSubscription.status).toBe("incomplete");
|
|
1299
|
+
expect(incompleteSubscription.stripeSubscriptionId).toBeUndefined();
|
|
1300
|
+
|
|
1301
|
+
// Get user with stripeCustomerId
|
|
1302
|
+
const user = await testCtx.adapter.findOne<any>({
|
|
1303
|
+
model: "user",
|
|
1304
|
+
where: [{ field: "id", value: userId }],
|
|
1305
|
+
});
|
|
1306
|
+
const stripeCustomerId = user?.stripeCustomerId;
|
|
1307
|
+
expect(stripeCustomerId).toBeDefined();
|
|
1308
|
+
|
|
1309
|
+
// Simulate `customer.subscription.created` webhook arriving
|
|
1310
|
+
const mockEvent = {
|
|
1311
|
+
type: "customer.subscription.created",
|
|
1312
|
+
data: {
|
|
1313
|
+
object: {
|
|
1314
|
+
id: "sub_new_from_checkout",
|
|
1315
|
+
customer: stripeCustomerId,
|
|
1316
|
+
status: "active",
|
|
1317
|
+
metadata: {
|
|
1318
|
+
subscriptionId: incompleteSubscription.id,
|
|
1319
|
+
},
|
|
1320
|
+
items: {
|
|
1321
|
+
data: [
|
|
1322
|
+
{
|
|
1323
|
+
price: { id: process.env.STRIPE_PRICE_ID_1 },
|
|
1324
|
+
quantity: 1,
|
|
1325
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
1326
|
+
current_period_end:
|
|
1327
|
+
Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
1328
|
+
},
|
|
1329
|
+
],
|
|
1330
|
+
},
|
|
1331
|
+
cancel_at_period_end: false,
|
|
1332
|
+
},
|
|
1333
|
+
},
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
(stripeForTest.webhooks.constructEventAsync as any).mockResolvedValue(
|
|
1337
|
+
mockEvent,
|
|
1338
|
+
);
|
|
1339
|
+
|
|
1340
|
+
const mockRequest = new Request(
|
|
1341
|
+
"http://localhost:3000/api/auth/stripe/webhook",
|
|
1342
|
+
{
|
|
1343
|
+
method: "POST",
|
|
1344
|
+
headers: {
|
|
1345
|
+
"stripe-signature": "test_signature",
|
|
1346
|
+
},
|
|
1347
|
+
body: JSON.stringify(mockEvent),
|
|
1348
|
+
},
|
|
1349
|
+
);
|
|
1350
|
+
|
|
1351
|
+
const response = await testAuth.handler(mockRequest);
|
|
1352
|
+
expect(response.status).toBe(200);
|
|
1353
|
+
|
|
1354
|
+
// Verify that no duplicate subscription was created
|
|
1355
|
+
const allSubscriptions = await testCtx.adapter.findMany<Subscription>({
|
|
1356
|
+
model: "subscription",
|
|
1357
|
+
where: [{ field: "referenceId", value: userId }],
|
|
1358
|
+
});
|
|
1359
|
+
expect(allSubscriptions.length).toBe(1);
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1156
1362
|
it("should execute subscription event handlers", async () => {
|
|
1157
1363
|
const { auth: testAuth } = await getTestInstance(
|
|
1158
1364
|
{
|
|
@@ -2074,8 +2280,8 @@ describe("stripe", () => {
|
|
|
2074
2280
|
},
|
|
2075
2281
|
);
|
|
2076
2282
|
|
|
2077
|
-
// Mock customers.
|
|
2078
|
-
mockStripe.customers.
|
|
2283
|
+
// Mock customers.search to find existing user customer
|
|
2284
|
+
mockStripe.customers.search.mockResolvedValueOnce({
|
|
2079
2285
|
data: [{ id: "cus_test_123" }],
|
|
2080
2286
|
});
|
|
2081
2287
|
|
|
@@ -2335,6 +2541,7 @@ describe("stripe", () => {
|
|
|
2335
2541
|
email: testUser.email,
|
|
2336
2542
|
name: testUser.name,
|
|
2337
2543
|
metadata: {
|
|
2544
|
+
customerType: "user",
|
|
2338
2545
|
userId: userRes.user.id,
|
|
2339
2546
|
},
|
|
2340
2547
|
});
|
|
@@ -2544,6 +2751,7 @@ describe("stripe", () => {
|
|
|
2544
2751
|
name: "Merge User",
|
|
2545
2752
|
phone: "+1234567890",
|
|
2546
2753
|
metadata: {
|
|
2754
|
+
customerType: "user",
|
|
2547
2755
|
userId: userRes.user.id,
|
|
2548
2756
|
customField: "customValue",
|
|
2549
2757
|
anotherField: "anotherValue",
|
|
@@ -2590,6 +2798,7 @@ describe("stripe", () => {
|
|
|
2590
2798
|
email: "no-custom-params@email.com",
|
|
2591
2799
|
name: "Default User",
|
|
2592
2800
|
metadata: {
|
|
2801
|
+
customerType: "user",
|
|
2593
2802
|
userId: userRes.user.id,
|
|
2594
2803
|
},
|
|
2595
2804
|
});
|
|
@@ -2636,7 +2845,7 @@ describe("stripe", () => {
|
|
|
2636
2845
|
const response = await testAuth.handler(mockRequest);
|
|
2637
2846
|
expect(response.status).toBe(400);
|
|
2638
2847
|
const data = await response.json();
|
|
2639
|
-
expect(data.message).toContain("
|
|
2848
|
+
expect(data.message).toContain("Failed to construct Stripe event");
|
|
2640
2849
|
});
|
|
2641
2850
|
|
|
2642
2851
|
it("should reject webhook request without stripe-signature header", async () => {
|
|
@@ -2664,7 +2873,7 @@ describe("stripe", () => {
|
|
|
2664
2873
|
const response = await testAuth.handler(mockRequest);
|
|
2665
2874
|
expect(response.status).toBe(400);
|
|
2666
2875
|
const data = await response.json();
|
|
2667
|
-
expect(data.message).toContain("Stripe
|
|
2876
|
+
expect(data.message).toContain("Stripe signature not found");
|
|
2668
2877
|
});
|
|
2669
2878
|
|
|
2670
2879
|
it("should handle constructEventAsync returning null/undefined", async () => {
|
|
@@ -2705,7 +2914,7 @@ describe("stripe", () => {
|
|
|
2705
2914
|
const response = await testAuth.handler(mockRequest);
|
|
2706
2915
|
expect(response.status).toBe(400);
|
|
2707
2916
|
const data = await response.json();
|
|
2708
|
-
expect(data.message).toContain("Failed to construct event");
|
|
2917
|
+
expect(data.message).toContain("Failed to construct Stripe event");
|
|
2709
2918
|
});
|
|
2710
2919
|
|
|
2711
2920
|
it("should handle async errors in webhook event processing", async () => {
|
|
@@ -3153,7 +3362,7 @@ describe("stripe", () => {
|
|
|
3153
3362
|
const existingEmail = "duplicate-email@example.com";
|
|
3154
3363
|
const existingCustomerId = "cus_stripe_existing_456";
|
|
3155
3364
|
|
|
3156
|
-
mockStripe.customers.
|
|
3365
|
+
mockStripe.customers.search.mockResolvedValueOnce({
|
|
3157
3366
|
data: [
|
|
3158
3367
|
{
|
|
3159
3368
|
id: existingCustomerId,
|
|
@@ -3194,9 +3403,9 @@ describe("stripe", () => {
|
|
|
3194
3403
|
{ throw: true },
|
|
3195
3404
|
);
|
|
3196
3405
|
|
|
3197
|
-
// Should check for existing customer by email
|
|
3198
|
-
expect(mockStripe.customers.
|
|
3199
|
-
email:
|
|
3406
|
+
// Should check for existing user customer by email (excluding organization customers)
|
|
3407
|
+
expect(mockStripe.customers.search).toHaveBeenCalledWith({
|
|
3408
|
+
query: `email:"${existingEmail}" AND -metadata["customerType"]:"organization"`,
|
|
3200
3409
|
limit: 1,
|
|
3201
3410
|
});
|
|
3202
3411
|
|
|
@@ -3216,7 +3425,7 @@ describe("stripe", () => {
|
|
|
3216
3425
|
it("should CREATE customer only when user has no stripeCustomerId and none exists in Stripe", async () => {
|
|
3217
3426
|
const newEmail = "brand-new@example.com";
|
|
3218
3427
|
|
|
3219
|
-
mockStripe.customers.
|
|
3428
|
+
mockStripe.customers.search.mockResolvedValueOnce({
|
|
3220
3429
|
data: [],
|
|
3221
3430
|
});
|
|
3222
3431
|
|
|
@@ -3256,9 +3465,9 @@ describe("stripe", () => {
|
|
|
3256
3465
|
{ throw: true },
|
|
3257
3466
|
);
|
|
3258
3467
|
|
|
3259
|
-
// Should check for existing customer first
|
|
3260
|
-
expect(mockStripe.customers.
|
|
3261
|
-
email:
|
|
3468
|
+
// Should check for existing user customer first (excluding organization customers)
|
|
3469
|
+
expect(mockStripe.customers.search).toHaveBeenCalledWith({
|
|
3470
|
+
query: `email:"${newEmail}" AND -metadata["customerType"]:"organization"`,
|
|
3262
3471
|
limit: 1,
|
|
3263
3472
|
});
|
|
3264
3473
|
|
|
@@ -3269,6 +3478,7 @@ describe("stripe", () => {
|
|
|
3269
3478
|
name: "Brand New User",
|
|
3270
3479
|
metadata: {
|
|
3271
3480
|
userId: userRes.user.id,
|
|
3481
|
+
customerType: "user",
|
|
3272
3482
|
},
|
|
3273
3483
|
});
|
|
3274
3484
|
|
|
@@ -3283,6 +3493,231 @@ describe("stripe", () => {
|
|
|
3283
3493
|
});
|
|
3284
3494
|
});
|
|
3285
3495
|
|
|
3496
|
+
describe("User/Organization customer collision prevention", () => {
|
|
3497
|
+
it("should NOT return organization customer when searching for user customer with same email", async () => {
|
|
3498
|
+
// Scenario: Organization has a Stripe customer with email "shared@example.com"
|
|
3499
|
+
// When a user signs up with the same email, the search should NOT find the org customer
|
|
3500
|
+
const sharedEmail = "shared@example.com";
|
|
3501
|
+
const orgCustomerId = "cus_org_123";
|
|
3502
|
+
|
|
3503
|
+
// Mock: Only organization customer exists with this email
|
|
3504
|
+
// The search query includes `-metadata['customerType']:'organization'`
|
|
3505
|
+
// so this should NOT be returned
|
|
3506
|
+
mockStripe.customers.search.mockResolvedValueOnce({
|
|
3507
|
+
data: [], // Organization customer is excluded by the search query
|
|
3508
|
+
});
|
|
3509
|
+
|
|
3510
|
+
mockStripe.customers.create.mockResolvedValueOnce({
|
|
3511
|
+
id: "cus_user_new_456",
|
|
3512
|
+
email: sharedEmail,
|
|
3513
|
+
});
|
|
3514
|
+
|
|
3515
|
+
const testOptions = {
|
|
3516
|
+
...stripeOptions,
|
|
3517
|
+
createCustomerOnSignUp: true,
|
|
3518
|
+
} satisfies StripeOptions;
|
|
3519
|
+
|
|
3520
|
+
const { client: testAuthClient, auth: testAuth } = await getTestInstance(
|
|
3521
|
+
{
|
|
3522
|
+
database: memory,
|
|
3523
|
+
plugins: [stripe(testOptions)],
|
|
3524
|
+
},
|
|
3525
|
+
{
|
|
3526
|
+
disableTestUser: true,
|
|
3527
|
+
clientOptions: {
|
|
3528
|
+
plugins: [stripeClient({ subscription: true })],
|
|
3529
|
+
},
|
|
3530
|
+
},
|
|
3531
|
+
);
|
|
3532
|
+
const testCtx = await testAuth.$context;
|
|
3533
|
+
|
|
3534
|
+
vi.clearAllMocks();
|
|
3535
|
+
|
|
3536
|
+
// User signs up with email that organization already uses
|
|
3537
|
+
const userRes = await testAuthClient.signUp.email(
|
|
3538
|
+
{
|
|
3539
|
+
email: sharedEmail,
|
|
3540
|
+
password: "password",
|
|
3541
|
+
name: "User With Shared Email",
|
|
3542
|
+
},
|
|
3543
|
+
{ throw: true },
|
|
3544
|
+
);
|
|
3545
|
+
|
|
3546
|
+
// Should search with query that EXCLUDES organization customers
|
|
3547
|
+
expect(mockStripe.customers.search).toHaveBeenCalledWith({
|
|
3548
|
+
query: `email:"${sharedEmail}" AND -metadata["customerType"]:"organization"`,
|
|
3549
|
+
limit: 1,
|
|
3550
|
+
});
|
|
3551
|
+
|
|
3552
|
+
// Should create NEW user customer (not use org customer)
|
|
3553
|
+
expect(mockStripe.customers.create).toHaveBeenCalledTimes(1);
|
|
3554
|
+
expect(mockStripe.customers.create).toHaveBeenCalledWith({
|
|
3555
|
+
email: sharedEmail,
|
|
3556
|
+
name: "User With Shared Email",
|
|
3557
|
+
metadata: {
|
|
3558
|
+
customerType: "user",
|
|
3559
|
+
userId: userRes.user.id,
|
|
3560
|
+
},
|
|
3561
|
+
});
|
|
3562
|
+
|
|
3563
|
+
// Verify user has their own customer ID (not the org's)
|
|
3564
|
+
const user = await testCtx.adapter.findOne<
|
|
3565
|
+
User & { stripeCustomerId?: string }
|
|
3566
|
+
>({
|
|
3567
|
+
model: "user",
|
|
3568
|
+
where: [{ field: "id", value: userRes.user.id }],
|
|
3569
|
+
});
|
|
3570
|
+
expect(user?.stripeCustomerId).toBe("cus_user_new_456");
|
|
3571
|
+
expect(user?.stripeCustomerId).not.toBe(orgCustomerId);
|
|
3572
|
+
});
|
|
3573
|
+
|
|
3574
|
+
it("should find existing user customer even when organization customer with same email exists", async () => {
|
|
3575
|
+
// Scenario: Both user and organization customers exist with same email
|
|
3576
|
+
// The search should only return the user customer
|
|
3577
|
+
const sharedEmail = "both-exist@example.com";
|
|
3578
|
+
const existingUserCustomerId = "cus_user_existing_789";
|
|
3579
|
+
|
|
3580
|
+
// Mock: Search returns ONLY user customer (org customer excluded by query)
|
|
3581
|
+
mockStripe.customers.search.mockResolvedValueOnce({
|
|
3582
|
+
data: [
|
|
3583
|
+
{
|
|
3584
|
+
id: existingUserCustomerId,
|
|
3585
|
+
email: sharedEmail,
|
|
3586
|
+
name: "Existing User Customer",
|
|
3587
|
+
metadata: {
|
|
3588
|
+
customerType: "user",
|
|
3589
|
+
userId: "some-old-user-id",
|
|
3590
|
+
},
|
|
3591
|
+
},
|
|
3592
|
+
],
|
|
3593
|
+
});
|
|
3594
|
+
|
|
3595
|
+
const testOptions = {
|
|
3596
|
+
...stripeOptions,
|
|
3597
|
+
createCustomerOnSignUp: true,
|
|
3598
|
+
} satisfies StripeOptions;
|
|
3599
|
+
|
|
3600
|
+
const { client: testAuthClient, auth: testAuth } = await getTestInstance(
|
|
3601
|
+
{
|
|
3602
|
+
database: memory,
|
|
3603
|
+
plugins: [stripe(testOptions)],
|
|
3604
|
+
},
|
|
3605
|
+
{
|
|
3606
|
+
disableTestUser: true,
|
|
3607
|
+
clientOptions: {
|
|
3608
|
+
plugins: [stripeClient({ subscription: true })],
|
|
3609
|
+
},
|
|
3610
|
+
},
|
|
3611
|
+
);
|
|
3612
|
+
const testCtx = await testAuth.$context;
|
|
3613
|
+
|
|
3614
|
+
vi.clearAllMocks();
|
|
3615
|
+
|
|
3616
|
+
// User signs up - should find their existing customer
|
|
3617
|
+
const userRes = await testAuthClient.signUp.email(
|
|
3618
|
+
{
|
|
3619
|
+
email: sharedEmail,
|
|
3620
|
+
password: "password",
|
|
3621
|
+
name: "User Reclaiming Account",
|
|
3622
|
+
},
|
|
3623
|
+
{ throw: true },
|
|
3624
|
+
);
|
|
3625
|
+
|
|
3626
|
+
// Should search excluding organization customers
|
|
3627
|
+
expect(mockStripe.customers.search).toHaveBeenCalledWith({
|
|
3628
|
+
query: `email:"${sharedEmail}" AND -metadata["customerType"]:"organization"`,
|
|
3629
|
+
limit: 1,
|
|
3630
|
+
});
|
|
3631
|
+
|
|
3632
|
+
// Should NOT create new customer - use existing user customer
|
|
3633
|
+
expect(mockStripe.customers.create).not.toHaveBeenCalled();
|
|
3634
|
+
|
|
3635
|
+
// Verify user has the existing user customer ID
|
|
3636
|
+
const user = await testCtx.adapter.findOne<
|
|
3637
|
+
User & { stripeCustomerId?: string }
|
|
3638
|
+
>({
|
|
3639
|
+
model: "user",
|
|
3640
|
+
where: [{ field: "id", value: userRes.user.id }],
|
|
3641
|
+
});
|
|
3642
|
+
expect(user?.stripeCustomerId).toBe(existingUserCustomerId);
|
|
3643
|
+
});
|
|
3644
|
+
|
|
3645
|
+
it("should create organization customer with customerType metadata", async () => {
|
|
3646
|
+
// Test that organization customers are properly tagged
|
|
3647
|
+
const orgEmail = "org@example.com";
|
|
3648
|
+
const orgId = "org_test_123";
|
|
3649
|
+
|
|
3650
|
+
mockStripe.customers.search.mockResolvedValueOnce({
|
|
3651
|
+
data: [],
|
|
3652
|
+
});
|
|
3653
|
+
|
|
3654
|
+
mockStripe.customers.create.mockResolvedValueOnce({
|
|
3655
|
+
id: "cus_org_new_999",
|
|
3656
|
+
email: orgEmail,
|
|
3657
|
+
});
|
|
3658
|
+
|
|
3659
|
+
const { auth: testAuth } = await getTestInstance(
|
|
3660
|
+
{
|
|
3661
|
+
database: memory,
|
|
3662
|
+
plugins: [
|
|
3663
|
+
organization(),
|
|
3664
|
+
stripe({
|
|
3665
|
+
...stripeOptions,
|
|
3666
|
+
organization: {
|
|
3667
|
+
enabled: true,
|
|
3668
|
+
createCustomerOnOrganizationCreate: true,
|
|
3669
|
+
},
|
|
3670
|
+
}),
|
|
3671
|
+
],
|
|
3672
|
+
},
|
|
3673
|
+
{ disableTestUser: true },
|
|
3674
|
+
);
|
|
3675
|
+
const testCtx = await testAuth.$context;
|
|
3676
|
+
|
|
3677
|
+
vi.clearAllMocks();
|
|
3678
|
+
|
|
3679
|
+
// Create organization
|
|
3680
|
+
await testCtx.adapter.create({
|
|
3681
|
+
model: "organization",
|
|
3682
|
+
data: {
|
|
3683
|
+
id: orgId,
|
|
3684
|
+
name: "Test Organization",
|
|
3685
|
+
slug: "test-org-collision",
|
|
3686
|
+
createdAt: new Date(),
|
|
3687
|
+
},
|
|
3688
|
+
});
|
|
3689
|
+
|
|
3690
|
+
// Manually trigger the organization customer creation flow
|
|
3691
|
+
// by calling the internal function (simulating what hooks do)
|
|
3692
|
+
const stripeClient = stripeOptions.stripeClient;
|
|
3693
|
+
const searchResult = await stripeClient.customers.search({
|
|
3694
|
+
query: `email:'${orgEmail}' AND metadata['customerType']:'organization'`,
|
|
3695
|
+
limit: 1,
|
|
3696
|
+
});
|
|
3697
|
+
|
|
3698
|
+
if (searchResult.data.length === 0) {
|
|
3699
|
+
await stripeClient.customers.create({
|
|
3700
|
+
email: orgEmail,
|
|
3701
|
+
name: "Test Organization",
|
|
3702
|
+
metadata: {
|
|
3703
|
+
customerType: "organization",
|
|
3704
|
+
organizationId: orgId,
|
|
3705
|
+
},
|
|
3706
|
+
});
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
// Verify organization customer was created with correct metadata
|
|
3710
|
+
expect(mockStripe.customers.create).toHaveBeenCalledWith({
|
|
3711
|
+
email: orgEmail,
|
|
3712
|
+
name: "Test Organization",
|
|
3713
|
+
metadata: {
|
|
3714
|
+
customerType: "organization",
|
|
3715
|
+
organizationId: orgId,
|
|
3716
|
+
},
|
|
3717
|
+
});
|
|
3718
|
+
});
|
|
3719
|
+
});
|
|
3720
|
+
|
|
3286
3721
|
describe("webhook: cancel_at_period_end cancellation", () => {
|
|
3287
3722
|
it("should sync cancelAtPeriodEnd and canceledAt when user cancels via Billing Portal (at_period_end mode)", async () => {
|
|
3288
3723
|
const { auth } = await getTestInstance(
|
|
@@ -3962,4 +4397,372 @@ describe("stripe", () => {
|
|
|
3962
4397
|
expect(updatedSub!.cancelAt!.getTime()).toBe(cancelAt * 1000);
|
|
3963
4398
|
});
|
|
3964
4399
|
});
|
|
4400
|
+
|
|
4401
|
+
describe("referenceMiddleware", () => {
|
|
4402
|
+
describe("referenceMiddleware - user subscription", () => {
|
|
4403
|
+
it("should pass when no explicit referenceId is provided", async () => {
|
|
4404
|
+
const { client, sessionSetter } = await getTestInstance(
|
|
4405
|
+
{
|
|
4406
|
+
plugins: [stripe(stripeOptions)],
|
|
4407
|
+
},
|
|
4408
|
+
{
|
|
4409
|
+
disableTestUser: true,
|
|
4410
|
+
clientOptions: {
|
|
4411
|
+
plugins: [stripeClient({ subscription: true })],
|
|
4412
|
+
},
|
|
4413
|
+
},
|
|
4414
|
+
);
|
|
4415
|
+
|
|
4416
|
+
await client.signUp.email(testUser, { throw: true });
|
|
4417
|
+
const headers = new Headers();
|
|
4418
|
+
await client.signIn.email(testUser, {
|
|
4419
|
+
throw: true,
|
|
4420
|
+
onSuccess: sessionSetter(headers),
|
|
4421
|
+
});
|
|
4422
|
+
|
|
4423
|
+
const res = await client.subscription.upgrade({
|
|
4424
|
+
plan: "starter",
|
|
4425
|
+
fetchOptions: { headers },
|
|
4426
|
+
});
|
|
4427
|
+
|
|
4428
|
+
expect(res.error).toBeNull();
|
|
4429
|
+
expect(res.data?.url).toBeDefined();
|
|
4430
|
+
});
|
|
4431
|
+
|
|
4432
|
+
it("should pass when referenceId equals user id", async () => {
|
|
4433
|
+
const { client, sessionSetter } = await getTestInstance(
|
|
4434
|
+
{
|
|
4435
|
+
plugins: [stripe(stripeOptions)],
|
|
4436
|
+
},
|
|
4437
|
+
{
|
|
4438
|
+
disableTestUser: true,
|
|
4439
|
+
clientOptions: {
|
|
4440
|
+
plugins: [stripeClient({ subscription: true })],
|
|
4441
|
+
},
|
|
4442
|
+
},
|
|
4443
|
+
);
|
|
4444
|
+
|
|
4445
|
+
const signUpRes = await client.signUp.email(
|
|
4446
|
+
{ ...testUser, email: "ref-test-2@example.com" },
|
|
4447
|
+
{ throw: true },
|
|
4448
|
+
);
|
|
4449
|
+
const headers = new Headers();
|
|
4450
|
+
await client.signIn.email(
|
|
4451
|
+
{ ...testUser, email: "ref-test-2@example.com" },
|
|
4452
|
+
{
|
|
4453
|
+
throw: true,
|
|
4454
|
+
onSuccess: sessionSetter(headers),
|
|
4455
|
+
},
|
|
4456
|
+
);
|
|
4457
|
+
|
|
4458
|
+
const res = await client.subscription.upgrade({
|
|
4459
|
+
plan: "starter",
|
|
4460
|
+
referenceId: signUpRes.user.id,
|
|
4461
|
+
fetchOptions: { headers },
|
|
4462
|
+
});
|
|
4463
|
+
|
|
4464
|
+
expect(res.error).toBeNull();
|
|
4465
|
+
expect(res.data?.url).toBeDefined();
|
|
4466
|
+
});
|
|
4467
|
+
|
|
4468
|
+
it("should reject when authorizeReference is not defined but other referenceId is provided", async () => {
|
|
4469
|
+
const { client, sessionSetter } = await getTestInstance(
|
|
4470
|
+
{
|
|
4471
|
+
plugins: [stripe(stripeOptions)],
|
|
4472
|
+
},
|
|
4473
|
+
{
|
|
4474
|
+
disableTestUser: true,
|
|
4475
|
+
clientOptions: {
|
|
4476
|
+
plugins: [stripeClient({ subscription: true })],
|
|
4477
|
+
},
|
|
4478
|
+
},
|
|
4479
|
+
);
|
|
4480
|
+
|
|
4481
|
+
await client.signUp.email(
|
|
4482
|
+
{ ...testUser, email: "ref-test-3@example.com" },
|
|
4483
|
+
{ throw: true },
|
|
4484
|
+
);
|
|
4485
|
+
const headers = new Headers();
|
|
4486
|
+
await client.signIn.email(
|
|
4487
|
+
{ ...testUser, email: "ref-test-3@example.com" },
|
|
4488
|
+
{
|
|
4489
|
+
throw: true,
|
|
4490
|
+
onSuccess: sessionSetter(headers),
|
|
4491
|
+
},
|
|
4492
|
+
);
|
|
4493
|
+
|
|
4494
|
+
const res = await client.subscription.upgrade({
|
|
4495
|
+
plan: "starter",
|
|
4496
|
+
referenceId: "some-other-id",
|
|
4497
|
+
fetchOptions: { headers },
|
|
4498
|
+
});
|
|
4499
|
+
|
|
4500
|
+
expect(res.error?.code).toBe("REFERENCE_ID_NOT_ALLOWED");
|
|
4501
|
+
});
|
|
4502
|
+
|
|
4503
|
+
it("should reject when authorizeReference returns false", async () => {
|
|
4504
|
+
const stripeOptionsWithAuth: StripeOptions = {
|
|
4505
|
+
...stripeOptions,
|
|
4506
|
+
subscription: {
|
|
4507
|
+
...stripeOptions.subscription,
|
|
4508
|
+
authorizeReference: async () => false,
|
|
4509
|
+
},
|
|
4510
|
+
};
|
|
4511
|
+
|
|
4512
|
+
const { client, sessionSetter } = await getTestInstance(
|
|
4513
|
+
{
|
|
4514
|
+
plugins: [stripe(stripeOptionsWithAuth)],
|
|
4515
|
+
},
|
|
4516
|
+
{
|
|
4517
|
+
disableTestUser: true,
|
|
4518
|
+
clientOptions: {
|
|
4519
|
+
plugins: [stripeClient({ subscription: true })],
|
|
4520
|
+
},
|
|
4521
|
+
},
|
|
4522
|
+
);
|
|
4523
|
+
|
|
4524
|
+
await client.signUp.email(
|
|
4525
|
+
{ ...testUser, email: "ref-test-4@example.com" },
|
|
4526
|
+
{ throw: true },
|
|
4527
|
+
);
|
|
4528
|
+
const headers = new Headers();
|
|
4529
|
+
await client.signIn.email(
|
|
4530
|
+
{ ...testUser, email: "ref-test-4@example.com" },
|
|
4531
|
+
{
|
|
4532
|
+
throw: true,
|
|
4533
|
+
onSuccess: sessionSetter(headers),
|
|
4534
|
+
},
|
|
4535
|
+
);
|
|
4536
|
+
|
|
4537
|
+
const res = await client.subscription.upgrade({
|
|
4538
|
+
plan: "starter",
|
|
4539
|
+
referenceId: "some-other-id",
|
|
4540
|
+
fetchOptions: { headers },
|
|
4541
|
+
});
|
|
4542
|
+
|
|
4543
|
+
expect(res.error?.code).toBe("UNAUTHORIZED");
|
|
4544
|
+
});
|
|
4545
|
+
|
|
4546
|
+
it("should pass when authorizeReference returns true", async () => {
|
|
4547
|
+
const stripeOptionsWithAuth: StripeOptions = {
|
|
4548
|
+
...stripeOptions,
|
|
4549
|
+
subscription: {
|
|
4550
|
+
...stripeOptions.subscription,
|
|
4551
|
+
authorizeReference: async () => true,
|
|
4552
|
+
},
|
|
4553
|
+
};
|
|
4554
|
+
|
|
4555
|
+
const { client, sessionSetter } = await getTestInstance(
|
|
4556
|
+
{
|
|
4557
|
+
plugins: [stripe(stripeOptionsWithAuth)],
|
|
4558
|
+
},
|
|
4559
|
+
{
|
|
4560
|
+
disableTestUser: true,
|
|
4561
|
+
clientOptions: {
|
|
4562
|
+
plugins: [stripeClient({ subscription: true })],
|
|
4563
|
+
},
|
|
4564
|
+
},
|
|
4565
|
+
);
|
|
4566
|
+
|
|
4567
|
+
await client.signUp.email(
|
|
4568
|
+
{ ...testUser, email: "ref-test-5@example.com" },
|
|
4569
|
+
{ throw: true },
|
|
4570
|
+
);
|
|
4571
|
+
const headers = new Headers();
|
|
4572
|
+
await client.signIn.email(
|
|
4573
|
+
{ ...testUser, email: "ref-test-5@example.com" },
|
|
4574
|
+
{
|
|
4575
|
+
throw: true,
|
|
4576
|
+
onSuccess: sessionSetter(headers),
|
|
4577
|
+
},
|
|
4578
|
+
);
|
|
4579
|
+
|
|
4580
|
+
const res = await client.subscription.upgrade({
|
|
4581
|
+
plan: "starter",
|
|
4582
|
+
referenceId: "some-other-id",
|
|
4583
|
+
fetchOptions: { headers },
|
|
4584
|
+
});
|
|
4585
|
+
|
|
4586
|
+
expect(res.error).toBeNull();
|
|
4587
|
+
expect(res.data?.url).toBeDefined();
|
|
4588
|
+
});
|
|
4589
|
+
});
|
|
4590
|
+
|
|
4591
|
+
describe("referenceMiddleware - organization subscription", () => {
|
|
4592
|
+
it("should reject when authorizeReference is not defined", async () => {
|
|
4593
|
+
const { client, sessionSetter } = await getTestInstance(
|
|
4594
|
+
{
|
|
4595
|
+
plugins: [stripe(stripeOptions)],
|
|
4596
|
+
},
|
|
4597
|
+
{
|
|
4598
|
+
disableTestUser: true,
|
|
4599
|
+
clientOptions: {
|
|
4600
|
+
plugins: [stripeClient({ subscription: true })],
|
|
4601
|
+
},
|
|
4602
|
+
},
|
|
4603
|
+
);
|
|
4604
|
+
|
|
4605
|
+
await client.signUp.email(
|
|
4606
|
+
{ ...testUser, email: "org-test-1@example.com" },
|
|
4607
|
+
{ throw: true },
|
|
4608
|
+
);
|
|
4609
|
+
const headers = new Headers();
|
|
4610
|
+
await client.signIn.email(
|
|
4611
|
+
{ ...testUser, email: "org-test-1@example.com" },
|
|
4612
|
+
{
|
|
4613
|
+
throw: true,
|
|
4614
|
+
onSuccess: sessionSetter(headers),
|
|
4615
|
+
},
|
|
4616
|
+
);
|
|
4617
|
+
|
|
4618
|
+
const res = await client.subscription.upgrade({
|
|
4619
|
+
plan: "starter",
|
|
4620
|
+
customerType: "organization",
|
|
4621
|
+
referenceId: "org_123",
|
|
4622
|
+
fetchOptions: { headers },
|
|
4623
|
+
});
|
|
4624
|
+
|
|
4625
|
+
expect(res.error?.code).toBe("ORGANIZATION_SUBSCRIPTION_NOT_ENABLED");
|
|
4626
|
+
});
|
|
4627
|
+
|
|
4628
|
+
it("should reject when no referenceId or activeOrganizationId", async () => {
|
|
4629
|
+
const stripeOptionsWithAuth: StripeOptions = {
|
|
4630
|
+
...stripeOptions,
|
|
4631
|
+
subscription: {
|
|
4632
|
+
...stripeOptions.subscription,
|
|
4633
|
+
authorizeReference: async () => true,
|
|
4634
|
+
},
|
|
4635
|
+
};
|
|
4636
|
+
|
|
4637
|
+
const { client, sessionSetter } = await getTestInstance(
|
|
4638
|
+
{
|
|
4639
|
+
plugins: [stripe(stripeOptionsWithAuth)],
|
|
4640
|
+
},
|
|
4641
|
+
{
|
|
4642
|
+
disableTestUser: true,
|
|
4643
|
+
clientOptions: {
|
|
4644
|
+
plugins: [stripeClient({ subscription: true })],
|
|
4645
|
+
},
|
|
4646
|
+
},
|
|
4647
|
+
);
|
|
4648
|
+
|
|
4649
|
+
await client.signUp.email(
|
|
4650
|
+
{ ...testUser, email: "org-test-2@example.com" },
|
|
4651
|
+
{ throw: true },
|
|
4652
|
+
);
|
|
4653
|
+
const headers = new Headers();
|
|
4654
|
+
await client.signIn.email(
|
|
4655
|
+
{ ...testUser, email: "org-test-2@example.com" },
|
|
4656
|
+
{
|
|
4657
|
+
throw: true,
|
|
4658
|
+
onSuccess: sessionSetter(headers),
|
|
4659
|
+
},
|
|
4660
|
+
);
|
|
4661
|
+
|
|
4662
|
+
const res = await client.subscription.upgrade({
|
|
4663
|
+
plan: "starter",
|
|
4664
|
+
customerType: "organization",
|
|
4665
|
+
fetchOptions: { headers },
|
|
4666
|
+
});
|
|
4667
|
+
|
|
4668
|
+
expect(res.error?.code).toBe("ORGANIZATION_REFERENCE_ID_REQUIRED");
|
|
4669
|
+
});
|
|
4670
|
+
|
|
4671
|
+
it("should reject when authorizeReference returns false", async () => {
|
|
4672
|
+
const stripeOptionsWithAuth: StripeOptions = {
|
|
4673
|
+
...stripeOptions,
|
|
4674
|
+
subscription: {
|
|
4675
|
+
...stripeOptions.subscription,
|
|
4676
|
+
authorizeReference: async () => false,
|
|
4677
|
+
},
|
|
4678
|
+
};
|
|
4679
|
+
|
|
4680
|
+
const { client, sessionSetter } = await getTestInstance(
|
|
4681
|
+
{
|
|
4682
|
+
plugins: [stripe(stripeOptionsWithAuth)],
|
|
4683
|
+
},
|
|
4684
|
+
{
|
|
4685
|
+
disableTestUser: true,
|
|
4686
|
+
clientOptions: {
|
|
4687
|
+
plugins: [stripeClient({ subscription: true })],
|
|
4688
|
+
},
|
|
4689
|
+
},
|
|
4690
|
+
);
|
|
4691
|
+
|
|
4692
|
+
await client.signUp.email(
|
|
4693
|
+
{ ...testUser, email: "org-test-3@example.com" },
|
|
4694
|
+
{ throw: true },
|
|
4695
|
+
);
|
|
4696
|
+
const headers = new Headers();
|
|
4697
|
+
await client.signIn.email(
|
|
4698
|
+
{ ...testUser, email: "org-test-3@example.com" },
|
|
4699
|
+
{
|
|
4700
|
+
throw: true,
|
|
4701
|
+
onSuccess: sessionSetter(headers),
|
|
4702
|
+
},
|
|
4703
|
+
);
|
|
4704
|
+
|
|
4705
|
+
const res = await client.subscription.upgrade({
|
|
4706
|
+
plan: "starter",
|
|
4707
|
+
customerType: "organization",
|
|
4708
|
+
referenceId: "org_123",
|
|
4709
|
+
fetchOptions: { headers },
|
|
4710
|
+
});
|
|
4711
|
+
|
|
4712
|
+
expect(res.error?.code).toBe("UNAUTHORIZED");
|
|
4713
|
+
});
|
|
4714
|
+
|
|
4715
|
+
it("should pass when authorizeReference returns true", async () => {
|
|
4716
|
+
const stripeOptionsWithAuth: StripeOptions = {
|
|
4717
|
+
...stripeOptions,
|
|
4718
|
+
organization: {
|
|
4719
|
+
enabled: true,
|
|
4720
|
+
},
|
|
4721
|
+
subscription: {
|
|
4722
|
+
...stripeOptions.subscription,
|
|
4723
|
+
authorizeReference: async () => true,
|
|
4724
|
+
},
|
|
4725
|
+
};
|
|
4726
|
+
|
|
4727
|
+
const { client, sessionSetter } = await getTestInstance(
|
|
4728
|
+
{
|
|
4729
|
+
plugins: [stripe(stripeOptionsWithAuth)],
|
|
4730
|
+
},
|
|
4731
|
+
{
|
|
4732
|
+
disableTestUser: true,
|
|
4733
|
+
clientOptions: {
|
|
4734
|
+
plugins: [stripeClient({ subscription: true })],
|
|
4735
|
+
},
|
|
4736
|
+
},
|
|
4737
|
+
);
|
|
4738
|
+
|
|
4739
|
+
await client.signUp.email(
|
|
4740
|
+
{ ...testUser, email: "org-test-4@example.com" },
|
|
4741
|
+
{ throw: true },
|
|
4742
|
+
);
|
|
4743
|
+
const headers = new Headers();
|
|
4744
|
+
await client.signIn.email(
|
|
4745
|
+
{ ...testUser, email: "org-test-4@example.com" },
|
|
4746
|
+
{
|
|
4747
|
+
throw: true,
|
|
4748
|
+
onSuccess: sessionSetter(headers),
|
|
4749
|
+
},
|
|
4750
|
+
);
|
|
4751
|
+
|
|
4752
|
+
const res = await client.subscription.upgrade({
|
|
4753
|
+
plan: "starter",
|
|
4754
|
+
customerType: "organization",
|
|
4755
|
+
referenceId: "org_123",
|
|
4756
|
+
fetchOptions: { headers },
|
|
4757
|
+
});
|
|
4758
|
+
|
|
4759
|
+
// Should pass middleware but may fail later due to org not existing
|
|
4760
|
+
// We're testing middleware authorization, not the full flow
|
|
4761
|
+
expect(res.error?.code).not.toBe(
|
|
4762
|
+
"ORGANIZATION_SUBSCRIPTION_NOT_ENABLED",
|
|
4763
|
+
);
|
|
4764
|
+
expect(res.error?.code).not.toBe("UNAUTHORIZED");
|
|
4765
|
+
});
|
|
4766
|
+
});
|
|
4767
|
+
});
|
|
3965
4768
|
});
|