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,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
|
+
};
|