@faire-auth/stripe 1.0.0
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 +24 -0
- package/.turbo/turbo-test.log +1578 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +22 -0
- package/LICENSE +661 -0
- package/dist/client.cjs +15 -0
- package/dist/client.d.cts +25 -0
- package/dist/client.d.mts +25 -0
- package/dist/client.mjs +14 -0
- package/dist/index-6UTHxaqg.d.cts +1110 -0
- package/dist/index-CYV4awaB.d.mts +1110 -0
- package/dist/index.cjs +992 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +968 -0
- package/overrides.d.ts +32 -0
- package/package.json +69 -0
- package/src/client.ts +35 -0
- package/src/hooks.ts +265 -0
- package/src/index.ts +1533 -0
- package/src/schema.ts +92 -0
- package/src/stripe.test.ts +1425 -0
- package/src/types.ts +355 -0
- package/src/utils.ts +70 -0
- package/tsconfig.json +17 -0
- package/tsdown.config.ts +16 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,1425 @@
|
|
|
1
|
+
import { faireAuth, type InferAPI, type InferApp } from "faire-auth";
|
|
2
|
+
import { memoryAdapter } from "faire-auth/adapters/memory";
|
|
3
|
+
import { createAuthClient } from "faire-auth/client";
|
|
4
|
+
import { createCookieCapture } from "faire-auth/cookies";
|
|
5
|
+
import { bearer } from "faire-auth/plugins";
|
|
6
|
+
import Stripe from "stripe";
|
|
7
|
+
import { beforeEach, describe, expectTypeOf, vi } from "vitest";
|
|
8
|
+
import { stripeClient } from "./client";
|
|
9
|
+
import { stripe, type StripePlugin } from "./index";
|
|
10
|
+
import type { StripeOptions, Subscription } from "./types";
|
|
11
|
+
import type { User } from "faire-auth/db";
|
|
12
|
+
|
|
13
|
+
describe("stripe type", (test) => {
|
|
14
|
+
test("should api endpoint exists", () => {
|
|
15
|
+
type Plugins = [
|
|
16
|
+
StripePlugin<{
|
|
17
|
+
stripeClient: Stripe;
|
|
18
|
+
stripeWebhookSecret: string;
|
|
19
|
+
subscription: {
|
|
20
|
+
enabled: false;
|
|
21
|
+
};
|
|
22
|
+
}>,
|
|
23
|
+
];
|
|
24
|
+
type MyApp = InferApp<{
|
|
25
|
+
plugins: Plugins;
|
|
26
|
+
}>;
|
|
27
|
+
type MyAPI = InferAPI<MyApp>;
|
|
28
|
+
expectTypeOf<MyAPI["stripeWebhook"]>().toBeFunction();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("should have subscription endpoints", () => {
|
|
32
|
+
type Plugins = [
|
|
33
|
+
StripePlugin<{
|
|
34
|
+
stripeClient: Stripe;
|
|
35
|
+
stripeWebhookSecret: string;
|
|
36
|
+
subscription: {
|
|
37
|
+
enabled: true;
|
|
38
|
+
plans: [];
|
|
39
|
+
};
|
|
40
|
+
}>,
|
|
41
|
+
];
|
|
42
|
+
type MyApp = InferApp<{
|
|
43
|
+
plugins: Plugins;
|
|
44
|
+
}>;
|
|
45
|
+
type MyAPI = InferAPI<MyApp>;
|
|
46
|
+
expectTypeOf<MyAPI["stripeWebhook"]>().toBeFunction();
|
|
47
|
+
expectTypeOf<MyAPI["subscriptionSuccess"]>().toBeFunction();
|
|
48
|
+
expectTypeOf<MyAPI["listActiveSubscriptions"]>().toBeFunction();
|
|
49
|
+
expectTypeOf<MyAPI["cancelSubscriptionCallback"]>().toBeFunction();
|
|
50
|
+
expectTypeOf<MyAPI["cancelSubscription"]>().toBeFunction();
|
|
51
|
+
expectTypeOf<MyAPI["restoreSubscription"]>().toBeFunction();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("stripe", async (test) => {
|
|
56
|
+
const mockStripe = {
|
|
57
|
+
prices: {
|
|
58
|
+
list: vi.fn().mockResolvedValue({ data: [{ id: "price_lookup_123" }] }),
|
|
59
|
+
},
|
|
60
|
+
customers: {
|
|
61
|
+
create: vi.fn().mockResolvedValue({ id: "cus_mock123" }),
|
|
62
|
+
},
|
|
63
|
+
checkout: {
|
|
64
|
+
sessions: {
|
|
65
|
+
create: vi.fn().mockResolvedValue({
|
|
66
|
+
url: "https://checkout.stripe.com/mock",
|
|
67
|
+
id: "",
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
billingPortal: {
|
|
72
|
+
sessions: {
|
|
73
|
+
create: vi
|
|
74
|
+
.fn()
|
|
75
|
+
.mockResolvedValue({ url: "https://billing.stripe.com/mock" }),
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
subscriptions: {
|
|
79
|
+
retrieve: vi.fn(),
|
|
80
|
+
list: vi.fn().mockResolvedValue({ data: [] }),
|
|
81
|
+
update: vi.fn(),
|
|
82
|
+
},
|
|
83
|
+
webhooks: {
|
|
84
|
+
constructEvent: vi.fn(),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const _stripe = mockStripe as unknown as Stripe;
|
|
89
|
+
const data = {
|
|
90
|
+
user: [],
|
|
91
|
+
session: [],
|
|
92
|
+
verification: [],
|
|
93
|
+
account: [],
|
|
94
|
+
customer: [],
|
|
95
|
+
subscription: [],
|
|
96
|
+
};
|
|
97
|
+
const memory = memoryAdapter(data);
|
|
98
|
+
const stripeOptions = {
|
|
99
|
+
stripeClient: _stripe,
|
|
100
|
+
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
101
|
+
createCustomerOnSignUp: true,
|
|
102
|
+
subscription: {
|
|
103
|
+
enabled: true,
|
|
104
|
+
plans: [
|
|
105
|
+
{
|
|
106
|
+
priceId: process.env.STRIPE_PRICE_ID_1!,
|
|
107
|
+
name: "starter",
|
|
108
|
+
lookupKey: "lookup_key_123",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
priceId: process.env.STRIPE_PRICE_ID_2!,
|
|
112
|
+
name: "premium",
|
|
113
|
+
lookupKey: "lookup_key_234",
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
} satisfies StripeOptions;
|
|
118
|
+
const opts = {
|
|
119
|
+
database: memory,
|
|
120
|
+
baseURL: "http://localhost:3000",
|
|
121
|
+
// database: new Database(":memory:"),
|
|
122
|
+
emailAndPassword: {
|
|
123
|
+
enabled: true,
|
|
124
|
+
},
|
|
125
|
+
plugins: [stripe(stripeOptions)],
|
|
126
|
+
};
|
|
127
|
+
const auth = faireAuth(opts);
|
|
128
|
+
const ctx = auth.$context;
|
|
129
|
+
const app = auth.$Infer.App(opts);
|
|
130
|
+
const authClient = createAuthClient<typeof app>()({
|
|
131
|
+
baseURL: "http://localhost:3000",
|
|
132
|
+
plugins: [
|
|
133
|
+
bearer(),
|
|
134
|
+
stripeClient({
|
|
135
|
+
subscription: true,
|
|
136
|
+
}),
|
|
137
|
+
],
|
|
138
|
+
fetchOptions: {
|
|
139
|
+
customFetchImpl: async (url, init) =>
|
|
140
|
+
auth.handler(new Request(url, init)),
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const testUser = {
|
|
145
|
+
email: "test@email.com",
|
|
146
|
+
password: "password",
|
|
147
|
+
name: "Test User",
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
beforeEach(() => {
|
|
151
|
+
data.user = [];
|
|
152
|
+
data.session = [];
|
|
153
|
+
data.verification = [];
|
|
154
|
+
data.account = [];
|
|
155
|
+
data.customer = [];
|
|
156
|
+
data.subscription = [];
|
|
157
|
+
|
|
158
|
+
vi.clearAllMocks();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("should create a customer on sign up", async ({ expect }) => {
|
|
162
|
+
const userRes = await authClient.signUp.email.$post({ json: testUser });
|
|
163
|
+
expect(userRes.data).not.toBeNull();
|
|
164
|
+
if (userRes.data === null) throw new Error("Can't continue");
|
|
165
|
+
const res = await ctx.adapter.findOne<User>({
|
|
166
|
+
model: "user",
|
|
167
|
+
where: [
|
|
168
|
+
{
|
|
169
|
+
field: "id",
|
|
170
|
+
value: userRes.data.data.user!.id,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
expect(res).toMatchObject({
|
|
175
|
+
id: expect.any(String),
|
|
176
|
+
stripeCustomerId: expect.any(String),
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("should create a subscription", async ({ expect }) => {
|
|
181
|
+
const userRes = await authClient.signUp.email.$post({ json: testUser });
|
|
182
|
+
expect(userRes.data).not.toBeNull();
|
|
183
|
+
if (userRes.data === null) throw new Error("Can't continue");
|
|
184
|
+
|
|
185
|
+
const headers = new Headers();
|
|
186
|
+
await authClient.signIn.email.$post(
|
|
187
|
+
{ json: testUser },
|
|
188
|
+
{
|
|
189
|
+
fetchOptions: {
|
|
190
|
+
onSuccess: createCookieCapture(headers)(),
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const res = await authClient.subscription.upgrade.$post(
|
|
196
|
+
{
|
|
197
|
+
json: {
|
|
198
|
+
plan: "starter",
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
headers,
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
expect(res.data?.url).toBeDefined();
|
|
206
|
+
const subscription = await ctx.adapter.findOne<Subscription>({
|
|
207
|
+
model: "subscription",
|
|
208
|
+
where: [
|
|
209
|
+
{
|
|
210
|
+
field: "referenceId",
|
|
211
|
+
value: userRes.data.data.user!.id,
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
expect(subscription).toMatchObject({
|
|
216
|
+
id: expect.any(String),
|
|
217
|
+
plan: "starter",
|
|
218
|
+
referenceId: userRes.data.data.user!.id,
|
|
219
|
+
stripeCustomerId: expect.any(String),
|
|
220
|
+
status: "incomplete",
|
|
221
|
+
periodStart: undefined,
|
|
222
|
+
cancelAtPeriodEnd: false,
|
|
223
|
+
trialStart: undefined,
|
|
224
|
+
trialEnd: undefined,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("should list active subscriptions", async ({ expect }) => {
|
|
229
|
+
const userRes = await authClient.signUp.email.$post({
|
|
230
|
+
json: {
|
|
231
|
+
...testUser,
|
|
232
|
+
email: "list-test@email.com",
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
expect(userRes.data).not.toBeNull();
|
|
236
|
+
if (userRes.data === null) throw new Error("Can't continue");
|
|
237
|
+
const userId = userRes.data.data.user!.id;
|
|
238
|
+
|
|
239
|
+
const headers = new Headers();
|
|
240
|
+
await authClient.signIn.email.$post(
|
|
241
|
+
{
|
|
242
|
+
json: {
|
|
243
|
+
...testUser,
|
|
244
|
+
email: "list-test@email.com",
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
fetchOptions: {
|
|
249
|
+
onSuccess: createCookieCapture(headers)(),
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const listRes = await authClient.subscription.list.$get(
|
|
255
|
+
{ query: {} },
|
|
256
|
+
{
|
|
257
|
+
headers,
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
expect(Array.isArray(listRes.data)).toBe(true);
|
|
262
|
+
|
|
263
|
+
await authClient.subscription.upgrade.$post(
|
|
264
|
+
{
|
|
265
|
+
json: {
|
|
266
|
+
plan: "starter",
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
headers,
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
const listBeforeActive = await authClient.subscription.list.$get(
|
|
274
|
+
{ query: {} },
|
|
275
|
+
{
|
|
276
|
+
headers,
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
expect(listBeforeActive.data?.length).toBe(0);
|
|
280
|
+
// Update the subscription status to active
|
|
281
|
+
await ctx.adapter.update({
|
|
282
|
+
model: "subscription",
|
|
283
|
+
update: {
|
|
284
|
+
status: "active",
|
|
285
|
+
},
|
|
286
|
+
where: [
|
|
287
|
+
{
|
|
288
|
+
field: "referenceId",
|
|
289
|
+
value: userId,
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
});
|
|
293
|
+
const listAfterRes = await authClient.subscription.list.$get(
|
|
294
|
+
{ query: {} },
|
|
295
|
+
{
|
|
296
|
+
headers,
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
expect(listAfterRes.data?.length).toBeGreaterThan(0);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("should handle subscription webhook events", async ({ expect }) => {
|
|
303
|
+
const { id: testReferenceId } = await ctx.adapter.create({
|
|
304
|
+
model: "user",
|
|
305
|
+
data: {
|
|
306
|
+
email: "test@email.com",
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
const { id: testSubscriptionId } = await ctx.adapter.create({
|
|
310
|
+
model: "subscription",
|
|
311
|
+
data: {
|
|
312
|
+
referenceId: testReferenceId,
|
|
313
|
+
stripeCustomerId: "cus_mock123",
|
|
314
|
+
status: "active",
|
|
315
|
+
plan: "starter",
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
const mockCheckoutSessionEvent = {
|
|
319
|
+
type: "checkout.session.completed",
|
|
320
|
+
data: {
|
|
321
|
+
object: {
|
|
322
|
+
mode: "subscription",
|
|
323
|
+
subscription: testSubscriptionId,
|
|
324
|
+
metadata: {
|
|
325
|
+
referenceId: testReferenceId,
|
|
326
|
+
subscriptionId: testSubscriptionId,
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const mockSubscription = {
|
|
333
|
+
id: testSubscriptionId,
|
|
334
|
+
status: "active",
|
|
335
|
+
items: {
|
|
336
|
+
data: [
|
|
337
|
+
{
|
|
338
|
+
price: { id: process.env.STRIPE_PRICE_ID_1 },
|
|
339
|
+
quantity: 1,
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
},
|
|
343
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
344
|
+
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const stripeForTest = {
|
|
348
|
+
...stripeOptions.stripeClient,
|
|
349
|
+
subscriptions: {
|
|
350
|
+
...stripeOptions.stripeClient.subscriptions,
|
|
351
|
+
retrieve: vi.fn().mockResolvedValue(mockSubscription),
|
|
352
|
+
},
|
|
353
|
+
webhooks: {
|
|
354
|
+
constructEventAsync: vi
|
|
355
|
+
.fn()
|
|
356
|
+
.mockResolvedValue(mockCheckoutSessionEvent),
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const testOptions = {
|
|
361
|
+
...stripeOptions,
|
|
362
|
+
stripeClient: stripeForTest as unknown as Stripe,
|
|
363
|
+
stripeWebhookSecret: "test_secret",
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const testAuth = faireAuth({
|
|
367
|
+
baseURL: "http://localhost:3000",
|
|
368
|
+
database: memory,
|
|
369
|
+
emailAndPassword: {
|
|
370
|
+
enabled: true,
|
|
371
|
+
},
|
|
372
|
+
plugins: [stripe(testOptions)],
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const testCtx = await testAuth.$context;
|
|
376
|
+
|
|
377
|
+
const mockRequest = new Request(
|
|
378
|
+
"http://localhost:3000/api/auth/stripe/webhook",
|
|
379
|
+
{
|
|
380
|
+
method: "POST",
|
|
381
|
+
headers: {
|
|
382
|
+
"stripe-signature": "test_signature",
|
|
383
|
+
},
|
|
384
|
+
body: JSON.stringify(mockCheckoutSessionEvent),
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
const response = await testAuth.handler(mockRequest);
|
|
388
|
+
expect(response.status).toBe(200);
|
|
389
|
+
|
|
390
|
+
const updatedSubscription = await testCtx.adapter.findOne<Subscription>({
|
|
391
|
+
model: "subscription",
|
|
392
|
+
where: [
|
|
393
|
+
{
|
|
394
|
+
field: "id",
|
|
395
|
+
value: testSubscriptionId,
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
expect(updatedSubscription).toMatchObject({
|
|
401
|
+
id: testSubscriptionId,
|
|
402
|
+
status: "active",
|
|
403
|
+
periodStart: expect.any(Date),
|
|
404
|
+
periodEnd: expect.any(Date),
|
|
405
|
+
plan: "starter",
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("should handle subscription webhook events with trial", async ({
|
|
410
|
+
expect,
|
|
411
|
+
}) => {
|
|
412
|
+
const { id: testReferenceId } = await ctx.adapter.create({
|
|
413
|
+
model: "user",
|
|
414
|
+
data: {
|
|
415
|
+
email: "test@email.com",
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
const { id: testSubscriptionId } = await ctx.adapter.create({
|
|
419
|
+
model: "subscription",
|
|
420
|
+
data: {
|
|
421
|
+
referenceId: testReferenceId,
|
|
422
|
+
stripeCustomerId: "cus_mock123",
|
|
423
|
+
status: "incomplete",
|
|
424
|
+
plan: "starter",
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
const mockCheckoutSessionEvent = {
|
|
428
|
+
type: "checkout.session.completed",
|
|
429
|
+
data: {
|
|
430
|
+
object: {
|
|
431
|
+
mode: "subscription",
|
|
432
|
+
subscription: testSubscriptionId,
|
|
433
|
+
metadata: {
|
|
434
|
+
referenceId: testReferenceId,
|
|
435
|
+
subscriptionId: testSubscriptionId,
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const mockSubscription = {
|
|
442
|
+
id: testSubscriptionId,
|
|
443
|
+
status: "active",
|
|
444
|
+
items: {
|
|
445
|
+
data: [
|
|
446
|
+
{
|
|
447
|
+
price: { id: process.env.STRIPE_PRICE_ID_1 },
|
|
448
|
+
quantity: 1,
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
},
|
|
452
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
453
|
+
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
454
|
+
trial_start: Math.floor(Date.now() / 1000),
|
|
455
|
+
trial_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const stripeForTest = {
|
|
459
|
+
...stripeOptions.stripeClient,
|
|
460
|
+
subscriptions: {
|
|
461
|
+
...stripeOptions.stripeClient.subscriptions,
|
|
462
|
+
retrieve: vi.fn().mockResolvedValue(mockSubscription),
|
|
463
|
+
},
|
|
464
|
+
webhooks: {
|
|
465
|
+
constructEventAsync: vi
|
|
466
|
+
.fn()
|
|
467
|
+
.mockResolvedValue(mockCheckoutSessionEvent),
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const testOptions = {
|
|
472
|
+
...stripeOptions,
|
|
473
|
+
stripeClient: stripeForTest as unknown as Stripe,
|
|
474
|
+
stripeWebhookSecret: "test_secret",
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const testAuth = faireAuth({
|
|
478
|
+
baseURL: "http://localhost:3000",
|
|
479
|
+
database: memory,
|
|
480
|
+
emailAndPassword: {
|
|
481
|
+
enabled: true,
|
|
482
|
+
},
|
|
483
|
+
plugins: [stripe(testOptions)],
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const testCtx = await testAuth.$context;
|
|
487
|
+
|
|
488
|
+
const mockRequest = new Request(
|
|
489
|
+
"http://localhost:3000/api/auth/stripe/webhook",
|
|
490
|
+
{
|
|
491
|
+
method: "POST",
|
|
492
|
+
headers: {
|
|
493
|
+
"stripe-signature": "test_signature",
|
|
494
|
+
},
|
|
495
|
+
body: JSON.stringify(mockCheckoutSessionEvent),
|
|
496
|
+
},
|
|
497
|
+
);
|
|
498
|
+
const response = await testAuth.handler(mockRequest);
|
|
499
|
+
expect(response.status).toBe(200);
|
|
500
|
+
|
|
501
|
+
const updatedSubscription = await testCtx.adapter.findOne<Subscription>({
|
|
502
|
+
model: "subscription",
|
|
503
|
+
where: [
|
|
504
|
+
{
|
|
505
|
+
field: "id",
|
|
506
|
+
value: testSubscriptionId,
|
|
507
|
+
},
|
|
508
|
+
],
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
expect(updatedSubscription).toMatchObject({
|
|
512
|
+
id: testSubscriptionId,
|
|
513
|
+
status: "active",
|
|
514
|
+
periodStart: expect.any(Date),
|
|
515
|
+
periodEnd: expect.any(Date),
|
|
516
|
+
plan: "starter",
|
|
517
|
+
trialStart: expect.any(Date),
|
|
518
|
+
trialEnd: expect.any(Date),
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const { id: userId } = await ctx.adapter.create({
|
|
523
|
+
model: "user",
|
|
524
|
+
data: {
|
|
525
|
+
email: "delete-test@email.com",
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("should handle subscription deletion webhook", async ({ expect }) => {
|
|
530
|
+
const subId = "test_sub_delete";
|
|
531
|
+
|
|
532
|
+
await ctx.adapter.create({
|
|
533
|
+
model: "subscription",
|
|
534
|
+
data: {
|
|
535
|
+
referenceId: userId,
|
|
536
|
+
stripeCustomerId: "cus_delete_test",
|
|
537
|
+
status: "active",
|
|
538
|
+
plan: "starter",
|
|
539
|
+
stripeSubscriptionId: "sub_delete_test",
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
const subscription = await ctx.adapter.findOne<Subscription>({
|
|
544
|
+
model: "subscription",
|
|
545
|
+
where: [
|
|
546
|
+
{
|
|
547
|
+
field: "referenceId",
|
|
548
|
+
value: userId,
|
|
549
|
+
},
|
|
550
|
+
],
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const mockDeleteEvent = {
|
|
554
|
+
type: "customer.subscription.deleted",
|
|
555
|
+
data: {
|
|
556
|
+
object: {
|
|
557
|
+
id: "sub_delete_test",
|
|
558
|
+
customer: subscription?.stripeCustomerId,
|
|
559
|
+
status: "canceled",
|
|
560
|
+
metadata: {
|
|
561
|
+
referenceId: subscription?.referenceId,
|
|
562
|
+
subscriptionId: subscription?.id,
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const stripeForTest = {
|
|
569
|
+
...stripeOptions.stripeClient,
|
|
570
|
+
webhooks: {
|
|
571
|
+
constructEventAsync: vi.fn().mockResolvedValue(mockDeleteEvent),
|
|
572
|
+
},
|
|
573
|
+
subscriptions: {
|
|
574
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
575
|
+
status: "canceled",
|
|
576
|
+
id: subId,
|
|
577
|
+
}),
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const testOptions = {
|
|
582
|
+
...stripeOptions,
|
|
583
|
+
stripeClient: stripeForTest as unknown as Stripe,
|
|
584
|
+
stripeWebhookSecret: "test_secret",
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const testAuth = faireAuth({
|
|
588
|
+
baseURL: "http://localhost:3000",
|
|
589
|
+
emailAndPassword: {
|
|
590
|
+
enabled: true,
|
|
591
|
+
},
|
|
592
|
+
database: memory,
|
|
593
|
+
plugins: [stripe(testOptions)],
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
const mockRequest = new Request(
|
|
597
|
+
"http://localhost:3000/api/auth/stripe/webhook",
|
|
598
|
+
{
|
|
599
|
+
method: "POST",
|
|
600
|
+
headers: {
|
|
601
|
+
"stripe-signature": "test_signature",
|
|
602
|
+
},
|
|
603
|
+
body: JSON.stringify(mockDeleteEvent),
|
|
604
|
+
},
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
const response = await testAuth.handler(mockRequest);
|
|
608
|
+
expect(response.status).toBe(200);
|
|
609
|
+
|
|
610
|
+
if (subscription) {
|
|
611
|
+
const updatedSubscription = await ctx.adapter.findOne<Subscription>({
|
|
612
|
+
model: "subscription",
|
|
613
|
+
where: [
|
|
614
|
+
{
|
|
615
|
+
field: "id",
|
|
616
|
+
value: subscription.id,
|
|
617
|
+
},
|
|
618
|
+
],
|
|
619
|
+
});
|
|
620
|
+
expect(updatedSubscription?.status).toBe("canceled");
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("should execute subscription event handlers", async ({ expect }) => {
|
|
625
|
+
const onSubscriptionComplete = vi.fn();
|
|
626
|
+
const onSubscriptionUpdate = vi.fn();
|
|
627
|
+
const onSubscriptionCancel = vi.fn();
|
|
628
|
+
const onSubscriptionDeleted = vi.fn();
|
|
629
|
+
|
|
630
|
+
const testOptions = {
|
|
631
|
+
...stripeOptions,
|
|
632
|
+
subscription: {
|
|
633
|
+
...stripeOptions.subscription,
|
|
634
|
+
onSubscriptionComplete,
|
|
635
|
+
onSubscriptionUpdate,
|
|
636
|
+
onSubscriptionCancel,
|
|
637
|
+
onSubscriptionDeleted,
|
|
638
|
+
},
|
|
639
|
+
stripeWebhookSecret: "test_secret",
|
|
640
|
+
} as unknown as StripeOptions;
|
|
641
|
+
|
|
642
|
+
const testAuth = faireAuth({
|
|
643
|
+
baseURL: "http://localhost:3000",
|
|
644
|
+
database: memory,
|
|
645
|
+
emailAndPassword: {
|
|
646
|
+
enabled: true,
|
|
647
|
+
},
|
|
648
|
+
plugins: [stripe(testOptions)],
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// Test subscription complete handler
|
|
652
|
+
const completeEvent = {
|
|
653
|
+
type: "checkout.session.completed",
|
|
654
|
+
data: {
|
|
655
|
+
object: {
|
|
656
|
+
mode: "subscription",
|
|
657
|
+
subscription: "sub_123",
|
|
658
|
+
metadata: {
|
|
659
|
+
referenceId: "user_123",
|
|
660
|
+
subscriptionId: "sub_123",
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const mockSubscription = {
|
|
667
|
+
status: "active",
|
|
668
|
+
items: {
|
|
669
|
+
data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
|
|
670
|
+
},
|
|
671
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
672
|
+
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
const mockStripeForEvents = {
|
|
676
|
+
...testOptions.stripeClient,
|
|
677
|
+
subscriptions: {
|
|
678
|
+
retrieve: vi.fn().mockResolvedValue(mockSubscription),
|
|
679
|
+
},
|
|
680
|
+
webhooks: {
|
|
681
|
+
constructEventAsync: vi.fn().mockResolvedValue(completeEvent),
|
|
682
|
+
},
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
const eventTestOptions = {
|
|
686
|
+
...testOptions,
|
|
687
|
+
stripeClient: mockStripeForEvents as unknown as Stripe,
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const eventTestAuth = faireAuth({
|
|
691
|
+
baseURL: "http://localhost:3000",
|
|
692
|
+
database: memory,
|
|
693
|
+
emailAndPassword: { enabled: true },
|
|
694
|
+
plugins: [stripe(eventTestOptions)],
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
const { id: testSubscriptionId } = await ctx.adapter.create({
|
|
698
|
+
model: "subscription",
|
|
699
|
+
data: {
|
|
700
|
+
referenceId: userId,
|
|
701
|
+
stripeCustomerId: "cus_123",
|
|
702
|
+
stripeSubscriptionId: "sub_123",
|
|
703
|
+
status: "incomplete",
|
|
704
|
+
plan: "starter",
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
const webhookRequest = new Request(
|
|
709
|
+
"http://localhost:3000/api/auth/stripe/webhook",
|
|
710
|
+
{
|
|
711
|
+
method: "POST",
|
|
712
|
+
headers: {
|
|
713
|
+
"stripe-signature": "test_signature",
|
|
714
|
+
},
|
|
715
|
+
body: JSON.stringify(completeEvent),
|
|
716
|
+
},
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
await eventTestAuth.handler(webhookRequest);
|
|
720
|
+
|
|
721
|
+
expect(onSubscriptionComplete).toHaveBeenCalledWith(
|
|
722
|
+
expect.objectContaining({
|
|
723
|
+
event: expect.any(Object),
|
|
724
|
+
subscription: expect.any(Object),
|
|
725
|
+
stripeSubscription: expect.any(Object),
|
|
726
|
+
plan: expect.any(Object),
|
|
727
|
+
}),
|
|
728
|
+
expect.objectContaining({
|
|
729
|
+
req: expect.any(Object),
|
|
730
|
+
get: expect.any(Function),
|
|
731
|
+
}),
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
const updateEvent = {
|
|
735
|
+
type: "customer.subscription.updated",
|
|
736
|
+
data: {
|
|
737
|
+
object: {
|
|
738
|
+
id: testSubscriptionId,
|
|
739
|
+
customer: "cus_123",
|
|
740
|
+
status: "active",
|
|
741
|
+
items: {
|
|
742
|
+
data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
|
|
743
|
+
},
|
|
744
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
745
|
+
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
const updateRequest = new Request(
|
|
751
|
+
"http://localhost:3000/api/auth/stripe/webhook",
|
|
752
|
+
{
|
|
753
|
+
method: "POST",
|
|
754
|
+
headers: {
|
|
755
|
+
"stripe-signature": "test_signature",
|
|
756
|
+
},
|
|
757
|
+
body: JSON.stringify(updateEvent),
|
|
758
|
+
},
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
|
|
762
|
+
updateEvent,
|
|
763
|
+
);
|
|
764
|
+
await eventTestAuth.handler(updateRequest);
|
|
765
|
+
expect(onSubscriptionUpdate).toHaveBeenCalledWith(
|
|
766
|
+
expect.objectContaining({
|
|
767
|
+
event: expect.any(Object),
|
|
768
|
+
subscription: expect.any(Object),
|
|
769
|
+
}),
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
const userCancelEvent = {
|
|
773
|
+
type: "customer.subscription.updated",
|
|
774
|
+
data: {
|
|
775
|
+
object: {
|
|
776
|
+
id: testSubscriptionId,
|
|
777
|
+
customer: "cus_123",
|
|
778
|
+
status: "active",
|
|
779
|
+
cancel_at_period_end: true,
|
|
780
|
+
cancellation_details: {
|
|
781
|
+
reason: "cancellation_requested",
|
|
782
|
+
comment: "Customer canceled subscription",
|
|
783
|
+
},
|
|
784
|
+
items: {
|
|
785
|
+
data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
|
|
786
|
+
},
|
|
787
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
788
|
+
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
const userCancelRequest = new Request(
|
|
794
|
+
"http://localhost:3000/api/auth/stripe/webhook",
|
|
795
|
+
{
|
|
796
|
+
method: "POST",
|
|
797
|
+
headers: {
|
|
798
|
+
"stripe-signature": "test_signature",
|
|
799
|
+
},
|
|
800
|
+
body: JSON.stringify(userCancelEvent),
|
|
801
|
+
},
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
|
|
805
|
+
userCancelEvent,
|
|
806
|
+
);
|
|
807
|
+
await eventTestAuth.handler(userCancelRequest);
|
|
808
|
+
const cancelEvent = {
|
|
809
|
+
type: "customer.subscription.updated",
|
|
810
|
+
data: {
|
|
811
|
+
object: {
|
|
812
|
+
id: testSubscriptionId,
|
|
813
|
+
customer: "cus_123",
|
|
814
|
+
status: "active",
|
|
815
|
+
cancel_at_period_end: true,
|
|
816
|
+
items: {
|
|
817
|
+
data: [{ price: { id: process.env.STRIPE_PRICE_ID_1 } }],
|
|
818
|
+
},
|
|
819
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
820
|
+
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
|
821
|
+
},
|
|
822
|
+
},
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const cancelRequest = new Request(
|
|
826
|
+
"http://localhost:3000/api/auth/stripe/webhook",
|
|
827
|
+
{
|
|
828
|
+
method: "POST",
|
|
829
|
+
headers: {
|
|
830
|
+
"stripe-signature": "test_signature",
|
|
831
|
+
},
|
|
832
|
+
body: JSON.stringify(cancelEvent),
|
|
833
|
+
},
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
|
|
837
|
+
cancelEvent,
|
|
838
|
+
);
|
|
839
|
+
await eventTestAuth.handler(cancelRequest);
|
|
840
|
+
|
|
841
|
+
expect(onSubscriptionCancel).toHaveBeenCalled();
|
|
842
|
+
|
|
843
|
+
const deleteEvent = {
|
|
844
|
+
type: "customer.subscription.deleted",
|
|
845
|
+
data: {
|
|
846
|
+
object: {
|
|
847
|
+
id: testSubscriptionId,
|
|
848
|
+
customer: "cus_123",
|
|
849
|
+
status: "canceled",
|
|
850
|
+
metadata: {
|
|
851
|
+
referenceId: userId,
|
|
852
|
+
subscriptionId: testSubscriptionId,
|
|
853
|
+
},
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
const deleteRequest = new Request(
|
|
859
|
+
"http://localhost:3000/api/auth/stripe/webhook",
|
|
860
|
+
{
|
|
861
|
+
method: "POST",
|
|
862
|
+
headers: {
|
|
863
|
+
"stripe-signature": "test_signature",
|
|
864
|
+
},
|
|
865
|
+
body: JSON.stringify(deleteEvent),
|
|
866
|
+
},
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
mockStripeForEvents.webhooks.constructEventAsync.mockReturnValue(
|
|
870
|
+
deleteEvent,
|
|
871
|
+
);
|
|
872
|
+
await eventTestAuth.handler(deleteRequest);
|
|
873
|
+
|
|
874
|
+
expect(onSubscriptionDeleted).toHaveBeenCalled();
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test("should allow seat upgrades for the same plan", async ({ expect }) => {
|
|
878
|
+
const userRes = await authClient.signUp.email.$post({
|
|
879
|
+
json: {
|
|
880
|
+
...testUser,
|
|
881
|
+
email: "seat-upgrade@email.com",
|
|
882
|
+
},
|
|
883
|
+
});
|
|
884
|
+
expect(userRes.data).not.toBeNull();
|
|
885
|
+
if (userRes.data === null) throw new Error("Can't continue");
|
|
886
|
+
|
|
887
|
+
const headers = new Headers();
|
|
888
|
+
await authClient.signIn.email.$post(
|
|
889
|
+
{
|
|
890
|
+
json: {
|
|
891
|
+
...testUser,
|
|
892
|
+
email: "seat-upgrade@email.com",
|
|
893
|
+
},
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
fetchOptions: {
|
|
897
|
+
onSuccess: createCookieCapture(headers)(),
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
await authClient.subscription.upgrade.$post(
|
|
903
|
+
{
|
|
904
|
+
json: {
|
|
905
|
+
plan: "starter",
|
|
906
|
+
seats: 1,
|
|
907
|
+
},
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
headers,
|
|
911
|
+
},
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
await ctx.adapter.update({
|
|
915
|
+
model: "subscription",
|
|
916
|
+
update: {
|
|
917
|
+
status: "active",
|
|
918
|
+
},
|
|
919
|
+
where: [
|
|
920
|
+
{
|
|
921
|
+
field: "referenceId",
|
|
922
|
+
value: userRes.data.data.user!.id,
|
|
923
|
+
},
|
|
924
|
+
],
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
const upgradeRes = await authClient.subscription.upgrade.$post(
|
|
928
|
+
{
|
|
929
|
+
json: {
|
|
930
|
+
plan: "starter",
|
|
931
|
+
seats: 5,
|
|
932
|
+
},
|
|
933
|
+
},
|
|
934
|
+
{
|
|
935
|
+
headers,
|
|
936
|
+
},
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
expect(upgradeRes.data?.url).toBeDefined();
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test("should prevent duplicate subscriptions with same plan and same seats", async ({
|
|
943
|
+
expect,
|
|
944
|
+
}) => {
|
|
945
|
+
const userRes = await authClient.signUp.email.$post({
|
|
946
|
+
json: {
|
|
947
|
+
...testUser,
|
|
948
|
+
email: "duplicate-prevention@email.com",
|
|
949
|
+
},
|
|
950
|
+
});
|
|
951
|
+
expect(userRes.data).not.toBeNull();
|
|
952
|
+
if (userRes.data === null) throw new Error("Can't continue");
|
|
953
|
+
|
|
954
|
+
const headers = new Headers();
|
|
955
|
+
await authClient.signIn.email.$post(
|
|
956
|
+
{
|
|
957
|
+
json: {
|
|
958
|
+
...testUser,
|
|
959
|
+
email: "duplicate-prevention@email.com",
|
|
960
|
+
},
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
fetchOptions: {
|
|
964
|
+
onSuccess: createCookieCapture(headers)(),
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
await authClient.subscription.upgrade.$post(
|
|
970
|
+
{
|
|
971
|
+
json: {
|
|
972
|
+
plan: "starter",
|
|
973
|
+
seats: 3,
|
|
974
|
+
},
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
headers,
|
|
978
|
+
},
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
await ctx.adapter.update({
|
|
982
|
+
model: "subscription",
|
|
983
|
+
update: {
|
|
984
|
+
status: "active",
|
|
985
|
+
seats: 3,
|
|
986
|
+
},
|
|
987
|
+
where: [
|
|
988
|
+
{
|
|
989
|
+
field: "referenceId",
|
|
990
|
+
value: userRes.data.data.user!.id,
|
|
991
|
+
},
|
|
992
|
+
],
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
const upgradeRes = await authClient.subscription.upgrade.$post(
|
|
996
|
+
{
|
|
997
|
+
json: {
|
|
998
|
+
plan: "starter",
|
|
999
|
+
seats: 3,
|
|
1000
|
+
},
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
headers,
|
|
1004
|
+
},
|
|
1005
|
+
);
|
|
1006
|
+
|
|
1007
|
+
expect(upgradeRes.error).toBeDefined();
|
|
1008
|
+
if (upgradeRes.error == null) throw new Error("never");
|
|
1009
|
+
expect(upgradeRes.error).toHaveProperty("message");
|
|
1010
|
+
if (!("message" in upgradeRes.error)) throw new Error("never");
|
|
1011
|
+
expect(upgradeRes.error?.message).toContain("already subscribed");
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test("should only call Stripe customers.create once for signup and upgrade", async ({
|
|
1015
|
+
expect,
|
|
1016
|
+
}) => {
|
|
1017
|
+
const userRes = await authClient.signUp.email.$post({
|
|
1018
|
+
json: { ...testUser, email: "single-create@email.com" },
|
|
1019
|
+
});
|
|
1020
|
+
expect(userRes.data).not.toBeNull();
|
|
1021
|
+
if (userRes.data === null) throw new Error("Can't continue");
|
|
1022
|
+
|
|
1023
|
+
const headers = new Headers();
|
|
1024
|
+
await authClient.signIn.email.$post(
|
|
1025
|
+
{ json: { ...testUser, email: "single-create@email.com" } },
|
|
1026
|
+
{
|
|
1027
|
+
fetchOptions: {
|
|
1028
|
+
onSuccess: createCookieCapture(headers)(),
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
await authClient.subscription.upgrade.$post(
|
|
1034
|
+
{
|
|
1035
|
+
json: {
|
|
1036
|
+
plan: "starter",
|
|
1037
|
+
},
|
|
1038
|
+
},
|
|
1039
|
+
{
|
|
1040
|
+
headers,
|
|
1041
|
+
},
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
expect(mockStripe.customers.create).toHaveBeenCalledTimes(1);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
test("should create billing portal session", async ({ expect }) => {
|
|
1048
|
+
await authClient.signUp.email.$post({
|
|
1049
|
+
json: {
|
|
1050
|
+
...testUser,
|
|
1051
|
+
email: "billing-portal@email.com",
|
|
1052
|
+
},
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
const headers = new Headers();
|
|
1056
|
+
await authClient.signIn.email.$post(
|
|
1057
|
+
{
|
|
1058
|
+
json: {
|
|
1059
|
+
...testUser,
|
|
1060
|
+
email: "billing-portal@email.com",
|
|
1061
|
+
},
|
|
1062
|
+
},
|
|
1063
|
+
{
|
|
1064
|
+
fetchOptions: {
|
|
1065
|
+
onSuccess: createCookieCapture(headers)(),
|
|
1066
|
+
},
|
|
1067
|
+
},
|
|
1068
|
+
);
|
|
1069
|
+
const billingPortalRes = await authClient.subscription.billingPortal.$post(
|
|
1070
|
+
{
|
|
1071
|
+
json: {
|
|
1072
|
+
returnUrl: "/dashboard",
|
|
1073
|
+
},
|
|
1074
|
+
},
|
|
1075
|
+
{
|
|
1076
|
+
headers,
|
|
1077
|
+
},
|
|
1078
|
+
);
|
|
1079
|
+
expect(billingPortalRes.data?.url).toBe("https://billing.stripe.com/mock");
|
|
1080
|
+
expect(billingPortalRes.data?.redirect).toBe(true);
|
|
1081
|
+
expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalledWith({
|
|
1082
|
+
customer: expect.any(String),
|
|
1083
|
+
return_url: "http://localhost:3000/dashboard",
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
test("should not update personal subscription when upgrading with an org referenceId", async ({
|
|
1088
|
+
expect,
|
|
1089
|
+
}) => {
|
|
1090
|
+
const orgId = "org_b67GF32Cljh7u588AuEblmLVobclDRcP";
|
|
1091
|
+
|
|
1092
|
+
const testOptions = {
|
|
1093
|
+
...stripeOptions,
|
|
1094
|
+
stripeClient: _stripe,
|
|
1095
|
+
subscription: {
|
|
1096
|
+
...stripeOptions.subscription,
|
|
1097
|
+
authorizeReference: async () => true,
|
|
1098
|
+
},
|
|
1099
|
+
};
|
|
1100
|
+
|
|
1101
|
+
const opts = {
|
|
1102
|
+
baseURL: "http://localhost:3000",
|
|
1103
|
+
database: memory,
|
|
1104
|
+
emailAndPassword: { enabled: true },
|
|
1105
|
+
plugins: [stripe(testOptions)],
|
|
1106
|
+
};
|
|
1107
|
+
const testAuth = faireAuth(opts);
|
|
1108
|
+
const testCtx = await testAuth.$context;
|
|
1109
|
+
const app = testAuth.$Infer.App(opts);
|
|
1110
|
+
|
|
1111
|
+
const testAuthClient = createAuthClient<typeof app>()({
|
|
1112
|
+
baseURL: "http://localhost:3000",
|
|
1113
|
+
plugins: [bearer(), stripeClient({ subscription: true })],
|
|
1114
|
+
fetchOptions: {
|
|
1115
|
+
customFetchImpl: async (url, init) =>
|
|
1116
|
+
testAuth.handler(new Request(url, init)),
|
|
1117
|
+
},
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
// Sign up and sign in the user
|
|
1121
|
+
const userRes = await testAuthClient.signUp.email.$post({
|
|
1122
|
+
json: { ...testUser, email: "org-ref@email.com" },
|
|
1123
|
+
});
|
|
1124
|
+
expect(userRes.data).not.toBeNull();
|
|
1125
|
+
if (userRes.data === null) throw new Error("Can't continue");
|
|
1126
|
+
const headers = new Headers();
|
|
1127
|
+
await testAuthClient.signIn.email.$post(
|
|
1128
|
+
{ json: { ...testUser, email: "org-ref@email.com" } },
|
|
1129
|
+
{
|
|
1130
|
+
fetchOptions: {
|
|
1131
|
+
onSuccess: createCookieCapture(headers)(),
|
|
1132
|
+
},
|
|
1133
|
+
},
|
|
1134
|
+
);
|
|
1135
|
+
|
|
1136
|
+
// Create a personal subscription (referenceId = user id)
|
|
1137
|
+
await testAuthClient.subscription.upgrade.$post(
|
|
1138
|
+
{
|
|
1139
|
+
json: {
|
|
1140
|
+
plan: "starter",
|
|
1141
|
+
},
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
headers,
|
|
1145
|
+
},
|
|
1146
|
+
);
|
|
1147
|
+
|
|
1148
|
+
const personalSub = await testCtx.adapter.findOne<Subscription>({
|
|
1149
|
+
model: "subscription",
|
|
1150
|
+
where: [{ field: "referenceId", value: userRes.data.data.user!.id }],
|
|
1151
|
+
});
|
|
1152
|
+
expect(personalSub).toBeTruthy();
|
|
1153
|
+
|
|
1154
|
+
await testCtx.adapter.update({
|
|
1155
|
+
model: "subscription",
|
|
1156
|
+
update: {
|
|
1157
|
+
status: "active",
|
|
1158
|
+
stripeSubscriptionId: "sub_personal_active_123",
|
|
1159
|
+
},
|
|
1160
|
+
where: [{ field: "id", value: personalSub!.id }],
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
mockStripe.subscriptions.list.mockResolvedValue({
|
|
1164
|
+
data: [
|
|
1165
|
+
{
|
|
1166
|
+
id: "sub_personal_active_123",
|
|
1167
|
+
status: "active",
|
|
1168
|
+
items: {
|
|
1169
|
+
data: [
|
|
1170
|
+
{
|
|
1171
|
+
id: "si_1",
|
|
1172
|
+
price: { id: process.env.STRIPE_PRICE_ID_1 },
|
|
1173
|
+
quantity: 1,
|
|
1174
|
+
},
|
|
1175
|
+
],
|
|
1176
|
+
},
|
|
1177
|
+
},
|
|
1178
|
+
],
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
// Attempt to upgrade using an org referenceId
|
|
1182
|
+
const upgradeRes = await testAuthClient.subscription.upgrade.$post(
|
|
1183
|
+
{
|
|
1184
|
+
json: {
|
|
1185
|
+
plan: "starter",
|
|
1186
|
+
referenceId: orgId,
|
|
1187
|
+
},
|
|
1188
|
+
},
|
|
1189
|
+
{
|
|
1190
|
+
headers,
|
|
1191
|
+
},
|
|
1192
|
+
);
|
|
1193
|
+
console.log(upgradeRes);
|
|
1194
|
+
|
|
1195
|
+
// // It should NOT go through billing portal (which would update the personal sub)
|
|
1196
|
+
expect(mockStripe.billingPortal.sessions.create).not.toHaveBeenCalled();
|
|
1197
|
+
expect(upgradeRes.data?.url).toBeDefined();
|
|
1198
|
+
|
|
1199
|
+
const orgSub = await testCtx.adapter.findOne<Subscription>({
|
|
1200
|
+
model: "subscription",
|
|
1201
|
+
where: [{ field: "referenceId", value: orgId }],
|
|
1202
|
+
});
|
|
1203
|
+
expect(orgSub).toMatchObject({
|
|
1204
|
+
referenceId: orgId,
|
|
1205
|
+
status: "incomplete",
|
|
1206
|
+
plan: "starter",
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
const personalAfter = await testCtx.adapter.findOne<Subscription>({
|
|
1210
|
+
model: "subscription",
|
|
1211
|
+
where: [{ field: "id", value: personalSub!.id }],
|
|
1212
|
+
});
|
|
1213
|
+
expect(personalAfter?.status).toBe("active");
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
test("should prevent multiple free trials for the same user", async ({
|
|
1217
|
+
expect,
|
|
1218
|
+
}) => {
|
|
1219
|
+
// Create a user
|
|
1220
|
+
const userRes = await authClient.signUp.email.$post({
|
|
1221
|
+
json: { ...testUser, email: "trial-prevention@email.com" },
|
|
1222
|
+
});
|
|
1223
|
+
expect(userRes.data).not.toBeNull();
|
|
1224
|
+
if (userRes.data === null) throw new Error("Can't continue");
|
|
1225
|
+
|
|
1226
|
+
const headers = new Headers();
|
|
1227
|
+
await authClient.signIn.email.$post(
|
|
1228
|
+
{ json: { ...testUser, email: "trial-prevention@email.com" } },
|
|
1229
|
+
{
|
|
1230
|
+
fetchOptions: {
|
|
1231
|
+
onSuccess: createCookieCapture(headers)(),
|
|
1232
|
+
},
|
|
1233
|
+
},
|
|
1234
|
+
);
|
|
1235
|
+
|
|
1236
|
+
// First subscription with trial
|
|
1237
|
+
const firstUpgradeRes = await authClient.subscription.upgrade.$post(
|
|
1238
|
+
{
|
|
1239
|
+
json: {
|
|
1240
|
+
plan: "starter",
|
|
1241
|
+
},
|
|
1242
|
+
},
|
|
1243
|
+
{
|
|
1244
|
+
headers,
|
|
1245
|
+
},
|
|
1246
|
+
);
|
|
1247
|
+
|
|
1248
|
+
expect(firstUpgradeRes.data?.url).toBeDefined();
|
|
1249
|
+
|
|
1250
|
+
// Simulate the subscription being created with trial data
|
|
1251
|
+
await ctx.adapter.update({
|
|
1252
|
+
model: "subscription",
|
|
1253
|
+
update: {
|
|
1254
|
+
status: "trialing",
|
|
1255
|
+
trialStart: new Date(),
|
|
1256
|
+
trialEnd: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
|
|
1257
|
+
},
|
|
1258
|
+
where: [
|
|
1259
|
+
{
|
|
1260
|
+
field: "referenceId",
|
|
1261
|
+
value: userRes.data.data.user!.id,
|
|
1262
|
+
},
|
|
1263
|
+
],
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
// Cancel the subscription
|
|
1267
|
+
await ctx.adapter.update({
|
|
1268
|
+
model: "subscription",
|
|
1269
|
+
update: {
|
|
1270
|
+
status: "canceled",
|
|
1271
|
+
},
|
|
1272
|
+
where: [
|
|
1273
|
+
{
|
|
1274
|
+
field: "referenceId",
|
|
1275
|
+
value: userRes.data.data.user!.id,
|
|
1276
|
+
},
|
|
1277
|
+
],
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
// Try to subscribe again - should NOT get a trial
|
|
1281
|
+
const secondUpgradeRes = await authClient.subscription.upgrade.$post(
|
|
1282
|
+
{
|
|
1283
|
+
json: {
|
|
1284
|
+
plan: "starter",
|
|
1285
|
+
},
|
|
1286
|
+
},
|
|
1287
|
+
{
|
|
1288
|
+
headers,
|
|
1289
|
+
},
|
|
1290
|
+
);
|
|
1291
|
+
|
|
1292
|
+
expect(secondUpgradeRes.data?.url).toBeDefined();
|
|
1293
|
+
|
|
1294
|
+
// Verify that the checkout session was created without trial_period_days
|
|
1295
|
+
// We can't directly test the Stripe session, but we can verify the logic
|
|
1296
|
+
// by checking that the user has trial history
|
|
1297
|
+
const subscriptions = (await ctx.adapter.findMany({
|
|
1298
|
+
model: "subscription",
|
|
1299
|
+
where: [
|
|
1300
|
+
{
|
|
1301
|
+
field: "referenceId",
|
|
1302
|
+
value: userRes.data.data.user!.id,
|
|
1303
|
+
},
|
|
1304
|
+
],
|
|
1305
|
+
})) as Subscription[];
|
|
1306
|
+
|
|
1307
|
+
// Should have 2 subscriptions (first canceled, second new)
|
|
1308
|
+
expect(subscriptions).toHaveLength(2);
|
|
1309
|
+
|
|
1310
|
+
// At least one should have trial data
|
|
1311
|
+
const hasTrialData = subscriptions.some(
|
|
1312
|
+
(s: Subscription) => s.trialStart || s.trialEnd,
|
|
1313
|
+
);
|
|
1314
|
+
expect(hasTrialData).toBe(true);
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
test("should prevent multiple free trials across different plans", async ({
|
|
1318
|
+
expect,
|
|
1319
|
+
}) => {
|
|
1320
|
+
// Create a user
|
|
1321
|
+
const userRes = await authClient.signUp.email.$post({
|
|
1322
|
+
json: { ...testUser, email: "cross-plan-trial@email.com" },
|
|
1323
|
+
});
|
|
1324
|
+
expect(userRes.data).not.toBeNull();
|
|
1325
|
+
if (userRes.data === null) throw new Error("Can't continue");
|
|
1326
|
+
|
|
1327
|
+
const headers = new Headers();
|
|
1328
|
+
await authClient.signIn.email.$post(
|
|
1329
|
+
{ json: { ...testUser, email: "cross-plan-trial@email.com" } },
|
|
1330
|
+
{
|
|
1331
|
+
fetchOptions: {
|
|
1332
|
+
onSuccess: createCookieCapture(headers)(),
|
|
1333
|
+
},
|
|
1334
|
+
},
|
|
1335
|
+
);
|
|
1336
|
+
|
|
1337
|
+
// First subscription with trial on starter plan
|
|
1338
|
+
const firstUpgradeRes = await authClient.subscription.upgrade.$post(
|
|
1339
|
+
{
|
|
1340
|
+
json: {
|
|
1341
|
+
plan: "starter",
|
|
1342
|
+
},
|
|
1343
|
+
},
|
|
1344
|
+
{
|
|
1345
|
+
headers,
|
|
1346
|
+
},
|
|
1347
|
+
);
|
|
1348
|
+
|
|
1349
|
+
expect(firstUpgradeRes.data?.url).toBeDefined();
|
|
1350
|
+
|
|
1351
|
+
// Simulate the subscription being created with trial data
|
|
1352
|
+
await ctx.adapter.update({
|
|
1353
|
+
model: "subscription",
|
|
1354
|
+
update: {
|
|
1355
|
+
status: "trialing",
|
|
1356
|
+
trialStart: new Date(),
|
|
1357
|
+
trialEnd: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
1358
|
+
},
|
|
1359
|
+
where: [
|
|
1360
|
+
{
|
|
1361
|
+
field: "referenceId",
|
|
1362
|
+
value: userRes.data.data.user!.id,
|
|
1363
|
+
},
|
|
1364
|
+
],
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
// Cancel the subscription
|
|
1368
|
+
await ctx.adapter.update({
|
|
1369
|
+
model: "subscription",
|
|
1370
|
+
update: {
|
|
1371
|
+
status: "canceled",
|
|
1372
|
+
},
|
|
1373
|
+
where: [
|
|
1374
|
+
{
|
|
1375
|
+
field: "referenceId",
|
|
1376
|
+
value: userRes.data.data.user!.id,
|
|
1377
|
+
},
|
|
1378
|
+
],
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
// Try to subscribe to a different plan - should NOT get a trial
|
|
1382
|
+
const secondUpgradeRes = await authClient.subscription.upgrade.$post(
|
|
1383
|
+
{
|
|
1384
|
+
json: {
|
|
1385
|
+
plan: "premium",
|
|
1386
|
+
},
|
|
1387
|
+
},
|
|
1388
|
+
{
|
|
1389
|
+
headers,
|
|
1390
|
+
},
|
|
1391
|
+
);
|
|
1392
|
+
|
|
1393
|
+
expect(secondUpgradeRes.data?.url).toBeDefined();
|
|
1394
|
+
|
|
1395
|
+
// Verify that the user has trial history from the first plan
|
|
1396
|
+
const subscriptions = (await ctx.adapter.findMany({
|
|
1397
|
+
model: "subscription",
|
|
1398
|
+
where: [
|
|
1399
|
+
{
|
|
1400
|
+
field: "referenceId",
|
|
1401
|
+
value: userRes.data.data.user!.id,
|
|
1402
|
+
},
|
|
1403
|
+
],
|
|
1404
|
+
})) as Subscription[];
|
|
1405
|
+
|
|
1406
|
+
// Should have at least 1 subscription (the starter with trial data)
|
|
1407
|
+
expect(subscriptions.length).toBeGreaterThanOrEqual(1);
|
|
1408
|
+
|
|
1409
|
+
// The starter subscription should have trial data
|
|
1410
|
+
const starterSub = subscriptions.find(
|
|
1411
|
+
(s: Subscription) => s.plan === "starter",
|
|
1412
|
+
) as Subscription | undefined;
|
|
1413
|
+
expect(starterSub?.trialStart).toBeDefined();
|
|
1414
|
+
expect(starterSub?.trialEnd).toBeDefined();
|
|
1415
|
+
|
|
1416
|
+
// Verify that the trial eligibility logic is working by checking
|
|
1417
|
+
// that the user has ever had a trial (which should prevent future trials)
|
|
1418
|
+
const hasEverTrialed = subscriptions.some((s: Subscription) => {
|
|
1419
|
+
const hadTrial =
|
|
1420
|
+
!!(s.trialStart || s.trialEnd) || s.status === "trialing";
|
|
1421
|
+
return hadTrial;
|
|
1422
|
+
});
|
|
1423
|
+
expect(hasEverTrialed).toBe(true);
|
|
1424
|
+
});
|
|
1425
|
+
});
|