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.
Files changed (139) hide show
  1. package/.env.staging.example +19 -0
  2. package/.firebaserc +6 -0
  3. package/.github/workflows/auto-merge-to-main.yml +28 -0
  4. package/.github/workflows/ci.yml +27 -0
  5. package/.github/workflows/npxhub-publish.yml +170 -0
  6. package/.prettierignore +4 -0
  7. package/.prettierrc.json +7 -0
  8. package/README.md +142 -0
  9. package/SRS.md +448 -0
  10. package/apphosting.staging.yaml +48 -0
  11. package/apphosting.yaml +50 -0
  12. package/bin/evaldoc.cjs +105 -0
  13. package/cors.json +8 -0
  14. package/docs/ARCHITECTURE.md +769 -0
  15. package/docs/ARCHITECTURE_SUMMARY.md +323 -0
  16. package/docs/DEPLOYMENT.md +86 -0
  17. package/docs/DEPLOYMENT_STEPS.md +373 -0
  18. package/docs/DEPLOY_FIRESTORE_RULES.md +239 -0
  19. package/docs/DEPLOY_STORAGE_RULES.md +82 -0
  20. package/docs/DOCTOR_TUTORIAL.md +467 -0
  21. package/docs/FIREBASE_STORAGE_CORS_FIX.md +131 -0
  22. package/docs/FIXES_APPLIED.md +128 -0
  23. package/docs/IMPLEMENTATION.md +498 -0
  24. package/docs/IMPLEMENTATION_COMPLETE.md +317 -0
  25. package/docs/LLM-SETUP.md +216 -0
  26. package/docs/MANUAL_TEST_GUIDE.md +327 -0
  27. package/docs/PATIENT_GUIDE.md +451 -0
  28. package/docs/PLATFORM_OVERVIEW.md +194 -0
  29. package/docs/QUICKSTART.md +252 -0
  30. package/docs/QUICK_REFERENCE.md +222 -0
  31. package/docs/READY_TO_TEST.md +383 -0
  32. package/docs/RUN.md +237 -0
  33. package/docs/START_TESTING.md +130 -0
  34. package/docs/STRIPE_CONNECT_SETUP.md +454 -0
  35. package/docs/SYSTEM_READY.md +345 -0
  36. package/docs/THREADS_IMPLEMENTATION_COMPLETE.md +431 -0
  37. package/docs/THREAD_IMPLEMENTATION_GUIDE.md +345 -0
  38. package/docs/firebasefiles.md +21 -0
  39. package/docs/homepage.md +1208 -0
  40. package/docs/payments.md +159 -0
  41. package/eslint.config.js +76 -0
  42. package/firebase.json +27 -0
  43. package/firestore.indexes.json +88 -0
  44. package/firestore.rules +125 -0
  45. package/functions/package-lock.json +2884 -0
  46. package/functions/package.json +20 -0
  47. package/functions/src/analytics.js +101 -0
  48. package/functions/src/audit.js +28 -0
  49. package/functions/src/auth.js +19 -0
  50. package/functions/src/drafts.js +128 -0
  51. package/functions/src/feedback.js +164 -0
  52. package/functions/src/firebase-init.js +29 -0
  53. package/functions/src/i18n.js +134 -0
  54. package/functions/src/limits.js +27 -0
  55. package/functions/src/llm.js +271 -0
  56. package/functions/src/ner.js +162 -0
  57. package/functions/src/notifications.js +273 -0
  58. package/functions/src/orgs.js +127 -0
  59. package/functions/src/patient-api.js +545 -0
  60. package/functions/src/payment.js +830 -0
  61. package/functions/src/safety.js +188 -0
  62. package/functions/src/stripe-config.js +33 -0
  63. package/functions/src/topics.js +94 -0
  64. package/functions/src/triage.js +191 -0
  65. package/functions/src/utils.js +28 -0
  66. package/functions/src/validate.js +177 -0
  67. package/functions/test-firebase.js +36 -0
  68. package/jest.config.js +18 -0
  69. package/package.json +52 -0
  70. package/public/404.html +93 -0
  71. package/public/app/analytics.html +336 -0
  72. package/public/app/analytics.js +177 -0
  73. package/public/app/app.css +1400 -0
  74. package/public/app/app.js +1754 -0
  75. package/public/app/config.js +14 -0
  76. package/public/app/inbox-threads.js +843 -0
  77. package/public/app/inbox.html +598 -0
  78. package/public/app/inbox.js +397 -0
  79. package/public/app/index.html +612 -0
  80. package/public/assets/logo.jpg +0 -0
  81. package/public/chat.html +137 -0
  82. package/public/css/chat.css +870 -0
  83. package/public/css/landing-enhance.css +298 -0
  84. package/public/css/styles.css +1198 -0
  85. package/public/favicon.svg +5 -0
  86. package/public/find-doctor.html +220 -0
  87. package/public/index.html +399 -0
  88. package/public/js/chat-threads.js +513 -0
  89. package/public/js/chat.js +832 -0
  90. package/public/js/search.js +41 -0
  91. package/public/privacy.html +101 -0
  92. package/public/robots.txt +6 -0
  93. package/public/site.webmanifest +12 -0
  94. package/public/terms.html +110 -0
  95. package/scripts/create-test-provider.js +55 -0
  96. package/scripts/deploy-staging.sh +51 -0
  97. package/scripts/fix-provider.js +39 -0
  98. package/scripts/quick-test.sh +37 -0
  99. package/scripts/test-chat.sh +54 -0
  100. package/scripts/test-multi-turn.js +137 -0
  101. package/scripts/test-ner-simple.js +28 -0
  102. package/scripts/test-ner.js +60 -0
  103. package/scripts/test-stripe-connect.js +304 -0
  104. package/scripts/test-webhook-secret.sh +24 -0
  105. package/server.js +3864 -0
  106. package/storage.rules +22 -0
  107. package/test-api.json +1 -0
  108. package/tests/emulator/README.md +38 -0
  109. package/tests/emulator/counters.emulator.test.js +81 -0
  110. package/tests/integration/api-account-selfserve.test.js +312 -0
  111. package/tests/integration/api-admin-digest.test.js +202 -0
  112. package/tests/integration/api-chat-satisfaction.test.js +259 -0
  113. package/tests/integration/api-doctor-drafts.test.js +236 -0
  114. package/tests/integration/api-doctor-inbox.test.js +401 -0
  115. package/tests/integration/api-org.test.js +328 -0
  116. package/tests/integration/api-payment.test.js +664 -0
  117. package/tests/integration/api-provider-settings.test.js +475 -0
  118. package/tests/integration/api-qr.test.js +121 -0
  119. package/tests/integration/api-security.test.js +241 -0
  120. package/tests/integration/api-stripe-search.test.js +303 -0
  121. package/tests/integration/api-webhook-deletion.test.js +231 -0
  122. package/tests/mocks/firebase-admin.js +220 -0
  123. package/tests/unit/analytics.test.js +151 -0
  124. package/tests/unit/audit-bugs.test.js +203 -0
  125. package/tests/unit/chat-ui-logic.test.js +283 -0
  126. package/tests/unit/drafts.test.js +104 -0
  127. package/tests/unit/feedback.test.js +148 -0
  128. package/tests/unit/i18n.test.js +229 -0
  129. package/tests/unit/llm-retry.test.js +122 -0
  130. package/tests/unit/notifications.test.js +155 -0
  131. package/tests/unit/orgs.test.js +328 -0
  132. package/tests/unit/payment.test.js +889 -0
  133. package/tests/unit/safety-edge-cases.test.js +509 -0
  134. package/tests/unit/safety-srs-compliance.test.js +252 -0
  135. package/tests/unit/safety.test.js +291 -0
  136. package/tests/unit/server-helpers.test.js +774 -0
  137. package/tests/unit/topics-limits.test.js +158 -0
  138. package/tests/unit/triage.test.js +152 -0
  139. 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
+ });