@donotdev/functions 0.0.9 → 0.0.11
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/package.json +31 -7
- package/src/firebase/auth/setCustomClaims.ts +26 -4
- package/src/firebase/baseFunction.ts +43 -20
- package/src/firebase/billing/cancelSubscription.ts +9 -1
- package/src/firebase/billing/changePlan.ts +8 -2
- package/src/firebase/billing/createCustomerPortal.ts +16 -2
- package/src/firebase/billing/refreshSubscriptionStatus.ts +3 -1
- package/src/firebase/billing/webhookHandler.ts +13 -1
- package/src/firebase/config/constants.ts +0 -3
- package/src/firebase/crud/aggregate.ts +20 -5
- package/src/firebase/crud/create.ts +31 -7
- package/src/firebase/crud/get.ts +16 -8
- package/src/firebase/crud/list.ts +70 -29
- package/src/firebase/crud/update.ts +29 -7
- package/src/firebase/oauth/exchangeToken.ts +30 -4
- package/src/firebase/oauth/githubAccess.ts +8 -3
- package/src/firebase/registerCrudFunctions.ts +15 -4
- package/src/firebase/scheduled/checkExpiredSubscriptions.ts +20 -3
- package/src/shared/__tests__/detectFirestore.test.ts +52 -0
- package/src/shared/__tests__/errorHandling.test.ts +144 -0
- package/src/shared/__tests__/idempotency.test.ts +95 -0
- package/src/shared/__tests__/rateLimiter.test.ts +142 -0
- package/src/shared/__tests__/validation.test.ts +172 -0
- package/src/shared/billing/__tests__/createCheckout.test.ts +393 -0
- package/src/shared/billing/__tests__/webhookHandler.test.ts +1091 -0
- package/src/shared/billing/webhookHandler.ts +16 -7
- package/src/shared/errorHandling.ts +16 -54
- package/src/shared/firebase.ts +1 -25
- package/src/shared/oauth/__tests__/exchangeToken.test.ts +116 -0
- package/src/shared/oauth/__tests__/grantAccess.test.ts +237 -0
- package/src/shared/schema.ts +7 -1
- package/src/shared/utils/__tests__/functionWrapper.test.ts +122 -0
- package/src/shared/utils/external/subscription.ts +10 -0
- package/src/shared/utils/internal/auth.ts +140 -16
- package/src/shared/utils/internal/rateLimiter.ts +101 -90
- package/src/shared/utils/internal/validation.ts +47 -3
- package/src/shared/utils.ts +154 -39
- package/src/supabase/auth/deleteAccount.ts +59 -0
- package/src/supabase/auth/getCustomClaims.ts +56 -0
- package/src/supabase/auth/getUserAuthStatus.ts +64 -0
- package/src/supabase/auth/removeCustomClaims.ts +75 -0
- package/src/supabase/auth/setCustomClaims.ts +73 -0
- package/src/supabase/baseFunction.ts +302 -0
- package/src/supabase/billing/cancelSubscription.ts +57 -0
- package/src/supabase/billing/changePlan.ts +62 -0
- package/src/supabase/billing/createCheckoutSession.ts +82 -0
- package/src/supabase/billing/createCustomerPortal.ts +58 -0
- package/src/supabase/billing/refreshSubscriptionStatus.ts +89 -0
- package/src/supabase/crud/aggregate.ts +169 -0
- package/src/supabase/crud/create.ts +225 -0
- package/src/supabase/crud/delete.ts +154 -0
- package/src/supabase/crud/get.ts +89 -0
- package/src/supabase/crud/index.ts +24 -0
- package/src/supabase/crud/list.ts +357 -0
- package/src/supabase/crud/update.ts +199 -0
- package/src/supabase/helpers/authProvider.ts +45 -0
- package/src/supabase/index.ts +73 -0
- package/src/supabase/registerCrudFunctions.ts +180 -0
- package/src/supabase/utils/idempotency.ts +141 -0
- package/src/supabase/utils/monitoring.ts +187 -0
- package/src/supabase/utils/rateLimiter.ts +216 -0
- package/src/vercel/api/auth/get-custom-claims.ts +3 -2
- package/src/vercel/api/auth/get-user-auth-status.ts +3 -2
- package/src/vercel/api/auth/remove-custom-claims.ts +3 -2
- package/src/vercel/api/auth/set-custom-claims.ts +5 -2
- package/src/vercel/api/billing/cancel.ts +2 -1
- package/src/vercel/api/billing/change-plan.ts +3 -1
- package/src/vercel/api/billing/customer-portal.ts +4 -1
- package/src/vercel/api/billing/refresh-subscription-status.ts +3 -1
- package/src/vercel/api/billing/webhook-handler.ts +24 -4
- package/src/vercel/api/crud/create.ts +14 -8
- package/src/vercel/api/crud/delete.ts +15 -6
- package/src/vercel/api/crud/get.ts +16 -8
- package/src/vercel/api/crud/list.ts +22 -10
- package/src/vercel/api/crud/update.ts +16 -10
- package/src/vercel/api/oauth/check-github-access.ts +2 -5
- package/src/vercel/api/oauth/grant-github-access.ts +1 -5
- package/src/vercel/api/oauth/revoke-github-access.ts +7 -8
- package/src/vercel/api/utils/cors.ts +13 -2
- package/src/vercel/baseFunction.ts +40 -25
|
@@ -0,0 +1,1091 @@
|
|
|
1
|
+
// packages/functions/src/shared/billing/__tests__/webhookHandler.test.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Tests for Stripe webhook handler
|
|
5
|
+
* @description Comprehensive tests for processWebhook, event routing, error handling,
|
|
6
|
+
* state machine transitions, and idempotency behavior.
|
|
7
|
+
*
|
|
8
|
+
* @version 0.0.1
|
|
9
|
+
* @since 0.0.1
|
|
10
|
+
* @author AMBROISE PARK Consulting
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
14
|
+
|
|
15
|
+
// Mock detectFirestore before any module that uses it (idempotency.ts)
|
|
16
|
+
vi.mock('../../utils/detectFirestore.js', () => ({
|
|
17
|
+
isFirestoreConfigured: vi.fn(() => false),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Mock firebase-functions (required by errorHandling.ts)
|
|
21
|
+
vi.mock('firebase-functions/v2/https', () => ({
|
|
22
|
+
HttpsError: class HttpsError extends Error {
|
|
23
|
+
code: string;
|
|
24
|
+
details: any;
|
|
25
|
+
constructor(code: string, message: string, details?: any) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.details = details;
|
|
29
|
+
this.name = 'HttpsError';
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// Mock @donotdev/core/server — provides validateStripeBackConfig, SUBSCRIPTION_STATUS, SUBSCRIPTION_TIERS
|
|
35
|
+
vi.mock('@donotdev/core/server', () => ({
|
|
36
|
+
validateStripeBackConfig: vi.fn((config: unknown) => config),
|
|
37
|
+
SUBSCRIPTION_STATUS: {
|
|
38
|
+
ACTIVE: 'active',
|
|
39
|
+
CANCELED: 'canceled',
|
|
40
|
+
INCOMPLETE: 'incomplete',
|
|
41
|
+
PAST_DUE: 'past_due',
|
|
42
|
+
TRIALING: 'trialing',
|
|
43
|
+
UNPAID: 'unpaid',
|
|
44
|
+
},
|
|
45
|
+
SUBSCRIPTION_TIERS: {
|
|
46
|
+
FREE: 'free',
|
|
47
|
+
PRO: 'pro',
|
|
48
|
+
PREMIUM: 'premium',
|
|
49
|
+
},
|
|
50
|
+
EntityHookError: class EntityHookError extends Error {
|
|
51
|
+
type: string;
|
|
52
|
+
constructor(message: string, type: string) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.type = type;
|
|
55
|
+
this.name = 'EntityHookError';
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
// Mock @donotdev/firebase/server (dynamic-imported by idempotency in Firestore mode)
|
|
61
|
+
vi.mock('@donotdev/firebase/server', () => ({
|
|
62
|
+
getFirebaseAdminFirestore: vi.fn(),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
// Mock the date utility — return a fixed ISO string for deterministic assertions
|
|
66
|
+
vi.mock('../../utils/external/date.js', () => ({
|
|
67
|
+
calculateSubscriptionEndDate: vi.fn(() => '2026-03-18T00:00:00.000Z'),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
// Mock logger to silence output in tests
|
|
71
|
+
vi.mock('../../logger.js', () => ({
|
|
72
|
+
logger: {
|
|
73
|
+
info: vi.fn(),
|
|
74
|
+
warn: vi.fn(),
|
|
75
|
+
error: vi.fn(),
|
|
76
|
+
debug: vi.fn(),
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
// Reset idempotency store between tests so events are never pre-marked
|
|
81
|
+
import { resetIdempotencyStore } from '../idempotency.js';
|
|
82
|
+
import { processWebhook } from '../webhookHandler.js';
|
|
83
|
+
import type { UpdateSubscriptionFn, WebhookAuthProvider } from '../webhookHandler.js';
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Shared test fixtures
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/** Minimal StripeBackConfig with one StripePayment and one StripeSubscription item */
|
|
90
|
+
const makeConfig = (overrides: Record<string, any> = {}) => ({
|
|
91
|
+
pro_monthly: {
|
|
92
|
+
type: 'StripeSubscription' as const,
|
|
93
|
+
name: 'Pro Monthly',
|
|
94
|
+
price: 999,
|
|
95
|
+
currency: 'usd',
|
|
96
|
+
priceId: 'price_pro_monthly',
|
|
97
|
+
tier: 'pro',
|
|
98
|
+
duration: '1month',
|
|
99
|
+
...overrides,
|
|
100
|
+
},
|
|
101
|
+
lifetime_purchase: {
|
|
102
|
+
type: 'StripePayment' as const,
|
|
103
|
+
name: 'Lifetime',
|
|
104
|
+
price: 19900,
|
|
105
|
+
currency: 'usd',
|
|
106
|
+
priceId: 'price_lifetime',
|
|
107
|
+
tier: 'premium',
|
|
108
|
+
duration: 'lifetime',
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** Build a minimal Stripe event object */
|
|
113
|
+
function makeEvent(type: string, dataObject: Record<string, any>): any {
|
|
114
|
+
return {
|
|
115
|
+
id: `evt_test_${Math.random().toString(36).slice(2)}`,
|
|
116
|
+
type,
|
|
117
|
+
data: { object: dataObject },
|
|
118
|
+
created: Date.now() / 1000,
|
|
119
|
+
livemode: false,
|
|
120
|
+
pending_webhooks: 1,
|
|
121
|
+
api_version: '2023-10-16',
|
|
122
|
+
object: 'event',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Default mocked Stripe instance */
|
|
127
|
+
function makeStripe(eventOrFn?: any): any {
|
|
128
|
+
return {
|
|
129
|
+
webhooks: {
|
|
130
|
+
constructEvent: vi.fn((_body, _sig, _secret) => {
|
|
131
|
+
if (typeof eventOrFn === 'function') return eventOrFn();
|
|
132
|
+
return eventOrFn;
|
|
133
|
+
}),
|
|
134
|
+
},
|
|
135
|
+
subscriptions: {
|
|
136
|
+
retrieve: vi.fn(),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Default mock auth provider */
|
|
142
|
+
function makeAuthProvider(): WebhookAuthProvider {
|
|
143
|
+
return {
|
|
144
|
+
getUser: vi.fn(async () => ({ customClaims: {} })),
|
|
145
|
+
setCustomUserClaims: vi.fn(async () => {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Default mock updateSubscription */
|
|
150
|
+
function makeUpdateSubscription(): UpdateSubscriptionFn {
|
|
151
|
+
return vi.fn(async () => {});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const RAW_BODY = 'raw-body-bytes';
|
|
155
|
+
const SIGNATURE = 'stripe-sig-header';
|
|
156
|
+
const WEBHOOK_SECRET = 'whsec_test_secret';
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Helpers
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
async function callProcessWebhook({
|
|
163
|
+
stripe,
|
|
164
|
+
config = makeConfig(),
|
|
165
|
+
updateSubscription = makeUpdateSubscription(),
|
|
166
|
+
authProvider = makeAuthProvider(),
|
|
167
|
+
}: {
|
|
168
|
+
stripe: any;
|
|
169
|
+
config?: any;
|
|
170
|
+
updateSubscription?: UpdateSubscriptionFn;
|
|
171
|
+
authProvider?: WebhookAuthProvider | null;
|
|
172
|
+
}) {
|
|
173
|
+
return processWebhook(
|
|
174
|
+
RAW_BODY,
|
|
175
|
+
SIGNATURE,
|
|
176
|
+
WEBHOOK_SECRET,
|
|
177
|
+
stripe,
|
|
178
|
+
config,
|
|
179
|
+
updateSubscription,
|
|
180
|
+
authProvider
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ===========================================================================
|
|
185
|
+
// Test suites
|
|
186
|
+
// ===========================================================================
|
|
187
|
+
|
|
188
|
+
describe('processWebhook — signature verification', () => {
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
resetIdempotencyStore();
|
|
191
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
192
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
193
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('calls stripe.webhooks.constructEvent with raw body, signature and secret', async () => {
|
|
197
|
+
const event = makeEvent('checkout.session.completed', {
|
|
198
|
+
metadata: { userId: 'u1', billingConfigKey: 'pro_monthly' },
|
|
199
|
+
subscription: 'sub_abc',
|
|
200
|
+
customer: 'cus_abc',
|
|
201
|
+
payment_status: 'paid',
|
|
202
|
+
});
|
|
203
|
+
const stripe = makeStripe(event);
|
|
204
|
+
const config = makeConfig();
|
|
205
|
+
const updateSubscription = makeUpdateSubscription();
|
|
206
|
+
|
|
207
|
+
await callProcessWebhook({ stripe, config, updateSubscription });
|
|
208
|
+
|
|
209
|
+
expect(stripe.webhooks.constructEvent).toHaveBeenCalledWith(
|
|
210
|
+
RAW_BODY,
|
|
211
|
+
SIGNATURE,
|
|
212
|
+
WEBHOOK_SECRET
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('throws when constructEvent throws (invalid signature)', async () => {
|
|
217
|
+
const stripe = {
|
|
218
|
+
webhooks: {
|
|
219
|
+
constructEvent: vi.fn(() => {
|
|
220
|
+
throw new Error('No signatures found matching the expected signature');
|
|
221
|
+
}),
|
|
222
|
+
},
|
|
223
|
+
subscriptions: { retrieve: vi.fn() },
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
await expect(
|
|
227
|
+
callProcessWebhook({ stripe })
|
|
228
|
+
).rejects.toThrow();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
describe('processWebhook — idempotency', () => {
|
|
235
|
+
beforeEach(() => {
|
|
236
|
+
resetIdempotencyStore();
|
|
237
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
238
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
239
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('returns "already processed" without calling updateSubscription for duplicate event', async () => {
|
|
243
|
+
const event = makeEvent('checkout.session.completed', {
|
|
244
|
+
metadata: { userId: 'u_dup', billingConfigKey: 'pro_monthly' },
|
|
245
|
+
subscription: 'sub_dup',
|
|
246
|
+
customer: 'cus_dup',
|
|
247
|
+
});
|
|
248
|
+
const stripe = makeStripe(event);
|
|
249
|
+
const updateSubscription = makeUpdateSubscription();
|
|
250
|
+
|
|
251
|
+
// First call — processes normally
|
|
252
|
+
await callProcessWebhook({ stripe, updateSubscription });
|
|
253
|
+
|
|
254
|
+
// Reconstruct stripe mock returning the same event id (same event object)
|
|
255
|
+
const stripe2 = makeStripe(event);
|
|
256
|
+
const updateSubscription2 = makeUpdateSubscription();
|
|
257
|
+
|
|
258
|
+
const result = await callProcessWebhook({
|
|
259
|
+
stripe: stripe2,
|
|
260
|
+
updateSubscription: updateSubscription2,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(result).toEqual({ success: true, message: 'Event already processed' });
|
|
264
|
+
expect(updateSubscription2).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('processes two different event ids independently', async () => {
|
|
268
|
+
const config = makeConfig();
|
|
269
|
+
const sessionPayload = {
|
|
270
|
+
metadata: { userId: 'u_a', billingConfigKey: 'pro_monthly' },
|
|
271
|
+
subscription: 'sub_a',
|
|
272
|
+
customer: 'cus_a',
|
|
273
|
+
};
|
|
274
|
+
const event1 = makeEvent('checkout.session.completed', sessionPayload);
|
|
275
|
+
const event2 = makeEvent('checkout.session.completed', sessionPayload);
|
|
276
|
+
// Ensure distinct ids
|
|
277
|
+
event2.id = event1.id + '_b';
|
|
278
|
+
|
|
279
|
+
const updateSubscription1 = makeUpdateSubscription();
|
|
280
|
+
const updateSubscription2 = makeUpdateSubscription();
|
|
281
|
+
|
|
282
|
+
await callProcessWebhook({ stripe: makeStripe(event1), config, updateSubscription: updateSubscription1 });
|
|
283
|
+
await callProcessWebhook({ stripe: makeStripe(event2), config, updateSubscription: updateSubscription2 });
|
|
284
|
+
|
|
285
|
+
expect(updateSubscription1).toHaveBeenCalledTimes(1);
|
|
286
|
+
expect(updateSubscription2).toHaveBeenCalledTimes(1);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
describe('processWebhook — config validation', () => {
|
|
293
|
+
beforeEach(() => {
|
|
294
|
+
resetIdempotencyStore();
|
|
295
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
296
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
297
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('calls validateStripeBackConfig with the supplied billing config', async () => {
|
|
301
|
+
const { validateStripeBackConfig } = await import('@donotdev/core/server');
|
|
302
|
+
const config = makeConfig();
|
|
303
|
+
const event = makeEvent('checkout.session.completed', {
|
|
304
|
+
metadata: { userId: 'u_cfg', billingConfigKey: 'pro_monthly' },
|
|
305
|
+
subscription: 'sub_cfg',
|
|
306
|
+
customer: 'cus_cfg',
|
|
307
|
+
});
|
|
308
|
+
const stripe = makeStripe(event);
|
|
309
|
+
|
|
310
|
+
await callProcessWebhook({ stripe, config });
|
|
311
|
+
|
|
312
|
+
expect(validateStripeBackConfig).toHaveBeenCalledWith(config);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('throws when validateStripeBackConfig throws', async () => {
|
|
316
|
+
const { validateStripeBackConfig } = await import('@donotdev/core/server');
|
|
317
|
+
(validateStripeBackConfig as any).mockImplementationOnce(() => {
|
|
318
|
+
throw new Error('Invalid billing config');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const event = makeEvent('checkout.session.completed', {
|
|
322
|
+
metadata: { userId: 'u_bad', billingConfigKey: 'pro_monthly' },
|
|
323
|
+
subscription: 'sub_bad',
|
|
324
|
+
customer: 'cus_bad',
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await expect(
|
|
328
|
+
callProcessWebhook({ stripe: makeStripe(event) })
|
|
329
|
+
).rejects.toThrow();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
describe('event routing — checkout.session.completed (StripeSubscription)', () => {
|
|
336
|
+
beforeEach(() => {
|
|
337
|
+
resetIdempotencyStore();
|
|
338
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
339
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
340
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('calls updateSubscription with active status and subscription fields', async () => {
|
|
344
|
+
const event = makeEvent('checkout.session.completed', {
|
|
345
|
+
metadata: { userId: 'u_sub', billingConfigKey: 'pro_monthly' },
|
|
346
|
+
subscription: 'sub_123',
|
|
347
|
+
customer: 'cus_123',
|
|
348
|
+
});
|
|
349
|
+
const config = makeConfig();
|
|
350
|
+
const updateSubscription = makeUpdateSubscription();
|
|
351
|
+
|
|
352
|
+
await callProcessWebhook({ stripe: makeStripe(event), config, updateSubscription });
|
|
353
|
+
|
|
354
|
+
expect(updateSubscription).toHaveBeenCalledOnce();
|
|
355
|
+
const [userId, data] = (updateSubscription as any).mock.calls[0];
|
|
356
|
+
expect(userId).toBe('u_sub');
|
|
357
|
+
expect(data.tier).toBe('pro');
|
|
358
|
+
expect(data.status).toBe('active');
|
|
359
|
+
expect(data.subscriptionId).toBe('sub_123');
|
|
360
|
+
expect(data.customerId).toBe('cus_123');
|
|
361
|
+
expect(typeof data.subscriptionEnd).toBe('string');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('calls onSubscriptionCreated hook on success', async () => {
|
|
365
|
+
const onSubscriptionCreated = vi.fn(async () => {});
|
|
366
|
+
const config = makeConfig({ onSubscriptionCreated });
|
|
367
|
+
const event = makeEvent('checkout.session.completed', {
|
|
368
|
+
metadata: { userId: 'u_hook', billingConfigKey: 'pro_monthly' },
|
|
369
|
+
subscription: 'sub_hook',
|
|
370
|
+
customer: 'cus_hook',
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
await callProcessWebhook({ stripe: makeStripe(event), config, updateSubscription: makeUpdateSubscription() });
|
|
374
|
+
|
|
375
|
+
expect(onSubscriptionCreated).toHaveBeenCalledWith('u_hook', expect.any(Object));
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('does NOT throw when onSubscriptionCreated hook throws (non-critical)', async () => {
|
|
379
|
+
const onSubscriptionCreated = vi.fn(async () => {
|
|
380
|
+
throw new Error('hook exploded');
|
|
381
|
+
});
|
|
382
|
+
const config = makeConfig({ onSubscriptionCreated });
|
|
383
|
+
const event = makeEvent('checkout.session.completed', {
|
|
384
|
+
metadata: { userId: 'u_hook_fail', billingConfigKey: 'pro_monthly' },
|
|
385
|
+
subscription: 'sub_hf',
|
|
386
|
+
customer: 'cus_hf',
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await expect(
|
|
390
|
+
callProcessWebhook({ stripe: makeStripe(event), config, updateSubscription: makeUpdateSubscription() })
|
|
391
|
+
).resolves.toEqual({ success: true, message: 'Webhook processed' });
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('throws when metadata is missing userId', async () => {
|
|
395
|
+
const event = makeEvent('checkout.session.completed', {
|
|
396
|
+
metadata: { billingConfigKey: 'pro_monthly' },
|
|
397
|
+
subscription: 'sub_no_user',
|
|
398
|
+
customer: 'cus_no_user',
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await expect(
|
|
402
|
+
callProcessWebhook({ stripe: makeStripe(event) })
|
|
403
|
+
).rejects.toThrow();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('throws when metadata is missing billingConfigKey', async () => {
|
|
407
|
+
const event = makeEvent('checkout.session.completed', {
|
|
408
|
+
metadata: { userId: 'u_no_key' },
|
|
409
|
+
subscription: 'sub_no_key',
|
|
410
|
+
customer: 'cus_no_key',
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
await expect(
|
|
414
|
+
callProcessWebhook({ stripe: makeStripe(event) })
|
|
415
|
+
).rejects.toThrow();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('throws when billingConfigKey does not match any config entry', async () => {
|
|
419
|
+
const event = makeEvent('checkout.session.completed', {
|
|
420
|
+
metadata: { userId: 'u_unknown', billingConfigKey: 'nonexistent_plan' },
|
|
421
|
+
subscription: 'sub_unknown',
|
|
422
|
+
customer: 'cus_unknown',
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
await expect(
|
|
426
|
+
callProcessWebhook({ stripe: makeStripe(event) })
|
|
427
|
+
).rejects.toThrow();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('throws when authProvider is null (subscription update requires auth)', async () => {
|
|
431
|
+
const event = makeEvent('checkout.session.completed', {
|
|
432
|
+
metadata: { userId: 'u_no_auth', billingConfigKey: 'pro_monthly' },
|
|
433
|
+
subscription: 'sub_no_auth',
|
|
434
|
+
customer: 'cus_no_auth',
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await expect(
|
|
438
|
+
callProcessWebhook({ stripe: makeStripe(event), authProvider: null })
|
|
439
|
+
).rejects.toThrow();
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
describe('event routing — checkout.session.completed (StripePayment)', () => {
|
|
446
|
+
beforeEach(() => {
|
|
447
|
+
resetIdempotencyStore();
|
|
448
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
449
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
450
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('calls updateSubscription with active status and customerId (no subscriptionId)', async () => {
|
|
454
|
+
const event = makeEvent('checkout.session.completed', {
|
|
455
|
+
metadata: { userId: 'u_pay', billingConfigKey: 'lifetime_purchase' },
|
|
456
|
+
subscription: null,
|
|
457
|
+
customer: 'cus_pay',
|
|
458
|
+
});
|
|
459
|
+
const config = makeConfig();
|
|
460
|
+
const updateSubscription = makeUpdateSubscription();
|
|
461
|
+
|
|
462
|
+
await callProcessWebhook({ stripe: makeStripe(event), config, updateSubscription });
|
|
463
|
+
|
|
464
|
+
expect(updateSubscription).toHaveBeenCalledOnce();
|
|
465
|
+
const [userId, data] = (updateSubscription as any).mock.calls[0];
|
|
466
|
+
expect(userId).toBe('u_pay');
|
|
467
|
+
expect(data.tier).toBe('premium');
|
|
468
|
+
expect(data.status).toBe('active');
|
|
469
|
+
expect(data.customerId).toBe('cus_pay');
|
|
470
|
+
// StripePayment does not pass subscriptionId
|
|
471
|
+
expect(data.subscriptionId).toBeUndefined();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('calls onPurchaseSuccess hook on success', async () => {
|
|
475
|
+
const onPurchaseSuccess = vi.fn(async () => {});
|
|
476
|
+
const config = {
|
|
477
|
+
...makeConfig(),
|
|
478
|
+
lifetime_purchase: {
|
|
479
|
+
...makeConfig().lifetime_purchase,
|
|
480
|
+
onPurchaseSuccess,
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
const event = makeEvent('checkout.session.completed', {
|
|
484
|
+
metadata: { userId: 'u_pay_hook', billingConfigKey: 'lifetime_purchase' },
|
|
485
|
+
subscription: null,
|
|
486
|
+
customer: 'cus_pay_hook',
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
await callProcessWebhook({ stripe: makeStripe(event), config, updateSubscription: makeUpdateSubscription() });
|
|
490
|
+
|
|
491
|
+
expect(onPurchaseSuccess).toHaveBeenCalledWith('u_pay_hook', expect.any(Object));
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('does NOT throw when onPurchaseSuccess hook throws (non-critical)', async () => {
|
|
495
|
+
const onPurchaseSuccess = vi.fn(async () => {
|
|
496
|
+
throw new Error('purchase hook failed');
|
|
497
|
+
});
|
|
498
|
+
const config = {
|
|
499
|
+
...makeConfig(),
|
|
500
|
+
lifetime_purchase: {
|
|
501
|
+
...makeConfig().lifetime_purchase,
|
|
502
|
+
onPurchaseSuccess,
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
const event = makeEvent('checkout.session.completed', {
|
|
506
|
+
metadata: { userId: 'u_phf', billingConfigKey: 'lifetime_purchase' },
|
|
507
|
+
subscription: null,
|
|
508
|
+
customer: 'cus_phf',
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
await expect(
|
|
512
|
+
callProcessWebhook({ stripe: makeStripe(event), config, updateSubscription: makeUpdateSubscription() })
|
|
513
|
+
).resolves.toEqual({ success: true, message: 'Webhook processed' });
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
describe('event routing — invoice.payment_succeeded', () => {
|
|
520
|
+
beforeEach(() => {
|
|
521
|
+
resetIdempotencyStore();
|
|
522
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
523
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
524
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('retrieves subscription and renews it with active status', async () => {
|
|
528
|
+
const invoice = {
|
|
529
|
+
id: 'in_renewal',
|
|
530
|
+
subscription: 'sub_renewal',
|
|
531
|
+
customer: 'cus_renewal',
|
|
532
|
+
metadata: {},
|
|
533
|
+
};
|
|
534
|
+
const event = makeEvent('invoice.payment_succeeded', invoice);
|
|
535
|
+
const stripe = makeStripe(event);
|
|
536
|
+
stripe.subscriptions.retrieve = vi.fn(async () => ({
|
|
537
|
+
id: 'sub_renewal',
|
|
538
|
+
metadata: { userId: 'u_renewal', billingConfigKey: 'pro_monthly' },
|
|
539
|
+
}));
|
|
540
|
+
|
|
541
|
+
const updateSubscription = makeUpdateSubscription();
|
|
542
|
+
|
|
543
|
+
await callProcessWebhook({ stripe, updateSubscription });
|
|
544
|
+
|
|
545
|
+
expect(stripe.subscriptions.retrieve).toHaveBeenCalledWith('sub_renewal');
|
|
546
|
+
expect(updateSubscription).toHaveBeenCalledOnce();
|
|
547
|
+
const [userId, data] = (updateSubscription as any).mock.calls[0];
|
|
548
|
+
expect(userId).toBe('u_renewal');
|
|
549
|
+
expect(data.status).toBe('active');
|
|
550
|
+
expect(data.subscriptionId).toBe('sub_renewal');
|
|
551
|
+
expect(data.customerId).toBe('cus_renewal');
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('calls onSubscriptionRenewed hook on successful renewal', async () => {
|
|
555
|
+
const onSubscriptionRenewed = vi.fn(async () => {});
|
|
556
|
+
const config = makeConfig({ onSubscriptionRenewed });
|
|
557
|
+
|
|
558
|
+
const invoice = {
|
|
559
|
+
id: 'in_hook_renewal',
|
|
560
|
+
subscription: 'sub_hook_renewal',
|
|
561
|
+
customer: 'cus_hook_renewal',
|
|
562
|
+
metadata: {},
|
|
563
|
+
};
|
|
564
|
+
const event = makeEvent('invoice.payment_succeeded', invoice);
|
|
565
|
+
const stripe = makeStripe(event);
|
|
566
|
+
stripe.subscriptions.retrieve = vi.fn(async () => ({
|
|
567
|
+
id: 'sub_hook_renewal',
|
|
568
|
+
metadata: { userId: 'u_hook_renewal', billingConfigKey: 'pro_monthly' },
|
|
569
|
+
}));
|
|
570
|
+
|
|
571
|
+
await callProcessWebhook({ stripe, config, updateSubscription: makeUpdateSubscription() });
|
|
572
|
+
|
|
573
|
+
expect(onSubscriptionRenewed).toHaveBeenCalledWith('u_hook_renewal', expect.any(Object));
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('does NOT throw when onSubscriptionRenewed hook throws (non-critical)', async () => {
|
|
577
|
+
const onSubscriptionRenewed = vi.fn(async () => {
|
|
578
|
+
throw new Error('renewal hook failed');
|
|
579
|
+
});
|
|
580
|
+
const config = makeConfig({ onSubscriptionRenewed });
|
|
581
|
+
|
|
582
|
+
const invoice = {
|
|
583
|
+
id: 'in_hook_fail',
|
|
584
|
+
subscription: 'sub_hook_fail',
|
|
585
|
+
customer: 'cus_hook_fail',
|
|
586
|
+
metadata: {},
|
|
587
|
+
};
|
|
588
|
+
const event = makeEvent('invoice.payment_succeeded', invoice);
|
|
589
|
+
const stripe = makeStripe(event);
|
|
590
|
+
stripe.subscriptions.retrieve = vi.fn(async () => ({
|
|
591
|
+
id: 'sub_hook_fail',
|
|
592
|
+
metadata: { userId: 'u_hook_fail', billingConfigKey: 'pro_monthly' },
|
|
593
|
+
}));
|
|
594
|
+
|
|
595
|
+
await expect(
|
|
596
|
+
callProcessWebhook({ stripe, config, updateSubscription: makeUpdateSubscription() })
|
|
597
|
+
).resolves.toEqual({ success: true, message: 'Webhook processed' });
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('returns success without calling updateSubscription when invoice has no subscriptionId', async () => {
|
|
601
|
+
const invoice = {
|
|
602
|
+
id: 'in_no_sub',
|
|
603
|
+
subscription: null,
|
|
604
|
+
customer: 'cus_no_sub',
|
|
605
|
+
metadata: {},
|
|
606
|
+
};
|
|
607
|
+
const event = makeEvent('invoice.payment_succeeded', invoice);
|
|
608
|
+
const stripe = makeStripe(event);
|
|
609
|
+
const updateSubscription = makeUpdateSubscription();
|
|
610
|
+
|
|
611
|
+
await callProcessWebhook({ stripe, updateSubscription });
|
|
612
|
+
|
|
613
|
+
expect(updateSubscription).not.toHaveBeenCalled();
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('returns success without calling updateSubscription when subscription metadata is missing userId', async () => {
|
|
617
|
+
const invoice = {
|
|
618
|
+
id: 'in_no_metadata',
|
|
619
|
+
subscription: 'sub_no_meta',
|
|
620
|
+
customer: 'cus_no_meta',
|
|
621
|
+
metadata: {},
|
|
622
|
+
};
|
|
623
|
+
const event = makeEvent('invoice.payment_succeeded', invoice);
|
|
624
|
+
const stripe = makeStripe(event);
|
|
625
|
+
stripe.subscriptions.retrieve = vi.fn(async () => ({
|
|
626
|
+
id: 'sub_no_meta',
|
|
627
|
+
metadata: {},
|
|
628
|
+
}));
|
|
629
|
+
const updateSubscription = makeUpdateSubscription();
|
|
630
|
+
|
|
631
|
+
await callProcessWebhook({ stripe, updateSubscription });
|
|
632
|
+
|
|
633
|
+
expect(updateSubscription).not.toHaveBeenCalled();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('returns success without updating when billingItem type is StripePayment (not renewable)', async () => {
|
|
637
|
+
const config = {
|
|
638
|
+
lifetime_purchase: makeConfig().lifetime_purchase,
|
|
639
|
+
};
|
|
640
|
+
const invoice = {
|
|
641
|
+
id: 'in_payment_type',
|
|
642
|
+
subscription: 'sub_payment_type',
|
|
643
|
+
customer: 'cus_payment_type',
|
|
644
|
+
metadata: {},
|
|
645
|
+
};
|
|
646
|
+
const event = makeEvent('invoice.payment_succeeded', invoice);
|
|
647
|
+
const stripe = makeStripe(event);
|
|
648
|
+
stripe.subscriptions.retrieve = vi.fn(async () => ({
|
|
649
|
+
id: 'sub_payment_type',
|
|
650
|
+
metadata: { userId: 'u_payment_type', billingConfigKey: 'lifetime_purchase' },
|
|
651
|
+
}));
|
|
652
|
+
const updateSubscription = makeUpdateSubscription();
|
|
653
|
+
|
|
654
|
+
await callProcessWebhook({ stripe, config, updateSubscription });
|
|
655
|
+
|
|
656
|
+
expect(updateSubscription).not.toHaveBeenCalled();
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('throws when authProvider is null during renewal', async () => {
|
|
660
|
+
const invoice = {
|
|
661
|
+
id: 'in_no_auth',
|
|
662
|
+
subscription: 'sub_no_auth',
|
|
663
|
+
customer: 'cus_no_auth',
|
|
664
|
+
metadata: {},
|
|
665
|
+
};
|
|
666
|
+
const event = makeEvent('invoice.payment_succeeded', invoice);
|
|
667
|
+
const stripe = makeStripe(event);
|
|
668
|
+
stripe.subscriptions.retrieve = vi.fn(async () => ({
|
|
669
|
+
id: 'sub_no_auth',
|
|
670
|
+
metadata: { userId: 'u_no_auth', billingConfigKey: 'pro_monthly' },
|
|
671
|
+
}));
|
|
672
|
+
|
|
673
|
+
await expect(
|
|
674
|
+
callProcessWebhook({ stripe, authProvider: null })
|
|
675
|
+
).rejects.toThrow();
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
describe('event routing — customer.subscription.deleted', () => {
|
|
682
|
+
beforeEach(() => {
|
|
683
|
+
resetIdempotencyStore();
|
|
684
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
685
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
686
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it('calls updateSubscription with CANCELED status and FREE tier', async () => {
|
|
690
|
+
const periodEnd = Math.floor(Date.now() / 1000) + 86400;
|
|
691
|
+
const subscription = {
|
|
692
|
+
id: 'sub_del',
|
|
693
|
+
customer: 'cus_del',
|
|
694
|
+
metadata: { userId: 'u_del', billingConfigKey: 'pro_monthly' },
|
|
695
|
+
current_period_end: periodEnd,
|
|
696
|
+
};
|
|
697
|
+
const event = makeEvent('customer.subscription.deleted', subscription);
|
|
698
|
+
const updateSubscription = makeUpdateSubscription();
|
|
699
|
+
|
|
700
|
+
await callProcessWebhook({ stripe: makeStripe(event), updateSubscription });
|
|
701
|
+
|
|
702
|
+
expect(updateSubscription).toHaveBeenCalledOnce();
|
|
703
|
+
const [userId, data] = (updateSubscription as any).mock.calls[0];
|
|
704
|
+
expect(userId).toBe('u_del');
|
|
705
|
+
expect(data.status).toBe('canceled');
|
|
706
|
+
expect(data.tier).toBe('free');
|
|
707
|
+
expect(data.subscriptionId).toBe('sub_del');
|
|
708
|
+
expect(data.customerId).toBe('cus_del');
|
|
709
|
+
// subscriptionEnd is derived from current_period_end timestamp
|
|
710
|
+
expect(typeof data.subscriptionEnd).toBe('string');
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it('calls onSubscriptionCancelled hook on successful cancellation', async () => {
|
|
714
|
+
const onSubscriptionCancelled = vi.fn(async () => {});
|
|
715
|
+
const config = makeConfig({ onSubscriptionCancelled });
|
|
716
|
+
|
|
717
|
+
const periodEnd = Math.floor(Date.now() / 1000) + 86400;
|
|
718
|
+
const subscription = {
|
|
719
|
+
id: 'sub_cancel_hook',
|
|
720
|
+
customer: 'cus_cancel_hook',
|
|
721
|
+
metadata: { userId: 'u_cancel_hook', billingConfigKey: 'pro_monthly' },
|
|
722
|
+
current_period_end: periodEnd,
|
|
723
|
+
};
|
|
724
|
+
const event = makeEvent('customer.subscription.deleted', subscription);
|
|
725
|
+
|
|
726
|
+
await callProcessWebhook({ stripe: makeStripe(event), config, updateSubscription: makeUpdateSubscription() });
|
|
727
|
+
|
|
728
|
+
expect(onSubscriptionCancelled).toHaveBeenCalledWith('u_cancel_hook', expect.any(Object));
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('does NOT throw when onSubscriptionCancelled hook throws (non-critical)', async () => {
|
|
732
|
+
const onSubscriptionCancelled = vi.fn(async () => {
|
|
733
|
+
throw new Error('cancel hook failed');
|
|
734
|
+
});
|
|
735
|
+
const config = makeConfig({ onSubscriptionCancelled });
|
|
736
|
+
|
|
737
|
+
const periodEnd = Math.floor(Date.now() / 1000) + 86400;
|
|
738
|
+
const subscription = {
|
|
739
|
+
id: 'sub_cancel_hf',
|
|
740
|
+
customer: 'cus_cancel_hf',
|
|
741
|
+
metadata: { userId: 'u_cancel_hf', billingConfigKey: 'pro_monthly' },
|
|
742
|
+
current_period_end: periodEnd,
|
|
743
|
+
};
|
|
744
|
+
const event = makeEvent('customer.subscription.deleted', subscription);
|
|
745
|
+
|
|
746
|
+
await expect(
|
|
747
|
+
callProcessWebhook({ stripe: makeStripe(event), config, updateSubscription: makeUpdateSubscription() })
|
|
748
|
+
).resolves.toEqual({ success: true, message: 'Webhook processed' });
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it('returns success without calling updateSubscription when metadata is missing', async () => {
|
|
752
|
+
const subscription = {
|
|
753
|
+
id: 'sub_no_meta_del',
|
|
754
|
+
customer: 'cus_no_meta_del',
|
|
755
|
+
metadata: {},
|
|
756
|
+
current_period_end: Date.now() / 1000,
|
|
757
|
+
};
|
|
758
|
+
const event = makeEvent('customer.subscription.deleted', subscription);
|
|
759
|
+
const updateSubscription = makeUpdateSubscription();
|
|
760
|
+
|
|
761
|
+
await callProcessWebhook({ stripe: makeStripe(event), updateSubscription });
|
|
762
|
+
|
|
763
|
+
expect(updateSubscription).not.toHaveBeenCalled();
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('returns success without updating when billingItem type is StripePayment (not cancellable)', async () => {
|
|
767
|
+
const config = {
|
|
768
|
+
lifetime_purchase: makeConfig().lifetime_purchase,
|
|
769
|
+
};
|
|
770
|
+
const subscription = {
|
|
771
|
+
id: 'sub_pay_del',
|
|
772
|
+
customer: 'cus_pay_del',
|
|
773
|
+
metadata: { userId: 'u_pay_del', billingConfigKey: 'lifetime_purchase' },
|
|
774
|
+
current_period_end: Date.now() / 1000,
|
|
775
|
+
};
|
|
776
|
+
const event = makeEvent('customer.subscription.deleted', subscription);
|
|
777
|
+
const updateSubscription = makeUpdateSubscription();
|
|
778
|
+
|
|
779
|
+
await callProcessWebhook({ stripe: makeStripe(event), config, updateSubscription });
|
|
780
|
+
|
|
781
|
+
expect(updateSubscription).not.toHaveBeenCalled();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('throws when authProvider is null during cancellation', async () => {
|
|
785
|
+
const subscription = {
|
|
786
|
+
id: 'sub_no_auth_del',
|
|
787
|
+
customer: 'cus_no_auth_del',
|
|
788
|
+
metadata: { userId: 'u_no_auth_del', billingConfigKey: 'pro_monthly' },
|
|
789
|
+
current_period_end: Date.now() / 1000,
|
|
790
|
+
};
|
|
791
|
+
const event = makeEvent('customer.subscription.deleted', subscription);
|
|
792
|
+
|
|
793
|
+
await expect(
|
|
794
|
+
callProcessWebhook({ stripe: makeStripe(event), authProvider: null })
|
|
795
|
+
).rejects.toThrow();
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
// ---------------------------------------------------------------------------
|
|
800
|
+
|
|
801
|
+
describe('event routing — invoice.payment_failed', () => {
|
|
802
|
+
beforeEach(() => {
|
|
803
|
+
resetIdempotencyStore();
|
|
804
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
805
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
806
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('calls onPurchaseFailure for StripePayment billing items', async () => {
|
|
810
|
+
const onPurchaseFailure = vi.fn(async () => {});
|
|
811
|
+
const config = {
|
|
812
|
+
...makeConfig(),
|
|
813
|
+
lifetime_purchase: {
|
|
814
|
+
...makeConfig().lifetime_purchase,
|
|
815
|
+
onPurchaseFailure,
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
const invoice = {
|
|
819
|
+
id: 'in_fail_pay',
|
|
820
|
+
customer: 'cus_fail_pay',
|
|
821
|
+
metadata: { userId: 'u_fail_pay', billingConfigKey: 'lifetime_purchase' },
|
|
822
|
+
};
|
|
823
|
+
const event = makeEvent('invoice.payment_failed', invoice);
|
|
824
|
+
|
|
825
|
+
await callProcessWebhook({ stripe: makeStripe(event), config });
|
|
826
|
+
|
|
827
|
+
expect(onPurchaseFailure).toHaveBeenCalledWith('u_fail_pay', expect.any(Object));
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('calls onPaymentFailed for StripeSubscription billing items', async () => {
|
|
831
|
+
const onPaymentFailed = vi.fn(async () => {});
|
|
832
|
+
const config = makeConfig({ onPaymentFailed });
|
|
833
|
+
const invoice = {
|
|
834
|
+
id: 'in_fail_sub',
|
|
835
|
+
customer: 'cus_fail_sub',
|
|
836
|
+
metadata: { userId: 'u_fail_sub', billingConfigKey: 'pro_monthly' },
|
|
837
|
+
};
|
|
838
|
+
const event = makeEvent('invoice.payment_failed', invoice);
|
|
839
|
+
|
|
840
|
+
await callProcessWebhook({ stripe: makeStripe(event), config });
|
|
841
|
+
|
|
842
|
+
expect(onPaymentFailed).toHaveBeenCalledWith('u_fail_sub', expect.any(Object));
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it('does NOT throw when onPurchaseFailure hook throws (non-critical)', async () => {
|
|
846
|
+
const onPurchaseFailure = vi.fn(async () => {
|
|
847
|
+
throw new Error('failure hook exploded');
|
|
848
|
+
});
|
|
849
|
+
const config = {
|
|
850
|
+
...makeConfig(),
|
|
851
|
+
lifetime_purchase: {
|
|
852
|
+
...makeConfig().lifetime_purchase,
|
|
853
|
+
onPurchaseFailure,
|
|
854
|
+
},
|
|
855
|
+
};
|
|
856
|
+
const invoice = {
|
|
857
|
+
id: 'in_hf_pay',
|
|
858
|
+
customer: 'cus_hf_pay',
|
|
859
|
+
metadata: { userId: 'u_hf_pay', billingConfigKey: 'lifetime_purchase' },
|
|
860
|
+
};
|
|
861
|
+
const event = makeEvent('invoice.payment_failed', invoice);
|
|
862
|
+
|
|
863
|
+
await expect(
|
|
864
|
+
callProcessWebhook({ stripe: makeStripe(event), config })
|
|
865
|
+
).resolves.toEqual({ success: true, message: 'Webhook processed' });
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it('does NOT throw when onPaymentFailed hook throws (non-critical)', async () => {
|
|
869
|
+
const onPaymentFailed = vi.fn(async () => {
|
|
870
|
+
throw new Error('payment failed hook exploded');
|
|
871
|
+
});
|
|
872
|
+
const config = makeConfig({ onPaymentFailed });
|
|
873
|
+
const invoice = {
|
|
874
|
+
id: 'in_hf_sub',
|
|
875
|
+
customer: 'cus_hf_sub',
|
|
876
|
+
metadata: { userId: 'u_hf_sub', billingConfigKey: 'pro_monthly' },
|
|
877
|
+
};
|
|
878
|
+
const event = makeEvent('invoice.payment_failed', invoice);
|
|
879
|
+
|
|
880
|
+
await expect(
|
|
881
|
+
callProcessWebhook({ stripe: makeStripe(event), config })
|
|
882
|
+
).resolves.toEqual({ success: true, message: 'Webhook processed' });
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it('returns success without calling hooks when invoice metadata is missing', async () => {
|
|
886
|
+
const onPaymentFailed = vi.fn(async () => {});
|
|
887
|
+
const config = makeConfig({ onPaymentFailed });
|
|
888
|
+
const invoice = {
|
|
889
|
+
id: 'in_no_meta_fail',
|
|
890
|
+
customer: 'cus_no_meta_fail',
|
|
891
|
+
metadata: {},
|
|
892
|
+
};
|
|
893
|
+
const event = makeEvent('invoice.payment_failed', invoice);
|
|
894
|
+
|
|
895
|
+
await callProcessWebhook({ stripe: makeStripe(event), config });
|
|
896
|
+
|
|
897
|
+
expect(onPaymentFailed).not.toHaveBeenCalled();
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
it('returns success without calling hooks when billingConfigKey is unknown', async () => {
|
|
901
|
+
const onPaymentFailed = vi.fn(async () => {});
|
|
902
|
+
const config = makeConfig({ onPaymentFailed });
|
|
903
|
+
const invoice = {
|
|
904
|
+
id: 'in_bad_key_fail',
|
|
905
|
+
customer: 'cus_bad_key_fail',
|
|
906
|
+
metadata: { userId: 'u_bad_key', billingConfigKey: 'does_not_exist' },
|
|
907
|
+
};
|
|
908
|
+
const event = makeEvent('invoice.payment_failed', invoice);
|
|
909
|
+
|
|
910
|
+
await callProcessWebhook({ stripe: makeStripe(event), config });
|
|
911
|
+
|
|
912
|
+
expect(onPaymentFailed).not.toHaveBeenCalled();
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it('does not call updateSubscription (payment_failed is notification-only)', async () => {
|
|
916
|
+
const config = makeConfig();
|
|
917
|
+
const invoice = {
|
|
918
|
+
id: 'in_no_update',
|
|
919
|
+
customer: 'cus_no_update',
|
|
920
|
+
metadata: { userId: 'u_no_update', billingConfigKey: 'pro_monthly' },
|
|
921
|
+
};
|
|
922
|
+
const event = makeEvent('invoice.payment_failed', invoice);
|
|
923
|
+
const updateSubscription = makeUpdateSubscription();
|
|
924
|
+
|
|
925
|
+
await callProcessWebhook({ stripe: makeStripe(event), config, updateSubscription });
|
|
926
|
+
|
|
927
|
+
expect(updateSubscription).not.toHaveBeenCalled();
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
// ---------------------------------------------------------------------------
|
|
932
|
+
|
|
933
|
+
describe('event routing — unknown event types', () => {
|
|
934
|
+
beforeEach(() => {
|
|
935
|
+
resetIdempotencyStore();
|
|
936
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
937
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
938
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it('handles unknown event type gracefully — returns success without throwing', async () => {
|
|
942
|
+
const event = makeEvent('customer.updated', { id: 'cus_unknown_type' });
|
|
943
|
+
const updateSubscription = makeUpdateSubscription();
|
|
944
|
+
|
|
945
|
+
const result = await callProcessWebhook({
|
|
946
|
+
stripe: makeStripe(event),
|
|
947
|
+
updateSubscription,
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
expect(result).toEqual({ success: true, message: 'Webhook processed' });
|
|
951
|
+
expect(updateSubscription).not.toHaveBeenCalled();
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it('handles payment_intent.succeeded gracefully (not in router)', async () => {
|
|
955
|
+
const event = makeEvent('payment_intent.succeeded', { id: 'pi_not_routed' });
|
|
956
|
+
|
|
957
|
+
const result = await callProcessWebhook({ stripe: makeStripe(event) });
|
|
958
|
+
|
|
959
|
+
expect(result).toEqual({ success: true, message: 'Webhook processed' });
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
it('handles customer.subscription.updated gracefully (not in router)', async () => {
|
|
963
|
+
const event = makeEvent('customer.subscription.updated', {
|
|
964
|
+
id: 'sub_updated',
|
|
965
|
+
metadata: {},
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
const result = await callProcessWebhook({ stripe: makeStripe(event) });
|
|
969
|
+
|
|
970
|
+
expect(result).toEqual({ success: true, message: 'Webhook processed' });
|
|
971
|
+
});
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
// ---------------------------------------------------------------------------
|
|
975
|
+
|
|
976
|
+
describe('state machine transitions', () => {
|
|
977
|
+
beforeEach(() => {
|
|
978
|
+
resetIdempotencyStore();
|
|
979
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
980
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
981
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it('checkout.session.completed → status=active (StripeSubscription)', async () => {
|
|
985
|
+
const event = makeEvent('checkout.session.completed', {
|
|
986
|
+
metadata: { userId: 'u_sm_active', billingConfigKey: 'pro_monthly' },
|
|
987
|
+
subscription: 'sub_sm_active',
|
|
988
|
+
customer: 'cus_sm_active',
|
|
989
|
+
});
|
|
990
|
+
const updateSubscription = makeUpdateSubscription();
|
|
991
|
+
|
|
992
|
+
await callProcessWebhook({ stripe: makeStripe(event), updateSubscription });
|
|
993
|
+
|
|
994
|
+
const [, data] = (updateSubscription as any).mock.calls[0];
|
|
995
|
+
expect(data.status).toBe('active');
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('checkout.session.completed → status=active (StripePayment)', async () => {
|
|
999
|
+
const event = makeEvent('checkout.session.completed', {
|
|
1000
|
+
metadata: { userId: 'u_sm_pay_active', billingConfigKey: 'lifetime_purchase' },
|
|
1001
|
+
subscription: null,
|
|
1002
|
+
customer: 'cus_sm_pay_active',
|
|
1003
|
+
});
|
|
1004
|
+
const updateSubscription = makeUpdateSubscription();
|
|
1005
|
+
|
|
1006
|
+
await callProcessWebhook({ stripe: makeStripe(event), updateSubscription });
|
|
1007
|
+
|
|
1008
|
+
const [, data] = (updateSubscription as any).mock.calls[0];
|
|
1009
|
+
expect(data.status).toBe('active');
|
|
1010
|
+
expect(data.tier).toBe('premium');
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
it('invoice.payment_succeeded → status=active (renewal)', async () => {
|
|
1014
|
+
const invoice = {
|
|
1015
|
+
id: 'in_sm_renewal',
|
|
1016
|
+
subscription: 'sub_sm_renewal',
|
|
1017
|
+
customer: 'cus_sm_renewal',
|
|
1018
|
+
metadata: {},
|
|
1019
|
+
};
|
|
1020
|
+
const event = makeEvent('invoice.payment_succeeded', invoice);
|
|
1021
|
+
const stripe = makeStripe(event);
|
|
1022
|
+
stripe.subscriptions.retrieve = vi.fn(async () => ({
|
|
1023
|
+
id: 'sub_sm_renewal',
|
|
1024
|
+
metadata: { userId: 'u_sm_renewal', billingConfigKey: 'pro_monthly' },
|
|
1025
|
+
}));
|
|
1026
|
+
const updateSubscription = makeUpdateSubscription();
|
|
1027
|
+
|
|
1028
|
+
await callProcessWebhook({ stripe, updateSubscription });
|
|
1029
|
+
|
|
1030
|
+
const [, data] = (updateSubscription as any).mock.calls[0];
|
|
1031
|
+
expect(data.status).toBe('active');
|
|
1032
|
+
expect(data.tier).toBe('pro');
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
it('customer.subscription.deleted → status=canceled, tier=free (downgrade)', async () => {
|
|
1036
|
+
const periodEnd = Math.floor(Date.now() / 1000) + 86400;
|
|
1037
|
+
const subscription = {
|
|
1038
|
+
id: 'sub_sm_del',
|
|
1039
|
+
customer: 'cus_sm_del',
|
|
1040
|
+
metadata: { userId: 'u_sm_del', billingConfigKey: 'pro_monthly' },
|
|
1041
|
+
current_period_end: periodEnd,
|
|
1042
|
+
};
|
|
1043
|
+
const event = makeEvent('customer.subscription.deleted', subscription);
|
|
1044
|
+
const updateSubscription = makeUpdateSubscription();
|
|
1045
|
+
|
|
1046
|
+
await callProcessWebhook({ stripe: makeStripe(event), updateSubscription });
|
|
1047
|
+
|
|
1048
|
+
const [, data] = (updateSubscription as any).mock.calls[0];
|
|
1049
|
+
expect(data.status).toBe('canceled');
|
|
1050
|
+
expect(data.tier).toBe('free');
|
|
1051
|
+
// subscriptionEnd set to current_period_end
|
|
1052
|
+
const expectedEnd = new Date(periodEnd * 1000).toISOString();
|
|
1053
|
+
expect(data.subscriptionEnd).toBe(expectedEnd);
|
|
1054
|
+
});
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
// ---------------------------------------------------------------------------
|
|
1058
|
+
|
|
1059
|
+
describe('processWebhook — return value', () => {
|
|
1060
|
+
beforeEach(() => {
|
|
1061
|
+
resetIdempotencyStore();
|
|
1062
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
1063
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
1064
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it('returns { success: true, message: "Webhook processed" } on success', async () => {
|
|
1068
|
+
const event = makeEvent('checkout.session.completed', {
|
|
1069
|
+
metadata: { userId: 'u_ret', billingConfigKey: 'pro_monthly' },
|
|
1070
|
+
subscription: 'sub_ret',
|
|
1071
|
+
customer: 'cus_ret',
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
const result = await callProcessWebhook({ stripe: makeStripe(event) });
|
|
1075
|
+
|
|
1076
|
+
expect(result).toEqual({ success: true, message: 'Webhook processed' });
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it('returns { success: true, message: "Event already processed" } for duplicate event', async () => {
|
|
1080
|
+
const event = makeEvent('checkout.session.completed', {
|
|
1081
|
+
metadata: { userId: 'u_dup2', billingConfigKey: 'pro_monthly' },
|
|
1082
|
+
subscription: 'sub_dup2',
|
|
1083
|
+
customer: 'cus_dup2',
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
await callProcessWebhook({ stripe: makeStripe(event) });
|
|
1087
|
+
const result = await callProcessWebhook({ stripe: makeStripe(event) });
|
|
1088
|
+
|
|
1089
|
+
expect(result).toEqual({ success: true, message: 'Event already processed' });
|
|
1090
|
+
});
|
|
1091
|
+
});
|