evaldoc 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/.env.staging.example +19 -0
- package/.firebaserc +6 -0
- package/.github/workflows/auto-merge-to-main.yml +28 -0
- package/.github/workflows/ci.yml +27 -0
- package/.github/workflows/npxhub-publish.yml +170 -0
- package/.prettierignore +4 -0
- package/.prettierrc.json +7 -0
- package/README.md +142 -0
- package/SRS.md +448 -0
- package/apphosting.staging.yaml +48 -0
- package/apphosting.yaml +50 -0
- package/bin/evaldoc.cjs +105 -0
- package/cors.json +8 -0
- package/docs/ARCHITECTURE.md +769 -0
- package/docs/ARCHITECTURE_SUMMARY.md +323 -0
- package/docs/DEPLOYMENT.md +86 -0
- package/docs/DEPLOYMENT_STEPS.md +373 -0
- package/docs/DEPLOY_FIRESTORE_RULES.md +239 -0
- package/docs/DEPLOY_STORAGE_RULES.md +82 -0
- package/docs/DOCTOR_TUTORIAL.md +467 -0
- package/docs/FIREBASE_STORAGE_CORS_FIX.md +131 -0
- package/docs/FIXES_APPLIED.md +128 -0
- package/docs/IMPLEMENTATION.md +498 -0
- package/docs/IMPLEMENTATION_COMPLETE.md +317 -0
- package/docs/LLM-SETUP.md +216 -0
- package/docs/MANUAL_TEST_GUIDE.md +327 -0
- package/docs/PATIENT_GUIDE.md +451 -0
- package/docs/PLATFORM_OVERVIEW.md +194 -0
- package/docs/QUICKSTART.md +252 -0
- package/docs/QUICK_REFERENCE.md +222 -0
- package/docs/READY_TO_TEST.md +383 -0
- package/docs/RUN.md +237 -0
- package/docs/START_TESTING.md +130 -0
- package/docs/STRIPE_CONNECT_SETUP.md +454 -0
- package/docs/SYSTEM_READY.md +345 -0
- package/docs/THREADS_IMPLEMENTATION_COMPLETE.md +431 -0
- package/docs/THREAD_IMPLEMENTATION_GUIDE.md +345 -0
- package/docs/firebasefiles.md +21 -0
- package/docs/homepage.md +1208 -0
- package/docs/payments.md +159 -0
- package/eslint.config.js +76 -0
- package/firebase.json +27 -0
- package/firestore.indexes.json +88 -0
- package/firestore.rules +125 -0
- package/functions/package-lock.json +2884 -0
- package/functions/package.json +20 -0
- package/functions/src/analytics.js +101 -0
- package/functions/src/audit.js +28 -0
- package/functions/src/auth.js +19 -0
- package/functions/src/drafts.js +128 -0
- package/functions/src/feedback.js +164 -0
- package/functions/src/firebase-init.js +29 -0
- package/functions/src/i18n.js +134 -0
- package/functions/src/limits.js +27 -0
- package/functions/src/llm.js +271 -0
- package/functions/src/ner.js +162 -0
- package/functions/src/notifications.js +273 -0
- package/functions/src/orgs.js +127 -0
- package/functions/src/patient-api.js +545 -0
- package/functions/src/payment.js +830 -0
- package/functions/src/safety.js +188 -0
- package/functions/src/stripe-config.js +33 -0
- package/functions/src/topics.js +94 -0
- package/functions/src/triage.js +191 -0
- package/functions/src/utils.js +28 -0
- package/functions/src/validate.js +177 -0
- package/functions/test-firebase.js +36 -0
- package/jest.config.js +18 -0
- package/package.json +52 -0
- package/public/404.html +93 -0
- package/public/app/analytics.html +336 -0
- package/public/app/analytics.js +177 -0
- package/public/app/app.css +1400 -0
- package/public/app/app.js +1754 -0
- package/public/app/config.js +14 -0
- package/public/app/inbox-threads.js +843 -0
- package/public/app/inbox.html +598 -0
- package/public/app/inbox.js +397 -0
- package/public/app/index.html +612 -0
- package/public/assets/logo.jpg +0 -0
- package/public/chat.html +137 -0
- package/public/css/chat.css +870 -0
- package/public/css/landing-enhance.css +298 -0
- package/public/css/styles.css +1198 -0
- package/public/favicon.svg +5 -0
- package/public/find-doctor.html +220 -0
- package/public/index.html +399 -0
- package/public/js/chat-threads.js +513 -0
- package/public/js/chat.js +832 -0
- package/public/js/search.js +41 -0
- package/public/privacy.html +101 -0
- package/public/robots.txt +6 -0
- package/public/site.webmanifest +12 -0
- package/public/terms.html +110 -0
- package/scripts/create-test-provider.js +55 -0
- package/scripts/deploy-staging.sh +51 -0
- package/scripts/fix-provider.js +39 -0
- package/scripts/quick-test.sh +37 -0
- package/scripts/test-chat.sh +54 -0
- package/scripts/test-multi-turn.js +137 -0
- package/scripts/test-ner-simple.js +28 -0
- package/scripts/test-ner.js +60 -0
- package/scripts/test-stripe-connect.js +304 -0
- package/scripts/test-webhook-secret.sh +24 -0
- package/server.js +3864 -0
- package/storage.rules +22 -0
- package/test-api.json +1 -0
- package/tests/emulator/README.md +38 -0
- package/tests/emulator/counters.emulator.test.js +81 -0
- package/tests/integration/api-account-selfserve.test.js +312 -0
- package/tests/integration/api-admin-digest.test.js +202 -0
- package/tests/integration/api-chat-satisfaction.test.js +259 -0
- package/tests/integration/api-doctor-drafts.test.js +236 -0
- package/tests/integration/api-doctor-inbox.test.js +401 -0
- package/tests/integration/api-org.test.js +328 -0
- package/tests/integration/api-payment.test.js +664 -0
- package/tests/integration/api-provider-settings.test.js +475 -0
- package/tests/integration/api-qr.test.js +121 -0
- package/tests/integration/api-security.test.js +241 -0
- package/tests/integration/api-stripe-search.test.js +303 -0
- package/tests/integration/api-webhook-deletion.test.js +231 -0
- package/tests/mocks/firebase-admin.js +220 -0
- package/tests/unit/analytics.test.js +151 -0
- package/tests/unit/audit-bugs.test.js +203 -0
- package/tests/unit/chat-ui-logic.test.js +283 -0
- package/tests/unit/drafts.test.js +104 -0
- package/tests/unit/feedback.test.js +148 -0
- package/tests/unit/i18n.test.js +229 -0
- package/tests/unit/llm-retry.test.js +122 -0
- package/tests/unit/notifications.test.js +155 -0
- package/tests/unit/orgs.test.js +328 -0
- package/tests/unit/payment.test.js +889 -0
- package/tests/unit/safety-edge-cases.test.js +509 -0
- package/tests/unit/safety-srs-compliance.test.js +252 -0
- package/tests/unit/safety.test.js +291 -0
- package/tests/unit/server-helpers.test.js +774 -0
- package/tests/unit/topics-limits.test.js +158 -0
- package/tests/unit/triage.test.js +152 -0
- package/tests/unit/validate.test.js +408 -0
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for payment.js module
|
|
3
|
+
*
|
|
4
|
+
* Tests Stripe Connect functions, checkout sessions, payment intents,
|
|
5
|
+
* subscriptions, access control, revenue analytics, and webhook handling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Mock firebase-admin
|
|
9
|
+
jest.mock('firebase-admin', () => require('../mocks/firebase-admin'));
|
|
10
|
+
|
|
11
|
+
// Mock Stripe
|
|
12
|
+
const mockStripe = {
|
|
13
|
+
accounts: {
|
|
14
|
+
create: jest.fn(),
|
|
15
|
+
retrieve: jest.fn()
|
|
16
|
+
},
|
|
17
|
+
accountLinks: {
|
|
18
|
+
create: jest.fn()
|
|
19
|
+
},
|
|
20
|
+
products: {
|
|
21
|
+
create: jest.fn()
|
|
22
|
+
},
|
|
23
|
+
prices: {
|
|
24
|
+
create: jest.fn()
|
|
25
|
+
},
|
|
26
|
+
customers: {
|
|
27
|
+
create: jest.fn()
|
|
28
|
+
},
|
|
29
|
+
checkout: {
|
|
30
|
+
sessions: {
|
|
31
|
+
create: jest.fn()
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
paymentIntents: {
|
|
35
|
+
create: jest.fn()
|
|
36
|
+
},
|
|
37
|
+
subscriptions: {
|
|
38
|
+
create: jest.fn(),
|
|
39
|
+
update: jest.fn()
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
jest.mock('../../functions/src/stripe-config', () => ({ stripe: mockStripe }));
|
|
44
|
+
|
|
45
|
+
const admin = require('firebase-admin');
|
|
46
|
+
const {
|
|
47
|
+
createConnectAccount,
|
|
48
|
+
createAccountLink,
|
|
49
|
+
refreshAccountStatus,
|
|
50
|
+
setProviderPricing,
|
|
51
|
+
createCheckoutSession,
|
|
52
|
+
createPaymentIntent,
|
|
53
|
+
createSubscription,
|
|
54
|
+
cancelSubscription,
|
|
55
|
+
checkPaymentAccess,
|
|
56
|
+
getRevenueAnalytics,
|
|
57
|
+
handleStripeWebhook
|
|
58
|
+
} = require('../../functions/src/payment');
|
|
59
|
+
|
|
60
|
+
describe('Payment Module', () => {
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
admin.__reset();
|
|
63
|
+
jest.clearAllMocks();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ============================================
|
|
67
|
+
// STRIPE CONNECT FUNCTIONS
|
|
68
|
+
// ============================================
|
|
69
|
+
|
|
70
|
+
describe('createConnectAccount', () => {
|
|
71
|
+
it('should create a Stripe Connect Express account and update Firestore', async () => {
|
|
72
|
+
admin.__seed('providers', 'doc-1', { displayName: 'Dr. Test' });
|
|
73
|
+
|
|
74
|
+
mockStripe.accounts.create.mockResolvedValue({
|
|
75
|
+
id: 'acct_123',
|
|
76
|
+
charges_enabled: false,
|
|
77
|
+
payouts_enabled: false
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const result = await createConnectAccount({
|
|
81
|
+
providerId: 'doc-1',
|
|
82
|
+
email: 'doc@test.com',
|
|
83
|
+
displayName: 'Dr. Test',
|
|
84
|
+
country: 'US'
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(result.accountId).toBe('acct_123');
|
|
88
|
+
expect(result.onboardingStatus).toBe('PENDING');
|
|
89
|
+
|
|
90
|
+
expect(mockStripe.accounts.create).toHaveBeenCalledWith(
|
|
91
|
+
expect.objectContaining({
|
|
92
|
+
type: 'express',
|
|
93
|
+
country: 'US',
|
|
94
|
+
email: 'doc@test.com',
|
|
95
|
+
capabilities: {
|
|
96
|
+
card_payments: { requested: true },
|
|
97
|
+
transfers: { requested: true }
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Verify Firestore was updated
|
|
103
|
+
const provider = admin.__getAll('providers')['doc-1'];
|
|
104
|
+
expect(provider.stripeAccountId).toBe('acct_123');
|
|
105
|
+
expect(provider.stripeOnboardingStatus).toBe('PENDING');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should throw on Stripe API error', async () => {
|
|
109
|
+
admin.__seed('providers', 'doc-1', { displayName: 'Dr. Test' });
|
|
110
|
+
mockStripe.accounts.create.mockRejectedValue(new Error('Stripe error'));
|
|
111
|
+
|
|
112
|
+
await expect(createConnectAccount({
|
|
113
|
+
providerId: 'doc-1',
|
|
114
|
+
email: 'doc@test.com'
|
|
115
|
+
})).rejects.toThrow('Stripe error');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('createAccountLink', () => {
|
|
120
|
+
it('should create an onboarding link', async () => {
|
|
121
|
+
mockStripe.accountLinks.create.mockResolvedValue({
|
|
122
|
+
url: 'https://connect.stripe.com/setup/onboarding',
|
|
123
|
+
expires_at: 1234567890
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const result = await createAccountLink({
|
|
127
|
+
stripeAccountId: 'acct_123',
|
|
128
|
+
providerId: 'doc-1'
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(result.url).toBe('https://connect.stripe.com/setup/onboarding');
|
|
132
|
+
expect(result.expiresAt).toBe(1234567890);
|
|
133
|
+
|
|
134
|
+
expect(mockStripe.accountLinks.create).toHaveBeenCalledWith(
|
|
135
|
+
expect.objectContaining({
|
|
136
|
+
account: 'acct_123',
|
|
137
|
+
type: 'account_onboarding'
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('refreshAccountStatus', () => {
|
|
144
|
+
it('should update Firestore with current account status', async () => {
|
|
145
|
+
admin.__seed('providers', 'doc-1', {
|
|
146
|
+
stripeAccountId: 'acct_123',
|
|
147
|
+
stripeOnboardingStatus: 'PENDING'
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
mockStripe.accounts.retrieve.mockResolvedValue({
|
|
151
|
+
charges_enabled: true,
|
|
152
|
+
payouts_enabled: true
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const result = await refreshAccountStatus({
|
|
156
|
+
providerId: 'doc-1',
|
|
157
|
+
stripeAccountId: 'acct_123'
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(result.chargesEnabled).toBe(true);
|
|
161
|
+
expect(result.payoutsEnabled).toBe(true);
|
|
162
|
+
expect(result.onboardingComplete).toBe(true);
|
|
163
|
+
|
|
164
|
+
const provider = admin.__getAll('providers')['doc-1'];
|
|
165
|
+
expect(provider.stripeOnboardingStatus).toBe('COMPLETE');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should keep PENDING status when not fully enabled', async () => {
|
|
169
|
+
admin.__seed('providers', 'doc-1', {
|
|
170
|
+
stripeAccountId: 'acct_123',
|
|
171
|
+
stripeOnboardingStatus: 'PENDING'
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
mockStripe.accounts.retrieve.mockResolvedValue({
|
|
175
|
+
charges_enabled: true,
|
|
176
|
+
payouts_enabled: false
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const result = await refreshAccountStatus({
|
|
180
|
+
providerId: 'doc-1',
|
|
181
|
+
stripeAccountId: 'acct_123'
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(result.onboardingComplete).toBe(false);
|
|
185
|
+
const provider = admin.__getAll('providers')['doc-1'];
|
|
186
|
+
expect(provider.stripeOnboardingStatus).toBe('PENDING');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ============================================
|
|
191
|
+
// PRICING
|
|
192
|
+
// ============================================
|
|
193
|
+
|
|
194
|
+
describe('setProviderPricing', () => {
|
|
195
|
+
it('should create product and prices on Stripe', async () => {
|
|
196
|
+
admin.__seed('providers', 'doc-1', { displayName: 'Dr. Test' });
|
|
197
|
+
|
|
198
|
+
mockStripe.products.create.mockResolvedValue({ id: 'prod_123' });
|
|
199
|
+
mockStripe.prices.create.mockResolvedValue({ id: 'price_month_123' });
|
|
200
|
+
|
|
201
|
+
const result = await setProviderPricing({
|
|
202
|
+
providerId: 'doc-1',
|
|
203
|
+
stripeAccountId: 'acct_123',
|
|
204
|
+
currency: 'usd',
|
|
205
|
+
prices: { month: 29.99 },
|
|
206
|
+
displayName: 'Dr. Test'
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(result.productId).toBe('prod_123');
|
|
210
|
+
expect(result.priceIds.month).toBe('price_month_123');
|
|
211
|
+
|
|
212
|
+
expect(mockStripe.products.create).toHaveBeenCalledWith(
|
|
213
|
+
expect.objectContaining({
|
|
214
|
+
name: 'EvalDoc AI Access - Dr. Test'
|
|
215
|
+
}),
|
|
216
|
+
{ stripeAccount: 'acct_123' }
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
expect(mockStripe.prices.create).toHaveBeenCalledWith(
|
|
220
|
+
expect.objectContaining({
|
|
221
|
+
product: 'prod_123',
|
|
222
|
+
unit_amount: 2999,
|
|
223
|
+
currency: 'usd',
|
|
224
|
+
recurring: { interval: 'month' }
|
|
225
|
+
}),
|
|
226
|
+
{ stripeAccount: 'acct_123' }
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should reuse existing product if available', async () => {
|
|
231
|
+
admin.__seed('providers', 'doc-1', {
|
|
232
|
+
displayName: 'Dr. Test',
|
|
233
|
+
stripeProductId: 'prod_existing'
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
mockStripe.prices.create.mockResolvedValue({ id: 'price_123' });
|
|
237
|
+
|
|
238
|
+
const result = await setProviderPricing({
|
|
239
|
+
providerId: 'doc-1',
|
|
240
|
+
stripeAccountId: 'acct_123',
|
|
241
|
+
prices: { month: 10 }
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(mockStripe.products.create).not.toHaveBeenCalled();
|
|
245
|
+
expect(result.productId).toBe('prod_existing');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should skip intervals with zero or null amounts', async () => {
|
|
249
|
+
admin.__seed('providers', 'doc-1', { displayName: 'Dr. Test' });
|
|
250
|
+
mockStripe.products.create.mockResolvedValue({ id: 'prod_123' });
|
|
251
|
+
|
|
252
|
+
await setProviderPricing({
|
|
253
|
+
providerId: 'doc-1',
|
|
254
|
+
stripeAccountId: 'acct_123',
|
|
255
|
+
prices: { month: 0, week: null, day: 5 }
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Only 'day' should create a price (month=0 and week=null are skipped)
|
|
259
|
+
expect(mockStripe.prices.create).toHaveBeenCalledTimes(1);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ============================================
|
|
264
|
+
// CHECKOUT & PAYMENT
|
|
265
|
+
// ============================================
|
|
266
|
+
|
|
267
|
+
describe('createCheckoutSession', () => {
|
|
268
|
+
it('should create checkout session with 25% platform fee', async () => {
|
|
269
|
+
admin.__seed('patients', 'patient-1', {});
|
|
270
|
+
|
|
271
|
+
mockStripe.customers.create.mockResolvedValue({ id: 'cus_123' });
|
|
272
|
+
mockStripe.checkout.sessions.create.mockResolvedValue({
|
|
273
|
+
id: 'cs_123',
|
|
274
|
+
url: 'https://checkout.stripe.com/pay/cs_123'
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const result = await createCheckoutSession({
|
|
278
|
+
providerId: 'doc-1',
|
|
279
|
+
patientId: 'patient-1',
|
|
280
|
+
interval: 'month',
|
|
281
|
+
stripeAccountId: 'acct_123',
|
|
282
|
+
stripePriceId: 'price_123'
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(result.sessionId).toBe('cs_123');
|
|
286
|
+
expect(result.url).toBe('https://checkout.stripe.com/pay/cs_123');
|
|
287
|
+
|
|
288
|
+
expect(mockStripe.checkout.sessions.create).toHaveBeenCalledWith(
|
|
289
|
+
expect.objectContaining({
|
|
290
|
+
mode: 'subscription',
|
|
291
|
+
subscription_data: expect.objectContaining({
|
|
292
|
+
application_fee_percent: 25
|
|
293
|
+
})
|
|
294
|
+
}),
|
|
295
|
+
{ stripeAccount: 'acct_123' }
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should reuse existing Stripe customer', async () => {
|
|
300
|
+
admin.__seed('patients', 'patient-1', { stripeCustomerId: 'cus_existing' });
|
|
301
|
+
|
|
302
|
+
mockStripe.checkout.sessions.create.mockResolvedValue({
|
|
303
|
+
id: 'cs_123',
|
|
304
|
+
url: 'https://checkout.stripe.com/pay/cs_123'
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await createCheckoutSession({
|
|
308
|
+
providerId: 'doc-1',
|
|
309
|
+
patientId: 'patient-1',
|
|
310
|
+
interval: 'month',
|
|
311
|
+
stripeAccountId: 'acct_123',
|
|
312
|
+
stripePriceId: 'price_123'
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(mockStripe.customers.create).not.toHaveBeenCalled();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('createPaymentIntent', () => {
|
|
320
|
+
it('should create payment intent with Connect and 25% fee', async () => {
|
|
321
|
+
mockStripe.paymentIntents.create.mockResolvedValue({
|
|
322
|
+
id: 'pi_123',
|
|
323
|
+
client_secret: 'pi_123_secret'
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const result = await createPaymentIntent({
|
|
327
|
+
providerId: 'doc-1',
|
|
328
|
+
patientId: 'patient-1',
|
|
329
|
+
amount: 20,
|
|
330
|
+
currency: 'usd',
|
|
331
|
+
stripeAccountId: 'acct_123',
|
|
332
|
+
metadata: { conversationId: 'conv-1' }
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
expect(result.clientSecret).toBe('pi_123_secret');
|
|
336
|
+
expect(result.paymentIntentId).toBe('pi_123');
|
|
337
|
+
|
|
338
|
+
expect(mockStripe.paymentIntents.create).toHaveBeenCalledWith(
|
|
339
|
+
expect.objectContaining({
|
|
340
|
+
amount: 2000,
|
|
341
|
+
currency: 'usd',
|
|
342
|
+
application_fee_amount: 500 // 25% of 2000
|
|
343
|
+
}),
|
|
344
|
+
{ stripeAccount: 'acct_123' }
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// Should save transaction record
|
|
348
|
+
const transactions = admin.__getAll('transactions');
|
|
349
|
+
const txIds = Object.keys(transactions);
|
|
350
|
+
expect(txIds.length).toBe(1);
|
|
351
|
+
expect(transactions[txIds[0]].amount).toBe(2000);
|
|
352
|
+
expect(transactions[txIds[0]].status).toBe('pending');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should create payment intent without Connect when no stripeAccountId', async () => {
|
|
356
|
+
mockStripe.paymentIntents.create.mockResolvedValue({
|
|
357
|
+
id: 'pi_456',
|
|
358
|
+
client_secret: 'pi_456_secret'
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await createPaymentIntent({
|
|
362
|
+
providerId: 'doc-1',
|
|
363
|
+
patientId: 'patient-1',
|
|
364
|
+
amount: 10
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
expect(mockStripe.paymentIntents.create).toHaveBeenCalledWith(
|
|
368
|
+
expect.not.objectContaining({ application_fee_amount: expect.anything() }),
|
|
369
|
+
{} // empty options, no stripeAccount
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// ============================================
|
|
375
|
+
// SUBSCRIPTIONS
|
|
376
|
+
// ============================================
|
|
377
|
+
|
|
378
|
+
describe('cancelSubscription', () => {
|
|
379
|
+
it('should cancel at period end by default', async () => {
|
|
380
|
+
admin.__seed('subscriptions', 'sub-doc-1', {
|
|
381
|
+
stripeSubscriptionId: 'sub_123',
|
|
382
|
+
status: 'active'
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
mockStripe.subscriptions.update.mockResolvedValue({
|
|
386
|
+
id: 'sub_123',
|
|
387
|
+
status: 'active',
|
|
388
|
+
cancel_at_period_end: true
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const result = await cancelSubscription({ subscriptionId: 'sub_123' });
|
|
392
|
+
|
|
393
|
+
expect(mockStripe.subscriptions.update).toHaveBeenCalledWith('sub_123', {
|
|
394
|
+
cancel_at_period_end: true
|
|
395
|
+
});
|
|
396
|
+
expect(result.cancel_at_period_end).toBe(true);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ============================================
|
|
401
|
+
// ACCESS CONTROL
|
|
402
|
+
// ============================================
|
|
403
|
+
|
|
404
|
+
describe('checkPaymentAccess', () => {
|
|
405
|
+
it('should grant access via access collection (fast path)', async () => {
|
|
406
|
+
admin.__seed('access', 'doc-1_patient-1', {
|
|
407
|
+
providerId: 'doc-1',
|
|
408
|
+
patientId: 'patient-1',
|
|
409
|
+
status: 'ACTIVE',
|
|
410
|
+
stripeSubscriptionId: 'sub_123'
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const result = await checkPaymentAccess({
|
|
414
|
+
providerId: 'doc-1',
|
|
415
|
+
patientId: 'patient-1'
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(result.hasAccess).toBe(true);
|
|
419
|
+
expect(result.type).toBe('subscription');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('should deny access when access doc is INACTIVE', async () => {
|
|
423
|
+
admin.__seed('access', 'doc-1_patient-1', {
|
|
424
|
+
providerId: 'doc-1',
|
|
425
|
+
patientId: 'patient-1',
|
|
426
|
+
status: 'INACTIVE'
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const result = await checkPaymentAccess({
|
|
430
|
+
providerId: 'doc-1',
|
|
431
|
+
patientId: 'patient-1'
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
expect(result.hasAccess).toBe(false);
|
|
435
|
+
expect(result.type).toBeNull();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should fallback to subscriptions collection for active subscription', async () => {
|
|
439
|
+
const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
440
|
+
const futureMs = futureDate.getTime();
|
|
441
|
+
|
|
442
|
+
const originalNow = admin.firestore.Timestamp.now;
|
|
443
|
+
admin.firestore.Timestamp.now = () => ({
|
|
444
|
+
_seconds: Math.floor(Date.now() / 1000),
|
|
445
|
+
_nanoseconds: 0,
|
|
446
|
+
toMillis: () => Date.now()
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
admin.__seed('subscriptions', 'sub-1', {
|
|
450
|
+
providerId: 'doc-1',
|
|
451
|
+
patientId: 'patient-1',
|
|
452
|
+
status: 'active',
|
|
453
|
+
currentPeriodEnd: {
|
|
454
|
+
_seconds: Math.floor(futureMs / 1000),
|
|
455
|
+
toMillis: () => futureMs
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const result = await checkPaymentAccess({
|
|
460
|
+
providerId: 'doc-1',
|
|
461
|
+
patientId: 'patient-1'
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
expect(result.hasAccess).toBe(true);
|
|
465
|
+
expect(result.type).toBe('subscription');
|
|
466
|
+
|
|
467
|
+
admin.firestore.Timestamp.now = originalNow;
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('should grant access for past_due subscription within period', async () => {
|
|
471
|
+
const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
472
|
+
const futureMs = futureDate.getTime();
|
|
473
|
+
|
|
474
|
+
const originalNow = admin.firestore.Timestamp.now;
|
|
475
|
+
admin.firestore.Timestamp.now = () => ({
|
|
476
|
+
_seconds: Math.floor(Date.now() / 1000),
|
|
477
|
+
_nanoseconds: 0,
|
|
478
|
+
toMillis: () => Date.now()
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
admin.__seed('subscriptions', 'sub-1', {
|
|
482
|
+
providerId: 'doc-1',
|
|
483
|
+
patientId: 'patient-1',
|
|
484
|
+
status: 'past_due',
|
|
485
|
+
currentPeriodEnd: {
|
|
486
|
+
_seconds: Math.floor(futureMs / 1000),
|
|
487
|
+
toMillis: () => futureMs
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const result = await checkPaymentAccess({
|
|
492
|
+
providerId: 'doc-1',
|
|
493
|
+
patientId: 'patient-1'
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
expect(result.hasAccess).toBe(true);
|
|
497
|
+
expect(result.type).toBe('subscription');
|
|
498
|
+
|
|
499
|
+
admin.firestore.Timestamp.now = originalNow;
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should deny access when no subscription or payment exists', async () => {
|
|
503
|
+
const result = await checkPaymentAccess({
|
|
504
|
+
providerId: 'doc-1',
|
|
505
|
+
patientId: 'patient-1'
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
expect(result.hasAccess).toBe(false);
|
|
509
|
+
expect(result.type).toBeNull();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('should grant access for per-chat payment', async () => {
|
|
513
|
+
admin.__seed('transactions', 'tx-1', {
|
|
514
|
+
providerId: 'doc-1',
|
|
515
|
+
patientId: 'patient-1',
|
|
516
|
+
conversationId: 'conv-1',
|
|
517
|
+
status: 'succeeded'
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const result = await checkPaymentAccess({
|
|
521
|
+
providerId: 'doc-1',
|
|
522
|
+
patientId: 'patient-1',
|
|
523
|
+
conversationId: 'conv-1'
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
expect(result.hasAccess).toBe(true);
|
|
527
|
+
expect(result.type).toBe('per-chat');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('should deny access for failed per-chat payment', async () => {
|
|
531
|
+
admin.__seed('transactions', 'tx-1', {
|
|
532
|
+
providerId: 'doc-1',
|
|
533
|
+
patientId: 'patient-1',
|
|
534
|
+
conversationId: 'conv-1',
|
|
535
|
+
status: 'failed'
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const result = await checkPaymentAccess({
|
|
539
|
+
providerId: 'doc-1',
|
|
540
|
+
patientId: 'patient-1',
|
|
541
|
+
conversationId: 'conv-1'
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
expect(result.hasAccess).toBe(false);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// ============================================
|
|
549
|
+
// REVENUE ANALYTICS
|
|
550
|
+
// ============================================
|
|
551
|
+
|
|
552
|
+
describe('getRevenueAnalytics', () => {
|
|
553
|
+
it('should return revenue summary', async () => {
|
|
554
|
+
const now = new Date();
|
|
555
|
+
admin.__seed('transactions', 'tx-1', {
|
|
556
|
+
providerId: 'doc-1',
|
|
557
|
+
status: 'succeeded',
|
|
558
|
+
amount: 2000,
|
|
559
|
+
createdAt: { toDate: () => now }
|
|
560
|
+
});
|
|
561
|
+
admin.__seed('transactions', 'tx-2', {
|
|
562
|
+
providerId: 'doc-1',
|
|
563
|
+
status: 'succeeded',
|
|
564
|
+
amount: 3000,
|
|
565
|
+
createdAt: { toDate: () => now }
|
|
566
|
+
});
|
|
567
|
+
admin.__seed('subscriptions', 'sub-1', {
|
|
568
|
+
providerId: 'doc-1',
|
|
569
|
+
status: 'active'
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const result = await getRevenueAnalytics({ providerId: 'doc-1' });
|
|
573
|
+
|
|
574
|
+
expect(result.totalRevenue).toBe(50); // (2000 + 3000) / 100
|
|
575
|
+
expect(result.transactionCount).toBe(2);
|
|
576
|
+
expect(result.activeSubscriptions).toBe(1);
|
|
577
|
+
expect(result.currency).toBe('USD');
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('should return zeros when no transactions exist', async () => {
|
|
581
|
+
const result = await getRevenueAnalytics({ providerId: 'doc-empty' });
|
|
582
|
+
|
|
583
|
+
expect(result.totalRevenue).toBe(0);
|
|
584
|
+
expect(result.transactionCount).toBe(0);
|
|
585
|
+
expect(result.activeSubscriptions).toBe(0);
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// ============================================
|
|
590
|
+
// WEBHOOK HANDLING
|
|
591
|
+
// ============================================
|
|
592
|
+
|
|
593
|
+
describe('handleStripeWebhook', () => {
|
|
594
|
+
describe('account.updated', () => {
|
|
595
|
+
it('should update provider onboarding status to COMPLETE', async () => {
|
|
596
|
+
admin.__seed('providers', 'doc-1', {
|
|
597
|
+
stripeAccountId: 'acct_123',
|
|
598
|
+
stripeOnboardingStatus: 'PENDING'
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
await handleStripeWebhook({
|
|
602
|
+
type: 'account.updated',
|
|
603
|
+
data: {
|
|
604
|
+
object: {
|
|
605
|
+
id: 'acct_123',
|
|
606
|
+
charges_enabled: true,
|
|
607
|
+
payouts_enabled: true,
|
|
608
|
+
metadata: { providerId: 'doc-1' }
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
const provider = admin.__getAll('providers')['doc-1'];
|
|
614
|
+
expect(provider.stripeOnboardingStatus).toBe('COMPLETE');
|
|
615
|
+
expect(provider.chargesEnabled).toBe(true);
|
|
616
|
+
expect(provider.payoutsEnabled).toBe(true);
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
describe('customer.subscription.created', () => {
|
|
621
|
+
it('should create subscription and access records', async () => {
|
|
622
|
+
await handleStripeWebhook({
|
|
623
|
+
type: 'customer.subscription.created',
|
|
624
|
+
data: {
|
|
625
|
+
object: {
|
|
626
|
+
id: 'sub_123',
|
|
627
|
+
customer: 'cus_123',
|
|
628
|
+
status: 'active',
|
|
629
|
+
items: { data: [{ price: { id: 'price_123' } }] },
|
|
630
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
631
|
+
current_period_end: Math.floor(Date.now() / 1000) + 30 * 86400,
|
|
632
|
+
cancel_at_period_end: false,
|
|
633
|
+
metadata: { providerId: 'doc-1', patientId: 'patient-1' }
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// Check subscription was created with stripeSubscriptionId as doc ID
|
|
639
|
+
const subs = admin.__getAll('subscriptions');
|
|
640
|
+
expect(subs['sub_123']).toBeDefined();
|
|
641
|
+
expect(subs['sub_123'].stripeSubscriptionId).toBe('sub_123');
|
|
642
|
+
expect(subs['sub_123'].status).toBe('active');
|
|
643
|
+
|
|
644
|
+
// Check access was created
|
|
645
|
+
const access = admin.__getAll('access');
|
|
646
|
+
expect(access['doc-1_patient-1']).toBeDefined();
|
|
647
|
+
expect(access['doc-1_patient-1'].status).toBe('ACTIVE');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('should be idempotent on webhook retries', async () => {
|
|
651
|
+
const webhookEvent = {
|
|
652
|
+
type: 'customer.subscription.created',
|
|
653
|
+
data: {
|
|
654
|
+
object: {
|
|
655
|
+
id: 'sub_retry',
|
|
656
|
+
customer: 'cus_123',
|
|
657
|
+
status: 'active',
|
|
658
|
+
items: { data: [{ price: { id: 'price_123' } }] },
|
|
659
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
660
|
+
current_period_end: Math.floor(Date.now() / 1000) + 30 * 86400,
|
|
661
|
+
cancel_at_period_end: false,
|
|
662
|
+
metadata: { providerId: 'doc-1', patientId: 'patient-1' }
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// Process same webhook twice (simulating Stripe retry)
|
|
668
|
+
await handleStripeWebhook(webhookEvent);
|
|
669
|
+
await handleStripeWebhook(webhookEvent);
|
|
670
|
+
|
|
671
|
+
// Should still only have one subscription record
|
|
672
|
+
const subs = admin.__getAll('subscriptions');
|
|
673
|
+
const subIds = Object.keys(subs);
|
|
674
|
+
expect(subIds.length).toBe(1);
|
|
675
|
+
expect(subs['sub_retry']).toBeDefined();
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('should skip when metadata is missing', async () => {
|
|
679
|
+
await handleStripeWebhook({
|
|
680
|
+
type: 'customer.subscription.created',
|
|
681
|
+
data: {
|
|
682
|
+
object: {
|
|
683
|
+
id: 'sub_no_meta',
|
|
684
|
+
customer: 'cus_123',
|
|
685
|
+
status: 'active',
|
|
686
|
+
items: { data: [{ price: { id: 'price_123' } }] },
|
|
687
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
688
|
+
current_period_end: Math.floor(Date.now() / 1000) + 30 * 86400,
|
|
689
|
+
cancel_at_period_end: false,
|
|
690
|
+
metadata: {}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
const subs = admin.__getAll('subscriptions');
|
|
696
|
+
expect(Object.keys(subs).length).toBe(0);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('should skip when subscription items are missing', async () => {
|
|
700
|
+
await handleStripeWebhook({
|
|
701
|
+
type: 'customer.subscription.created',
|
|
702
|
+
data: {
|
|
703
|
+
object: {
|
|
704
|
+
id: 'sub_no_items',
|
|
705
|
+
customer: 'cus_123',
|
|
706
|
+
status: 'active',
|
|
707
|
+
items: { data: [] },
|
|
708
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
709
|
+
current_period_end: Math.floor(Date.now() / 1000) + 30 * 86400,
|
|
710
|
+
cancel_at_period_end: false,
|
|
711
|
+
metadata: { providerId: 'doc-1', patientId: 'patient-1' }
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
const subs = admin.__getAll('subscriptions');
|
|
717
|
+
expect(Object.keys(subs).length).toBe(0);
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
describe('customer.subscription.deleted', () => {
|
|
722
|
+
it('should cancel subscription and revoke access', async () => {
|
|
723
|
+
admin.__seed('subscriptions', 'sub-doc-1', {
|
|
724
|
+
stripeSubscriptionId: 'sub_123',
|
|
725
|
+
status: 'active'
|
|
726
|
+
});
|
|
727
|
+
admin.__seed('access', 'doc-1_patient-1', {
|
|
728
|
+
status: 'ACTIVE',
|
|
729
|
+
stripeSubscriptionId: 'sub_123'
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
await handleStripeWebhook({
|
|
733
|
+
type: 'customer.subscription.deleted',
|
|
734
|
+
data: {
|
|
735
|
+
object: {
|
|
736
|
+
id: 'sub_123',
|
|
737
|
+
metadata: { providerId: 'doc-1', patientId: 'patient-1' }
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
const sub = admin.__getAll('subscriptions')['sub-doc-1'];
|
|
743
|
+
expect(sub.status).toBe('canceled');
|
|
744
|
+
|
|
745
|
+
const access = admin.__getAll('access')['doc-1_patient-1'];
|
|
746
|
+
expect(access.status).toBe('INACTIVE');
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it('should not crash when access doc does not exist', async () => {
|
|
750
|
+
admin.__seed('subscriptions', 'sub-doc-2', {
|
|
751
|
+
stripeSubscriptionId: 'sub_456',
|
|
752
|
+
status: 'active'
|
|
753
|
+
});
|
|
754
|
+
// Intentionally NOT seeding access doc
|
|
755
|
+
|
|
756
|
+
await handleStripeWebhook({
|
|
757
|
+
type: 'customer.subscription.deleted',
|
|
758
|
+
data: {
|
|
759
|
+
object: {
|
|
760
|
+
id: 'sub_456',
|
|
761
|
+
metadata: { providerId: 'doc-2', patientId: 'patient-2' }
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Access doc should be created with INACTIVE status
|
|
767
|
+
const access = admin.__getAll('access')['doc-2_patient-2'];
|
|
768
|
+
expect(access).toBeDefined();
|
|
769
|
+
expect(access.status).toBe('INACTIVE');
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
describe('payment_intent.succeeded', () => {
|
|
774
|
+
it('should update transaction status to succeeded', async () => {
|
|
775
|
+
admin.__seed('transactions', 'tx-1', {
|
|
776
|
+
stripePaymentIntentId: 'pi_123',
|
|
777
|
+
status: 'pending'
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
await handleStripeWebhook({
|
|
781
|
+
type: 'payment_intent.succeeded',
|
|
782
|
+
data: { object: { id: 'pi_123' } }
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
const tx = admin.__getAll('transactions')['tx-1'];
|
|
786
|
+
expect(tx.status).toBe('succeeded');
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
describe('payment_intent.payment_failed', () => {
|
|
791
|
+
it('should update transaction status to failed', async () => {
|
|
792
|
+
admin.__seed('transactions', 'tx-1', {
|
|
793
|
+
stripePaymentIntentId: 'pi_123',
|
|
794
|
+
status: 'pending'
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
await handleStripeWebhook({
|
|
798
|
+
type: 'payment_intent.payment_failed',
|
|
799
|
+
data: { object: { id: 'pi_123' } }
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const tx = admin.__getAll('transactions')['tx-1'];
|
|
803
|
+
expect(tx.status).toBe('failed');
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
describe('invoice.payment_failed', () => {
|
|
808
|
+
it('should revoke access on failed invoice', async () => {
|
|
809
|
+
admin.__seed('subscriptions', 'sub-doc-1', {
|
|
810
|
+
stripeSubscriptionId: 'sub_123',
|
|
811
|
+
providerId: 'doc-1',
|
|
812
|
+
patientId: 'patient-1',
|
|
813
|
+
currentPeriodEnd: { _seconds: Math.floor(Date.now() / 1000) + 86400 }
|
|
814
|
+
});
|
|
815
|
+
admin.__seed('access', 'doc-1_patient-1', {
|
|
816
|
+
status: 'ACTIVE',
|
|
817
|
+
providerId: 'doc-1',
|
|
818
|
+
patientId: 'patient-1'
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
await handleStripeWebhook({
|
|
822
|
+
type: 'invoice.payment_failed',
|
|
823
|
+
data: {
|
|
824
|
+
object: {
|
|
825
|
+
subscription: 'sub_123'
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
const access = admin.__getAll('access')['doc-1_patient-1'];
|
|
831
|
+
expect(access.status).toBe('INACTIVE');
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it('should not crash when access doc does not exist', async () => {
|
|
835
|
+
admin.__seed('subscriptions', 'sub-doc-1', {
|
|
836
|
+
stripeSubscriptionId: 'sub_789',
|
|
837
|
+
providerId: 'doc-3',
|
|
838
|
+
patientId: 'patient-3'
|
|
839
|
+
});
|
|
840
|
+
// Intentionally NOT seeding access doc
|
|
841
|
+
|
|
842
|
+
await handleStripeWebhook({
|
|
843
|
+
type: 'invoice.payment_failed',
|
|
844
|
+
data: {
|
|
845
|
+
object: {
|
|
846
|
+
subscription: 'sub_789'
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
// Access doc should be created with INACTIVE status
|
|
852
|
+
const access = admin.__getAll('access')['doc-3_patient-3'];
|
|
853
|
+
expect(access).toBeDefined();
|
|
854
|
+
expect(access.status).toBe('INACTIVE');
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
describe('invoice.payment_succeeded', () => {
|
|
859
|
+
it('should ensure access is ACTIVE on successful payment', async () => {
|
|
860
|
+
admin.__seed('subscriptions', 'sub-doc-1', {
|
|
861
|
+
stripeSubscriptionId: 'sub_123',
|
|
862
|
+
providerId: 'doc-1',
|
|
863
|
+
patientId: 'patient-1',
|
|
864
|
+
currentPeriodEnd: { _seconds: Math.floor(Date.now() / 1000) + 86400 }
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
await handleStripeWebhook({
|
|
868
|
+
type: 'invoice.payment_succeeded',
|
|
869
|
+
data: {
|
|
870
|
+
object: {
|
|
871
|
+
subscription: 'sub_123'
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
const access = admin.__getAll('access')['doc-1_patient-1'];
|
|
877
|
+
expect(access.status).toBe('ACTIVE');
|
|
878
|
+
});
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it('should handle unrecognized event types gracefully', async () => {
|
|
882
|
+
// Should not throw
|
|
883
|
+
await handleStripeWebhook({
|
|
884
|
+
type: 'some.unknown.event',
|
|
885
|
+
data: { object: {} }
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
});
|