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,830 @@
1
+ // Payment processing functions with Stripe Connect
2
+ const admin = require('firebase-admin');
3
+ const { stripe } = require('./stripe-config');
4
+
5
+ /**
6
+ * Firestore collections for Stripe Connect payment system:
7
+ *
8
+ * 1. providers collection - add Stripe Connect fields:
9
+ * - pricingModel: 'free' | 'subscription' | 'per-chat' | 'hybrid'
10
+ * - subscriptionPrice: number (monthly price in USD)
11
+ * - perChatPrice: number (price per conversation in USD)
12
+ * - paymentEnabled: boolean
13
+ * - currency: string (default 'usd')
14
+ * - stripeAccountId: string (Stripe Connect account)
15
+ * - stripeOnboardingStatus: 'NOT_STARTED' | 'PENDING' | 'COMPLETE'
16
+ * - chargesEnabled: boolean
17
+ * - payoutsEnabled: boolean
18
+ * - stripeProductId: string
19
+ * - stripePriceIds: { day?: string, week?: string, month?: string }
20
+ *
21
+ * 2. patients collection (root):
22
+ * - stripeCustomerId: string
23
+ * - createdAt: timestamp
24
+ * - updatedAt: timestamp
25
+ *
26
+ * 3. transactions collection (root):
27
+ * - providerId: string
28
+ * - patientId: string (session ID)
29
+ * - conversationId: string (optional)
30
+ * - amount: number (in cents)
31
+ * - currency: string
32
+ * - status: 'pending' | 'succeeded' | 'failed' | 'refunded'
33
+ * - paymentMethod: string
34
+ * - stripePaymentIntentId: string
35
+ * - metadata: object
36
+ * - createdAt: timestamp
37
+ * - updatedAt: timestamp
38
+ *
39
+ * 4. subscriptions collection (root):
40
+ * - providerId: string
41
+ * - patientId: string (session ID)
42
+ * - status: 'active' | 'canceled' | 'past_due' | 'incomplete'
43
+ * - stripeSubscriptionId: string
44
+ * - stripeCustomerId: string
45
+ * - stripePriceId: string
46
+ * - currentPeriodStart: timestamp
47
+ * - currentPeriodEnd: timestamp
48
+ * - cancelAtPeriodEnd: boolean
49
+ * - createdAt: timestamp
50
+ * - updatedAt: timestamp
51
+ *
52
+ * 5. access collection (root) - composite key: {providerId}_{patientId}
53
+ * - providerId: string
54
+ * - patientId: string
55
+ * - status: 'ACTIVE' | 'INACTIVE'
56
+ * - stripeSubscriptionId: string
57
+ * - currentPeriodEnd: timestamp
58
+ * - updatedAt: timestamp
59
+ */
60
+
61
+ // ============================================
62
+ // STRIPE CONNECT FUNCTIONS
63
+ // ============================================
64
+
65
+ /**
66
+ * Create a Stripe Connect Express account for a provider
67
+ */
68
+ async function createConnectAccount({ providerId, email, displayName, country = 'US' }) {
69
+ try {
70
+ const account = await stripe.accounts.create({
71
+ type: 'express',
72
+ country,
73
+ email,
74
+ capabilities: {
75
+ card_payments: { requested: true },
76
+ transfers: { requested: true },
77
+ },
78
+ business_type: 'individual',
79
+ metadata: {
80
+ providerId,
81
+ displayName: displayName || '',
82
+ platform: 'evaldoc'
83
+ }
84
+ });
85
+
86
+ const db = admin.firestore();
87
+ await db.collection('providers').doc(providerId).update({
88
+ stripeAccountId: account.id,
89
+ stripeOnboardingStatus: 'PENDING',
90
+ chargesEnabled: account.charges_enabled,
91
+ payoutsEnabled: account.payouts_enabled,
92
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
93
+ });
94
+
95
+ return {
96
+ accountId: account.id,
97
+ onboardingStatus: 'PENDING'
98
+ };
99
+ } catch (error) {
100
+ console.error('Error creating Connect account:', error);
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Create an onboarding link for Connect account
107
+ */
108
+ async function createAccountLink({ stripeAccountId, providerId }) {
109
+ try {
110
+ const origin = process.env.PLATFORM_URL || 'http://localhost:8080';
111
+
112
+ const accountLink = await stripe.accountLinks.create({
113
+ account: stripeAccountId,
114
+ refresh_url: `${origin}/app?refresh=true`,
115
+ return_url: `${origin}/app?onboarding=complete`,
116
+ type: 'account_onboarding',
117
+ });
118
+
119
+ return {
120
+ url: accountLink.url,
121
+ expiresAt: accountLink.expires_at
122
+ };
123
+ } catch (error) {
124
+ console.error('Error creating account link:', error);
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Check and update Connect account status
131
+ */
132
+ async function refreshAccountStatus({ providerId, stripeAccountId }) {
133
+ try {
134
+ const account = await stripe.accounts.retrieve(stripeAccountId);
135
+
136
+ const db = admin.firestore();
137
+ const updates = {
138
+ chargesEnabled: account.charges_enabled,
139
+ payoutsEnabled: account.payouts_enabled,
140
+ stripeOnboardingStatus: (account.charges_enabled && account.payouts_enabled) ? 'COMPLETE' : 'PENDING',
141
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
142
+ };
143
+
144
+ await db.collection('providers').doc(providerId).update(updates);
145
+
146
+ return {
147
+ chargesEnabled: account.charges_enabled,
148
+ payoutsEnabled: account.payouts_enabled,
149
+ onboardingComplete: account.charges_enabled && account.payouts_enabled
150
+ };
151
+ } catch (error) {
152
+ console.error('Error refreshing account status:', error);
153
+ throw error;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Set provider pricing and create Stripe Products/Prices
159
+ */
160
+ async function setProviderPricing({
161
+ providerId,
162
+ stripeAccountId,
163
+ currency = 'usd',
164
+ prices = { month: null, week: null, day: null },
165
+ displayName
166
+ }) {
167
+ try {
168
+ const db = admin.firestore();
169
+ const providerRef = db.collection('providers').doc(providerId);
170
+ const provider = (await providerRef.get()).data();
171
+
172
+ // Create or retrieve product
173
+ let productId = provider.stripeProductId;
174
+
175
+ if (!productId) {
176
+ const product = await stripe.products.create({
177
+ name: `EvalDoc AI Access - ${displayName || 'Provider'}`,
178
+ description: 'Subscription for AI-powered health consultations',
179
+ }, {
180
+ stripeAccount: stripeAccountId
181
+ });
182
+ productId = product.id;
183
+ }
184
+
185
+ // Create price objects for each interval
186
+ const priceIds = {};
187
+
188
+ for (const [interval, amount] of Object.entries(prices)) {
189
+ if (amount && amount > 0) {
190
+ const price = await stripe.prices.create({
191
+ product: productId,
192
+ unit_amount: Math.round(amount * 100), // Convert to cents
193
+ currency,
194
+ recurring: {
195
+ interval: interval === 'month' ? 'month' : interval === 'week' ? 'week' : 'day'
196
+ },
197
+ }, {
198
+ stripeAccount: stripeAccountId
199
+ });
200
+
201
+ priceIds[interval] = price.id;
202
+ }
203
+ }
204
+
205
+ // Update provider document
206
+ await providerRef.update({
207
+ stripeProductId: productId,
208
+ stripePriceIds: priceIds,
209
+ currency,
210
+ subscriptionPrice: prices.month || 0,
211
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
212
+ });
213
+
214
+ return { productId, priceIds };
215
+ } catch (error) {
216
+ console.error('Error setting provider pricing:', error);
217
+ throw error;
218
+ }
219
+ }
220
+
221
+ // ============================================
222
+ // CHECKOUT & SUBSCRIPTION FUNCTIONS
223
+ // ============================================
224
+
225
+ /**
226
+ * Create Stripe Checkout Session for subscription with Connect
227
+ * Platform takes 25% fee, provider gets 75%
228
+ */
229
+ async function createCheckoutSession({
230
+ providerId,
231
+ patientId,
232
+ interval = 'month',
233
+ stripeAccountId,
234
+ stripePriceId
235
+ }) {
236
+ try {
237
+ const db = admin.firestore();
238
+
239
+ // Get or create customer
240
+ let patientDoc = await db.collection('patients').doc(patientId).get();
241
+ let customerId;
242
+
243
+ if (!patientDoc.exists || !patientDoc.data().stripeCustomerId) {
244
+ // Create Stripe customer
245
+ const customer = await stripe.customers.create({
246
+ metadata: {
247
+ patientId,
248
+ platform: 'evaldoc'
249
+ }
250
+ });
251
+ customerId = customer.id;
252
+
253
+ await db.collection('patients').doc(patientId).set({
254
+ stripeCustomerId: customerId,
255
+ createdAt: admin.firestore.FieldValue.serverTimestamp(),
256
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
257
+ }, { merge: true });
258
+ } else {
259
+ customerId = patientDoc.data().stripeCustomerId;
260
+ }
261
+
262
+ const origin = process.env.PLATFORM_URL || 'http://localhost:8080';
263
+
264
+ // Look up the provider handle for redirect URLs
265
+ const providerSnap = await db.collection('providers').doc(providerId).get();
266
+ const handle = providerSnap.exists ? providerSnap.data().handle : '';
267
+
268
+ // Create checkout session with Connect
269
+ // Using application_fee_percent to take 25% platform fee
270
+ const session = await stripe.checkout.sessions.create({
271
+ mode: 'subscription',
272
+ customer: customerId,
273
+ line_items: [{
274
+ price: stripePriceId,
275
+ quantity: 1,
276
+ }],
277
+ success_url: `${origin}/${handle}?payment=success&session_id={CHECKOUT_SESSION_ID}`,
278
+ cancel_url: `${origin}/${handle}?payment=cancelled`,
279
+ metadata: {
280
+ providerId,
281
+ patientId,
282
+ interval
283
+ },
284
+ subscription_data: {
285
+ application_fee_percent: 25, // Platform takes 25%
286
+ metadata: {
287
+ providerId,
288
+ patientId
289
+ }
290
+ }
291
+ }, {
292
+ stripeAccount: stripeAccountId // Connect account
293
+ });
294
+
295
+ return {
296
+ sessionId: session.id,
297
+ url: session.url
298
+ };
299
+ } catch (error) {
300
+ console.error('Error creating checkout session:', error);
301
+ throw error;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Create a payment intent for per-chat payment (with Connect)
307
+ */
308
+ async function createPaymentIntent({ providerId, patientId, amount, currency = 'usd', metadata = {}, stripeAccountId }) {
309
+ try {
310
+ const amountInCents = Math.round(amount * 100);
311
+ const applicationFee = Math.round(amountInCents * 0.25); // 25% platform fee
312
+
313
+ const paymentIntentParams = {
314
+ amount: amountInCents,
315
+ currency,
316
+ metadata: {
317
+ providerId,
318
+ patientId,
319
+ ...metadata
320
+ },
321
+ automatic_payment_methods: {
322
+ enabled: true,
323
+ },
324
+ };
325
+
326
+ // Route payment through Connect account with 25% platform fee
327
+ const createOptions = {};
328
+ if (stripeAccountId) {
329
+ paymentIntentParams.application_fee_amount = applicationFee;
330
+ createOptions.stripeAccount = stripeAccountId;
331
+ }
332
+
333
+ const paymentIntent = await stripe.paymentIntents.create(
334
+ paymentIntentParams,
335
+ createOptions
336
+ );
337
+
338
+ // Save transaction record
339
+ const db = admin.firestore();
340
+ await db.collection('transactions').add({
341
+ providerId,
342
+ patientId,
343
+ conversationId: metadata.conversationId || null,
344
+ amount: Math.round(amount * 100),
345
+ currency,
346
+ status: 'pending',
347
+ paymentMethod: 'card',
348
+ stripePaymentIntentId: paymentIntent.id,
349
+ metadata,
350
+ createdAt: admin.firestore.FieldValue.serverTimestamp(),
351
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
352
+ });
353
+
354
+ return {
355
+ clientSecret: paymentIntent.client_secret,
356
+ paymentIntentId: paymentIntent.id
357
+ };
358
+ } catch (error) {
359
+ console.error('Error creating payment intent:', error);
360
+ throw error;
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Create a subscription for recurring payments
366
+ */
367
+ async function createSubscription({ providerId, patientId, priceAmount, currency = 'usd' }) {
368
+ try {
369
+ // Create or retrieve customer
370
+ const db = admin.firestore();
371
+ const existingSubSnapshot = await db
372
+ .collection('subscriptions')
373
+ .where('providerId', '==', providerId)
374
+ .where('patientId', '==', patientId)
375
+ .where('status', 'in', ['active', 'past_due'])
376
+ .limit(1)
377
+ .get();
378
+
379
+ if (!existingSubSnapshot.empty) {
380
+ throw new Error('Active subscription already exists');
381
+ }
382
+
383
+ // Create Stripe customer
384
+ const customer = await stripe.customers.create({
385
+ metadata: {
386
+ providerId,
387
+ patientId
388
+ }
389
+ });
390
+
391
+ // Create price object
392
+ const price = await stripe.prices.create({
393
+ unit_amount: Math.round(priceAmount * 100),
394
+ currency,
395
+ recurring: { interval: 'month' },
396
+ product_data: {
397
+ name: `Subscription to Provider ${providerId}`,
398
+ },
399
+ });
400
+
401
+ // Create subscription
402
+ const subscription = await stripe.subscriptions.create({
403
+ customer: customer.id,
404
+ items: [{ price: price.id }],
405
+ payment_behavior: 'default_incomplete',
406
+ payment_settings: { save_default_payment_method: 'on_subscription' },
407
+ expand: ['latest_invoice.payment_intent'],
408
+ metadata: {
409
+ providerId,
410
+ patientId
411
+ }
412
+ });
413
+
414
+ // Save subscription record
415
+ await db.collection('subscriptions').add({
416
+ providerId,
417
+ patientId,
418
+ status: subscription.status,
419
+ stripeSubscriptionId: subscription.id,
420
+ stripeCustomerId: customer.id,
421
+ stripePriceId: price.id,
422
+ currentPeriodStart: admin.firestore.Timestamp.fromDate(
423
+ new Date(subscription.current_period_start * 1000)
424
+ ),
425
+ currentPeriodEnd: admin.firestore.Timestamp.fromDate(
426
+ new Date(subscription.current_period_end * 1000)
427
+ ),
428
+ cancelAtPeriodEnd: false,
429
+ createdAt: admin.firestore.FieldValue.serverTimestamp(),
430
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
431
+ });
432
+
433
+ return {
434
+ subscriptionId: subscription.id,
435
+ clientSecret: subscription.latest_invoice.payment_intent.client_secret
436
+ };
437
+ } catch (error) {
438
+ console.error('Error creating subscription:', error);
439
+ throw error;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Cancel a subscription
445
+ */
446
+ async function cancelSubscription({ subscriptionId, cancelAtPeriodEnd = true }) {
447
+ try {
448
+ const subscription = await stripe.subscriptions.update(subscriptionId, {
449
+ cancel_at_period_end: cancelAtPeriodEnd
450
+ });
451
+
452
+ // Update Firestore record
453
+ const db = admin.firestore();
454
+ const snapshot = await db
455
+ .collection('subscriptions')
456
+ .where('stripeSubscriptionId', '==', subscriptionId)
457
+ .limit(1)
458
+ .get();
459
+
460
+ if (!snapshot.empty) {
461
+ await snapshot.docs[0].ref.update({
462
+ cancelAtPeriodEnd,
463
+ status: subscription.status,
464
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
465
+ });
466
+ }
467
+
468
+ return subscription;
469
+ } catch (error) {
470
+ console.error('Error canceling subscription:', error);
471
+ throw error;
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Check if patient has active subscription or has paid for conversation
477
+ */
478
+ async function checkPaymentAccess({ providerId, patientId, conversationId }) {
479
+ const db = admin.firestore();
480
+
481
+ // Fast O(1) lookup via the access collection (maintained by webhooks)
482
+ const accessId = `${providerId}_${patientId}`;
483
+ const accessDoc = await db.collection('access').doc(accessId).get();
484
+
485
+ if (accessDoc.exists) {
486
+ const access = accessDoc.data();
487
+ if (access.status === 'ACTIVE') {
488
+ return { hasAccess: true, type: 'subscription' };
489
+ }
490
+ }
491
+
492
+ // Fallback: check subscriptions collection directly (for non-Connect subscriptions)
493
+ const subSnapshot = await db
494
+ .collection('subscriptions')
495
+ .where('providerId', '==', providerId)
496
+ .where('patientId', '==', patientId)
497
+ .where('status', 'in', ['active', 'past_due'])
498
+ .limit(1)
499
+ .get();
500
+
501
+ if (!subSnapshot.empty) {
502
+ const sub = subSnapshot.docs[0].data();
503
+ const now = admin.firestore.Timestamp.now();
504
+ if (sub.currentPeriodEnd && sub.currentPeriodEnd.toMillis() > now.toMillis()) {
505
+ return { hasAccess: true, type: 'subscription' };
506
+ }
507
+ }
508
+
509
+ // Check for per-chat payment
510
+ if (conversationId) {
511
+ const txSnapshot = await db
512
+ .collection('transactions')
513
+ .where('providerId', '==', providerId)
514
+ .where('patientId', '==', patientId)
515
+ .where('conversationId', '==', conversationId)
516
+ .where('status', '==', 'succeeded')
517
+ .limit(1)
518
+ .get();
519
+
520
+ if (!txSnapshot.empty) {
521
+ return { hasAccess: true, type: 'per-chat' };
522
+ }
523
+ }
524
+
525
+ return { hasAccess: false, type: null };
526
+ }
527
+
528
+ /**
529
+ * Get revenue analytics for provider
530
+ */
531
+ async function getRevenueAnalytics({ providerId, startDate, endDate }) {
532
+ const db = admin.firestore();
533
+
534
+ let query = db
535
+ .collection('transactions')
536
+ .where('providerId', '==', providerId)
537
+ .where('status', '==', 'succeeded');
538
+
539
+ if (startDate) {
540
+ query = query.where('createdAt', '>=', startDate);
541
+ }
542
+ if (endDate) {
543
+ query = query.where('createdAt', '<=', endDate);
544
+ }
545
+
546
+ const snapshot = await query.get();
547
+
548
+ let totalRevenue = 0;
549
+ let transactionCount = 0;
550
+ const revenueByDay = {};
551
+
552
+ snapshot.forEach(doc => {
553
+ const data = doc.data();
554
+ totalRevenue += data.amount;
555
+ transactionCount++;
556
+
557
+ // Group by day
558
+ const date = data.createdAt.toDate().toISOString().split('T')[0];
559
+ if (!revenueByDay[date]) {
560
+ revenueByDay[date] = { revenue: 0, count: 0 };
561
+ }
562
+ revenueByDay[date].revenue += data.amount;
563
+ revenueByDay[date].count++;
564
+ });
565
+
566
+ // Get active subscriptions
567
+ const subSnapshot = await db
568
+ .collection('subscriptions')
569
+ .where('providerId', '==', providerId)
570
+ .where('status', '==', 'active')
571
+ .get();
572
+
573
+ return {
574
+ totalRevenue: totalRevenue / 100, // Convert from cents to dollars
575
+ transactionCount,
576
+ revenueByDay,
577
+ activeSubscriptions: subSnapshot.size,
578
+ currency: 'USD'
579
+ };
580
+ }
581
+
582
+ /**
583
+ * Handle Stripe webhook events (including Connect events)
584
+ */
585
+ async function handleStripeWebhook(event) {
586
+ const db = admin.firestore();
587
+
588
+ try {
589
+ switch (event.type) {
590
+ // Connect account events
591
+ case 'account.updated': {
592
+ const account = event.data.object;
593
+ const providerId = account.metadata?.providerId;
594
+
595
+ if (providerId) {
596
+ await db.collection('providers').doc(providerId).update({
597
+ chargesEnabled: account.charges_enabled,
598
+ payoutsEnabled: account.payouts_enabled,
599
+ stripeOnboardingStatus: (account.charges_enabled && account.payouts_enabled) ? 'COMPLETE' : 'PENDING',
600
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
601
+ });
602
+ }
603
+ break;
604
+ }
605
+
606
+ // Checkout session completed
607
+ case 'checkout.session.completed': {
608
+ const session = event.data.object;
609
+ const { providerId, patientId } = session.metadata;
610
+
611
+ if (session.mode === 'subscription' && session.subscription) {
612
+ // Subscription will be handled by customer.subscription.created event
613
+ console.log('Checkout session completed for subscription:', session.subscription);
614
+ }
615
+ break;
616
+ }
617
+
618
+ // Subscription events
619
+ case 'customer.subscription.created':
620
+ case 'customer.subscription.updated': {
621
+ const subscription = event.data.object;
622
+ const { providerId, patientId } = subscription.metadata;
623
+
624
+ if (!providerId || !patientId) {
625
+ console.warn('Subscription missing metadata:', subscription.id);
626
+ break;
627
+ }
628
+
629
+ // Validate subscription has price items
630
+ const stripePriceId = subscription.items?.data?.[0]?.price?.id;
631
+ if (!stripePriceId) {
632
+ console.warn('Subscription missing price items:', subscription.id);
633
+ break;
634
+ }
635
+
636
+ const subData = {
637
+ providerId,
638
+ patientId,
639
+ stripeSubscriptionId: subscription.id,
640
+ stripeCustomerId: subscription.customer,
641
+ stripePriceId,
642
+ status: subscription.status,
643
+ currentPeriodStart: admin.firestore.Timestamp.fromDate(
644
+ new Date(subscription.current_period_start * 1000)
645
+ ),
646
+ currentPeriodEnd: admin.firestore.Timestamp.fromDate(
647
+ new Date(subscription.current_period_end * 1000)
648
+ ),
649
+ cancelAtPeriodEnd: subscription.cancel_at_period_end,
650
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
651
+ };
652
+
653
+ // Use stripeSubscriptionId as doc ID to prevent duplicates on webhook retries
654
+ const subDocRef = db.collection('subscriptions').doc(subscription.id);
655
+ const subDoc = await subDocRef.get();
656
+
657
+ if (!subDoc.exists) {
658
+ subData.createdAt = admin.firestore.FieldValue.serverTimestamp();
659
+ }
660
+ await subDocRef.set(subData, { merge: true });
661
+
662
+ // Update access control
663
+ const accessId = `${providerId}_${patientId}`;
664
+ const isActive = subscription.status === 'active';
665
+
666
+ await db.collection('access').doc(accessId).set({
667
+ providerId,
668
+ patientId,
669
+ status: isActive ? 'ACTIVE' : 'INACTIVE',
670
+ stripeSubscriptionId: subscription.id,
671
+ currentPeriodEnd: admin.firestore.Timestamp.fromDate(
672
+ new Date(subscription.current_period_end * 1000)
673
+ ),
674
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
675
+ }, { merge: true });
676
+
677
+ break;
678
+ }
679
+
680
+ case 'customer.subscription.deleted': {
681
+ const subscription = event.data.object;
682
+ const { providerId, patientId } = subscription.metadata;
683
+
684
+ if (!providerId || !patientId) break;
685
+
686
+ // Mark subscription as canceled
687
+ const subSnapshot = await db
688
+ .collection('subscriptions')
689
+ .where('stripeSubscriptionId', '==', subscription.id)
690
+ .limit(1)
691
+ .get();
692
+
693
+ if (!subSnapshot.empty) {
694
+ await subSnapshot.docs[0].ref.update({
695
+ status: 'canceled',
696
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
697
+ });
698
+ }
699
+
700
+ // Revoke access (use set+merge in case access doc doesn't exist yet)
701
+ const accessId = `${providerId}_${patientId}`;
702
+ await db.collection('access').doc(accessId).set({
703
+ providerId,
704
+ patientId,
705
+ status: 'INACTIVE',
706
+ stripeSubscriptionId: subscription.id,
707
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
708
+ }, { merge: true });
709
+
710
+ break;
711
+ }
712
+
713
+ // Invoice events
714
+ case 'invoice.payment_succeeded': {
715
+ const invoice = event.data.object;
716
+ const subscription = invoice.subscription;
717
+
718
+ if (subscription) {
719
+ // Payment successful - ensure access is active
720
+ const subDoc = await db
721
+ .collection('subscriptions')
722
+ .where('stripeSubscriptionId', '==', subscription)
723
+ .limit(1)
724
+ .get();
725
+
726
+ if (!subDoc.empty) {
727
+ const subData = subDoc.docs[0].data();
728
+ const accessId = `${subData.providerId}_${subData.patientId}`;
729
+
730
+ await db.collection('access').doc(accessId).set({
731
+ providerId: subData.providerId,
732
+ patientId: subData.patientId,
733
+ status: 'ACTIVE',
734
+ stripeSubscriptionId: subscription,
735
+ currentPeriodEnd: subData.currentPeriodEnd,
736
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
737
+ }, { merge: true });
738
+ }
739
+ }
740
+ break;
741
+ }
742
+
743
+ case 'invoice.payment_failed': {
744
+ const invoice = event.data.object;
745
+ const subscription = invoice.subscription;
746
+
747
+ if (subscription) {
748
+ // Payment failed - revoke access immediately
749
+ const subDoc = await db
750
+ .collection('subscriptions')
751
+ .where('stripeSubscriptionId', '==', subscription)
752
+ .limit(1)
753
+ .get();
754
+
755
+ if (!subDoc.empty) {
756
+ const subData = subDoc.docs[0].data();
757
+ const accessId = `${subData.providerId}_${subData.patientId}`;
758
+
759
+ await db.collection('access').doc(accessId).set({
760
+ providerId: subData.providerId,
761
+ patientId: subData.patientId,
762
+ status: 'INACTIVE',
763
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
764
+ }, { merge: true });
765
+ }
766
+ }
767
+ break;
768
+ }
769
+
770
+ // Payment intent events (for per-chat payments)
771
+ case 'payment_intent.succeeded': {
772
+ const paymentIntent = event.data.object;
773
+ const snapshot = await db
774
+ .collection('transactions')
775
+ .where('stripePaymentIntentId', '==', paymentIntent.id)
776
+ .limit(1)
777
+ .get();
778
+
779
+ if (!snapshot.empty) {
780
+ await snapshot.docs[0].ref.update({
781
+ status: 'succeeded',
782
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
783
+ });
784
+ }
785
+ break;
786
+ }
787
+
788
+ case 'payment_intent.payment_failed': {
789
+ const paymentIntent = event.data.object;
790
+ const snapshot = await db
791
+ .collection('transactions')
792
+ .where('stripePaymentIntentId', '==', paymentIntent.id)
793
+ .limit(1)
794
+ .get();
795
+
796
+ if (!snapshot.empty) {
797
+ await snapshot.docs[0].ref.update({
798
+ status: 'failed',
799
+ updatedAt: admin.firestore.FieldValue.serverTimestamp()
800
+ });
801
+ }
802
+ break;
803
+ }
804
+
805
+ default:
806
+ console.log(`Unhandled event type: ${event.type}`);
807
+ }
808
+ } catch (error) {
809
+ console.error('Error handling webhook:', error);
810
+ throw error;
811
+ }
812
+ }
813
+
814
+ module.exports = {
815
+ // Stripe Connect functions
816
+ createConnectAccount,
817
+ createAccountLink,
818
+ refreshAccountStatus,
819
+ setProviderPricing,
820
+ // Checkout & subscriptions
821
+ createCheckoutSession,
822
+ createPaymentIntent,
823
+ createSubscription,
824
+ cancelSubscription,
825
+ checkPaymentAccess,
826
+ // Analytics
827
+ getRevenueAnalytics,
828
+ // Webhooks
829
+ handleStripeWebhook
830
+ };