@equilateral_ai/mindmeld 3.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 (86) hide show
  1. package/README.md +300 -0
  2. package/hooks/README.md +494 -0
  3. package/hooks/pre-compact.js +392 -0
  4. package/hooks/session-start.js +264 -0
  5. package/package.json +90 -0
  6. package/scripts/harvest.js +561 -0
  7. package/scripts/init-project.js +437 -0
  8. package/scripts/inject.js +388 -0
  9. package/src/collaboration/CollaborationPrompt.js +460 -0
  10. package/src/core/AlertEngine.js +813 -0
  11. package/src/core/AlertNotifier.js +363 -0
  12. package/src/core/CorrelationAnalyzer.js +774 -0
  13. package/src/core/CurationEngine.js +688 -0
  14. package/src/core/LLMPatternDetector.js +508 -0
  15. package/src/core/LoadBearingDetector.js +242 -0
  16. package/src/core/NotificationService.js +1032 -0
  17. package/src/core/PatternValidator.js +355 -0
  18. package/src/core/README.md +160 -0
  19. package/src/core/RapportOrchestrator.js +446 -0
  20. package/src/core/RelevanceDetector.js +577 -0
  21. package/src/core/StandardsIngestion.js +575 -0
  22. package/src/core/TeamLoadBearingDetector.js +431 -0
  23. package/src/database/dbOperations.js +105 -0
  24. package/src/handlers/activity/activityGetMe.js +98 -0
  25. package/src/handlers/activity/activityGetTeam.js +130 -0
  26. package/src/handlers/alerts/alertsAcknowledge.js +91 -0
  27. package/src/handlers/alerts/alertsGet.js +250 -0
  28. package/src/handlers/collaborators/collaboratorAdd.js +201 -0
  29. package/src/handlers/collaborators/collaboratorInvite.js +218 -0
  30. package/src/handlers/collaborators/collaboratorList.js +88 -0
  31. package/src/handlers/collaborators/collaboratorRemove.js +127 -0
  32. package/src/handlers/collaborators/inviteAccept.js +122 -0
  33. package/src/handlers/context/contextGet.js +57 -0
  34. package/src/handlers/context/invariantsGet.js +74 -0
  35. package/src/handlers/context/loopsGet.js +82 -0
  36. package/src/handlers/context/notesCreate.js +74 -0
  37. package/src/handlers/context/purposeGet.js +78 -0
  38. package/src/handlers/correlations/correlationsDeveloperGet.js +226 -0
  39. package/src/handlers/correlations/correlationsGet.js +93 -0
  40. package/src/handlers/correlations/correlationsProjectGet.js +161 -0
  41. package/src/handlers/github/githubConnectionStatus.js +49 -0
  42. package/src/handlers/github/githubDiscoverPatterns.js +364 -0
  43. package/src/handlers/github/githubOAuthCallback.js +166 -0
  44. package/src/handlers/github/githubOAuthStart.js +59 -0
  45. package/src/handlers/github/githubPatternsReview.js +109 -0
  46. package/src/handlers/github/githubReposList.js +105 -0
  47. package/src/handlers/helpers/checkSuperAdmin.js +85 -0
  48. package/src/handlers/helpers/dbOperations.js +53 -0
  49. package/src/handlers/helpers/errorHandler.js +49 -0
  50. package/src/handlers/helpers/index.js +106 -0
  51. package/src/handlers/helpers/lambdaWrapper.js +60 -0
  52. package/src/handlers/helpers/responseUtil.js +55 -0
  53. package/src/handlers/helpers/subscriptionTiers.js +1168 -0
  54. package/src/handlers/notifications/getPreferences.js +84 -0
  55. package/src/handlers/notifications/sendNotification.js +170 -0
  56. package/src/handlers/notifications/updatePreferences.js +316 -0
  57. package/src/handlers/patterns/patternUsagePost.js +182 -0
  58. package/src/handlers/patterns/patternViolationPost.js +185 -0
  59. package/src/handlers/projects/projectCreate.js +107 -0
  60. package/src/handlers/projects/projectDelete.js +82 -0
  61. package/src/handlers/projects/projectGet.js +95 -0
  62. package/src/handlers/projects/projectUpdate.js +118 -0
  63. package/src/handlers/reports/aiLeverage.js +206 -0
  64. package/src/handlers/reports/engineeringInvestment.js +132 -0
  65. package/src/handlers/reports/riskForecast.js +186 -0
  66. package/src/handlers/reports/standardsRoi.js +162 -0
  67. package/src/handlers/scheduled/analyzeCorrelations.js +178 -0
  68. package/src/handlers/scheduled/analyzeGitHistory.js +510 -0
  69. package/src/handlers/scheduled/generateAlerts.js +135 -0
  70. package/src/handlers/scheduled/refreshActivity.js +21 -0
  71. package/src/handlers/scheduled/scanCompliance.js +334 -0
  72. package/src/handlers/sessions/sessionEndPost.js +180 -0
  73. package/src/handlers/sessions/sessionStandardsPost.js +135 -0
  74. package/src/handlers/stripe/addonManagePost.js +240 -0
  75. package/src/handlers/stripe/billingPortalPost.js +93 -0
  76. package/src/handlers/stripe/enterpriseCheckoutPost.js +272 -0
  77. package/src/handlers/stripe/seatsUpdatePost.js +185 -0
  78. package/src/handlers/stripe/subscriptionCancelDelete.js +169 -0
  79. package/src/handlers/stripe/subscriptionCreatePost.js +221 -0
  80. package/src/handlers/stripe/subscriptionUpdatePut.js +163 -0
  81. package/src/handlers/stripe/webhookPost.js +454 -0
  82. package/src/handlers/users/cognitoPostConfirmation.js +150 -0
  83. package/src/handlers/users/userEntitlementsGet.js +89 -0
  84. package/src/handlers/users/userGet.js +114 -0
  85. package/src/handlers/webhooks/githubWebhook.js +223 -0
  86. package/src/index.js +969 -0
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Subscription Update Handler
3
+ * Upgrades or downgrades subscription tier
4
+ *
5
+ * PUT /api/stripe/subscription/update
6
+ * Body: { newTier, billingPeriod }
7
+ * Auth: Cognito JWT required
8
+ */
9
+
10
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse, handleError, getTierConfig, getStripePriceId } = require('./helpers');
11
+
12
+ // Stripe is optional
13
+ let stripe = null;
14
+ try {
15
+ if (process.env.STRIPE_SECRET_KEY) {
16
+ stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
17
+ }
18
+ } catch (e) {
19
+ console.warn('Stripe not available:', e.message);
20
+ }
21
+
22
+ /**
23
+ * Update subscription tier
24
+ */
25
+ async function updateSubscription({ body: requestBody = {}, requestContext }) {
26
+ try {
27
+ const Request_ID = requestContext.requestId;
28
+ // REST API: requestContext.authorizer.claims.email
29
+ // HTTP API: requestContext.authorizer.jwt.claims.email
30
+ const email = requestContext.authorizer?.claims?.email || requestContext.authorizer?.jwt?.claims?.email;
31
+
32
+ if (!email) {
33
+ return createErrorResponse(401, 'Authentication required');
34
+ }
35
+
36
+ const { newTier, billingPeriod = 'monthly' } = requestBody;
37
+
38
+ // Validate new tier
39
+ const newTierConfig = getTierConfig(newTier);
40
+ if (!newTierConfig || newTier === 'free') {
41
+ return createErrorResponse(400, 'Invalid tier. Use cancel endpoint for downgrade to free.', {
42
+ valid_tiers: ['team', 'professional', 'enterprise']
43
+ });
44
+ }
45
+
46
+ if (!stripe) {
47
+ return createErrorResponse(503, 'Payment system not configured');
48
+ }
49
+
50
+ // Get user's current subscription
51
+ const userQuery = `
52
+ SELECT
53
+ u.client_id,
54
+ c.stripe_customer_id,
55
+ c.subscription_tier,
56
+ c.subscription_status
57
+ FROM rapport.users u
58
+ JOIN rapport.clients c ON u.client_id = c.client_id
59
+ WHERE u.email_address = $1 AND u.active = true
60
+ `;
61
+ const userResult = await executeQuery(userQuery, [email]);
62
+
63
+ if (userResult.rowCount === 0) {
64
+ return createErrorResponse(404, 'User not found');
65
+ }
66
+
67
+ const user = userResult.rows[0];
68
+ const currentTierConfig = getTierConfig(user.subscription_tier);
69
+
70
+ if (!user.stripe_customer_id || user.subscription_status !== 'active') {
71
+ return createErrorResponse(400, 'No active subscription. Use create endpoint first.');
72
+ }
73
+
74
+ // Get active subscription from Stripe
75
+ const subscriptions = await stripe.subscriptions.list({
76
+ customer: user.stripe_customer_id,
77
+ status: 'active',
78
+ limit: 1
79
+ });
80
+
81
+ if (subscriptions.data.length === 0) {
82
+ return createErrorResponse(400, 'No active subscription found in Stripe');
83
+ }
84
+
85
+ const subscription = subscriptions.data[0];
86
+ const newPriceId = getStripePriceId(newTier, billingPeriod);
87
+
88
+ if (!newPriceId) {
89
+ return createErrorResponse(500, 'Price not configured for tier');
90
+ }
91
+
92
+ // Count users for per-seat pricing
93
+ const usageQuery = `
94
+ SELECT COUNT(*) as user_count
95
+ FROM rapport.user_entitlements
96
+ WHERE client_id = $1
97
+ `;
98
+ const usageResult = await executeQuery(usageQuery, [user.client_id]);
99
+ const userCount = parseInt(usageResult.rows[0].user_count) || 1;
100
+
101
+ // Determine if upgrade or downgrade
102
+ const isUpgrade = (newTierConfig.priceMonthly || 0) > (currentTierConfig?.priceMonthly || 0);
103
+
104
+ // Update subscription in Stripe
105
+ const updatedSubscription = await stripe.subscriptions.update(subscription.id, {
106
+ items: [{
107
+ id: subscription.items.data[0].id,
108
+ price: newPriceId,
109
+ quantity: newTierConfig.perUser ? userCount : 1
110
+ }],
111
+ proration_behavior: 'create_prorations',
112
+ metadata: {
113
+ client_id: user.client_id,
114
+ tier: newTier
115
+ }
116
+ });
117
+
118
+ // Update local database
119
+ await executeQuery(`
120
+ UPDATE rapport.clients
121
+ SET subscription_tier = $2,
122
+ last_updated = CURRENT_TIMESTAMP
123
+ WHERE client_id = $1
124
+ `, [user.client_id, newTier]);
125
+
126
+ // Calculate proration info
127
+ const currentPeriodEnd = new Date(subscription.current_period_end * 1000);
128
+ const daysRemaining = Math.ceil((currentPeriodEnd - new Date()) / (1000 * 60 * 60 * 24));
129
+
130
+ return createSuccessResponse(
131
+ {
132
+ Records: [{
133
+ previous_tier: user.subscription_tier,
134
+ new_tier: newTier,
135
+ new_tier_name: newTierConfig.displayName,
136
+ is_upgrade: isUpgrade,
137
+ billing_period: billingPeriod,
138
+ user_count: newTierConfig.perUser ? userCount : null,
139
+ monthly_cost: newTierConfig.perUser
140
+ ? newTierConfig.priceMonthly * userCount
141
+ : newTierConfig.priceMonthly,
142
+ proration: {
143
+ applied: true,
144
+ days_remaining: daysRemaining,
145
+ current_period_end: currentPeriodEnd.toISOString()
146
+ }
147
+ }]
148
+ },
149
+ isUpgrade ? 'Subscription upgraded' : 'Subscription changed',
150
+ {
151
+ Total_Records: 1,
152
+ Request_ID,
153
+ Timestamp: new Date().toISOString()
154
+ }
155
+ );
156
+
157
+ } catch (error) {
158
+ console.error('Handler Error:', error);
159
+ return handleError(error);
160
+ }
161
+ }
162
+
163
+ exports.handler = wrapHandler(updateSubscription);
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Stripe Webhook Handler
3
+ * Processes Stripe webhook events for subscription lifecycle
4
+ *
5
+ * POST /api/stripe/webhook
6
+ * Auth: Stripe signature verification (no Cognito)
7
+ *
8
+ * Following HoneyDo webhookPost.js pattern
9
+ */
10
+
11
+ const { executeQuery } = require('./helpers');
12
+ const crypto = require('crypto');
13
+
14
+ // Stripe is optional
15
+ let stripe = null;
16
+ try {
17
+ if (process.env.STRIPE_SECRET_KEY) {
18
+ stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
19
+ }
20
+ } catch (e) {
21
+ console.warn('Stripe not available:', e.message);
22
+ }
23
+
24
+ /**
25
+ * Verify Stripe webhook signature
26
+ * Uses timing-safe comparison to prevent timing attacks
27
+ */
28
+ function verifySignature(payload, signature, secret) {
29
+ const parts = signature.split(',').reduce((acc, part) => {
30
+ const [key, value] = part.split('=');
31
+ acc[key] = value;
32
+ return acc;
33
+ }, {});
34
+
35
+ const timestamp = parts.t;
36
+ const expectedSig = parts.v1;
37
+
38
+ if (!timestamp || !expectedSig) {
39
+ return false;
40
+ }
41
+
42
+ // Check timestamp is within 5 minutes
43
+ const now = Math.floor(Date.now() / 1000);
44
+ if (Math.abs(now - parseInt(timestamp)) > 300) {
45
+ console.warn('Webhook timestamp too old');
46
+ return false;
47
+ }
48
+
49
+ // Compute expected signature
50
+ const signedPayload = `${timestamp}.${payload}`;
51
+ const computedSig = crypto
52
+ .createHmac('sha256', secret)
53
+ .update(signedPayload)
54
+ .digest('hex');
55
+
56
+ // Timing-safe comparison
57
+ try {
58
+ return crypto.timingSafeEqual(
59
+ Buffer.from(expectedSig),
60
+ Buffer.from(computedSig)
61
+ );
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Handle checkout.session.completed
69
+ * New subscription created
70
+ */
71
+ async function handleCheckoutCompleted(session) {
72
+ const { client_id, tier, user_email, enterprise_package, seat_count, addons } = session.metadata || {};
73
+
74
+ if (!client_id) {
75
+ console.warn('Missing client_id in session metadata');
76
+ return;
77
+ }
78
+
79
+ // Parse enterprise metadata
80
+ const isEnterprise = tier === 'enterprise';
81
+ const seatCountNum = seat_count ? parseInt(seat_count, 10) : 1;
82
+ const addonsList = addons ? JSON.parse(addons) : [];
83
+
84
+ // Update client with subscription
85
+ if (isEnterprise) {
86
+ await executeQuery(`
87
+ UPDATE rapport.clients
88
+ SET stripe_customer_id = $2,
89
+ stripe_subscription_id = $3,
90
+ subscription_tier = 'enterprise',
91
+ subscription_status = 'active',
92
+ enterprise_package = $4,
93
+ seat_count = $5,
94
+ subscribed_addons = $6,
95
+ last_updated = CURRENT_TIMESTAMP
96
+ WHERE client_id = $1
97
+ `, [client_id, session.customer, session.subscription, enterprise_package, seatCountNum, JSON.stringify(addonsList)]);
98
+
99
+ // Create addon entitlements if any
100
+ if (addonsList.length > 0) {
101
+ for (const addonId of addonsList) {
102
+ await executeQuery(`
103
+ INSERT INTO rapport.addon_entitlements (client_id, addon_id, seat_count, status)
104
+ VALUES ($1, $2, $3, 'active')
105
+ ON CONFLICT (client_id, addon_id) DO UPDATE SET
106
+ seat_count = EXCLUDED.seat_count,
107
+ status = 'active',
108
+ updated_at = CURRENT_TIMESTAMP
109
+ `, [client_id, addonId, seatCountNum]);
110
+ }
111
+ }
112
+
113
+ console.log('Enterprise checkout completed:', { client_id, enterprise_package, seat_count: seatCountNum, addons: addonsList });
114
+ } else {
115
+ await executeQuery(`
116
+ UPDATE rapport.clients
117
+ SET stripe_customer_id = $2,
118
+ stripe_subscription_id = $3,
119
+ subscription_tier = $4,
120
+ subscription_status = 'active',
121
+ last_updated = CURRENT_TIMESTAMP
122
+ WHERE client_id = $1
123
+ `, [client_id, session.customer, session.subscription, tier || 'team']);
124
+
125
+ console.log('Checkout completed:', { client_id, tier, customer: session.customer });
126
+ }
127
+
128
+ // Update checkout session record
129
+ await executeQuery(`
130
+ UPDATE rapport.stripe_checkout_sessions
131
+ SET status = 'completed',
132
+ stripe_customer_id = $2,
133
+ stripe_subscription_id = $3,
134
+ completed_at = CURRENT_TIMESTAMP
135
+ WHERE session_id = $1
136
+ `, [session.id, session.customer, session.subscription]);
137
+ }
138
+
139
+ /**
140
+ * Handle customer.subscription.updated
141
+ * Subscription tier/status changed, seat count changed, addons changed
142
+ */
143
+ async function handleSubscriptionUpdated(subscription) {
144
+ const { client_id, tier, enterprise_package, seat_count, addons } = subscription.metadata || {};
145
+
146
+ // Find client by customer ID if not in metadata
147
+ let clientId = client_id;
148
+ if (!clientId) {
149
+ const result = await executeQuery(`
150
+ SELECT client_id FROM rapport.clients WHERE stripe_customer_id = $1
151
+ `, [subscription.customer]);
152
+
153
+ if (result.rowCount === 0) {
154
+ console.warn('Client not found for subscription update');
155
+ return;
156
+ }
157
+ clientId = result.rows[0].client_id;
158
+ }
159
+
160
+ // Parse enterprise metadata
161
+ const isEnterprise = tier === 'enterprise';
162
+ const seatCountNum = seat_count ? parseInt(seat_count, 10) : null;
163
+ const addonsList = addons ? JSON.parse(addons) : null;
164
+
165
+ // Build update query based on what changed
166
+ if (isEnterprise) {
167
+ // Calculate seat count from subscription items quantity
168
+ let calculatedSeats = seatCountNum;
169
+ if (!calculatedSeats && subscription.items?.data?.length > 0) {
170
+ // Each item quantity represents seat packs (1 pack = 25 seats)
171
+ const seatPacks = subscription.items.data[0].quantity || 1;
172
+ calculatedSeats = seatPacks * 25;
173
+ }
174
+
175
+ await executeQuery(`
176
+ UPDATE rapport.clients
177
+ SET subscription_status = $2,
178
+ subscription_tier = 'enterprise',
179
+ enterprise_package = COALESCE($3, enterprise_package),
180
+ seat_count = COALESCE($4, seat_count),
181
+ subscribed_addons = COALESCE($5, subscribed_addons),
182
+ stripe_subscription_id = $6,
183
+ last_updated = CURRENT_TIMESTAMP
184
+ WHERE client_id = $1
185
+ `, [
186
+ clientId,
187
+ subscription.status,
188
+ enterprise_package,
189
+ calculatedSeats,
190
+ addonsList ? JSON.stringify(addonsList) : null,
191
+ subscription.id
192
+ ]);
193
+
194
+ console.log('Enterprise subscription updated:', {
195
+ client_id: clientId,
196
+ status: subscription.status,
197
+ seat_count: calculatedSeats
198
+ });
199
+ } else {
200
+ await executeQuery(`
201
+ UPDATE rapport.clients
202
+ SET subscription_tier = COALESCE($2, subscription_tier),
203
+ subscription_status = $3,
204
+ stripe_subscription_id = $4,
205
+ last_updated = CURRENT_TIMESTAMP
206
+ WHERE client_id = $1
207
+ `, [clientId, tier, subscription.status, subscription.id]);
208
+
209
+ console.log('Subscription updated:', { client_id: clientId, status: subscription.status });
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Handle customer.subscription.deleted
215
+ * Subscription canceled
216
+ */
217
+ async function handleSubscriptionDeleted(subscription) {
218
+ // Find client by customer ID
219
+ const result = await executeQuery(`
220
+ SELECT client_id FROM rapport.clients WHERE stripe_customer_id = $1
221
+ `, [subscription.customer]);
222
+
223
+ if (result.rowCount === 0) {
224
+ console.warn('Client not found for subscription deletion');
225
+ return;
226
+ }
227
+
228
+ const clientId = result.rows[0].client_id;
229
+
230
+ await executeQuery(`
231
+ UPDATE rapport.clients
232
+ SET subscription_tier = 'free',
233
+ subscription_status = 'canceled',
234
+ subscription_ends_at = CURRENT_TIMESTAMP,
235
+ last_updated = CURRENT_TIMESTAMP
236
+ WHERE client_id = $1
237
+ `, [clientId]);
238
+
239
+ console.log('Subscription deleted, reverted to free:', { client_id: clientId });
240
+ }
241
+
242
+ /**
243
+ * Handle invoice.payment_succeeded
244
+ * Record successful payment
245
+ */
246
+ async function handlePaymentSucceeded(invoice) {
247
+ // Find client by customer
248
+ const result = await executeQuery(`
249
+ SELECT client_id FROM rapport.clients WHERE stripe_customer_id = $1
250
+ `, [invoice.customer]);
251
+
252
+ if (result.rowCount === 0) {
253
+ console.warn('Client not found for payment');
254
+ return;
255
+ }
256
+
257
+ const clientId = result.rows[0].client_id;
258
+
259
+ await executeQuery(`
260
+ INSERT INTO rapport.payment_history (
261
+ stripe_invoice_id,
262
+ stripe_subscription_id,
263
+ client_id,
264
+ amount_cents,
265
+ currency,
266
+ status,
267
+ paid_at
268
+ )
269
+ VALUES ($1, $2, $3, $4, $5, 'succeeded', $6)
270
+ ON CONFLICT (stripe_invoice_id) DO UPDATE SET
271
+ status = 'succeeded',
272
+ paid_at = EXCLUDED.paid_at
273
+ `, [
274
+ invoice.id,
275
+ invoice.subscription,
276
+ clientId,
277
+ invoice.amount_paid,
278
+ invoice.currency,
279
+ new Date(invoice.status_transitions?.paid_at * 1000 || Date.now())
280
+ ]);
281
+
282
+ console.log('Payment succeeded:', { invoice: invoice.id, amount: invoice.amount_paid });
283
+ }
284
+
285
+ /**
286
+ * Handle invoice.payment_failed
287
+ * Record failed payment
288
+ */
289
+ async function handlePaymentFailed(invoice) {
290
+ const result = await executeQuery(`
291
+ SELECT client_id FROM rapport.clients WHERE stripe_customer_id = $1
292
+ `, [invoice.customer]);
293
+
294
+ if (result.rowCount === 0) {
295
+ return;
296
+ }
297
+
298
+ const clientId = result.rows[0].client_id;
299
+
300
+ // Record failed payment
301
+ await executeQuery(`
302
+ INSERT INTO rapport.payment_history (
303
+ stripe_invoice_id,
304
+ stripe_subscription_id,
305
+ client_id,
306
+ amount_cents,
307
+ currency,
308
+ status,
309
+ failure_message
310
+ )
311
+ VALUES ($1, $2, $3, $4, $5, 'failed', $6)
312
+ ON CONFLICT (stripe_invoice_id) DO UPDATE SET
313
+ status = 'failed',
314
+ failure_message = EXCLUDED.failure_message
315
+ `, [
316
+ invoice.id,
317
+ invoice.subscription,
318
+ clientId,
319
+ invoice.amount_due,
320
+ invoice.currency,
321
+ invoice.last_finalization_error?.message || 'Payment failed'
322
+ ]);
323
+
324
+ // Update subscription status
325
+ await executeQuery(`
326
+ UPDATE rapport.clients
327
+ SET subscription_status = 'past_due',
328
+ last_updated = CURRENT_TIMESTAMP
329
+ WHERE client_id = $1
330
+ `, [clientId]);
331
+
332
+ console.log('Payment failed:', { invoice: invoice.id, client_id: clientId });
333
+ }
334
+
335
+ /**
336
+ * Main webhook handler
337
+ */
338
+ async function handler(event, context) {
339
+ try {
340
+ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
341
+
342
+ if (!webhookSecret) {
343
+ console.error('STRIPE_WEBHOOK_SECRET not configured');
344
+ return {
345
+ statusCode: 500,
346
+ headers: { 'Content-Type': 'application/json' },
347
+ body: JSON.stringify({ error: 'Webhook not configured' })
348
+ };
349
+ }
350
+
351
+ const signature = event.headers['stripe-signature'] || event.headers['Stripe-Signature'];
352
+ const payload = event.body;
353
+
354
+ if (!signature || !payload) {
355
+ return {
356
+ statusCode: 400,
357
+ headers: { 'Content-Type': 'application/json' },
358
+ body: JSON.stringify({ error: 'Missing signature or payload' })
359
+ };
360
+ }
361
+
362
+ // Verify signature
363
+ if (!verifySignature(payload, signature, webhookSecret)) {
364
+ console.warn('Invalid webhook signature');
365
+ return {
366
+ statusCode: 400,
367
+ headers: { 'Content-Type': 'application/json' },
368
+ body: JSON.stringify({ error: 'Invalid signature' })
369
+ };
370
+ }
371
+
372
+ const stripeEvent = JSON.parse(payload);
373
+
374
+ // Check for duplicate (idempotency)
375
+ const dupCheck = await executeQuery(`
376
+ SELECT event_id FROM rapport.stripe_webhook_events WHERE event_id = $1
377
+ `, [stripeEvent.id]);
378
+
379
+ if (dupCheck.rowCount > 0) {
380
+ console.log('Duplicate event, skipping:', stripeEvent.id);
381
+ return {
382
+ statusCode: 200,
383
+ headers: { 'Content-Type': 'application/json' },
384
+ body: JSON.stringify({ received: true, duplicate: true })
385
+ };
386
+ }
387
+
388
+ // Record event before processing
389
+ await executeQuery(`
390
+ INSERT INTO rapport.stripe_webhook_events (event_id, event_type, handled)
391
+ VALUES ($1, $2, false)
392
+ `, [stripeEvent.id, stripeEvent.type]);
393
+
394
+ // Process by event type
395
+ try {
396
+ switch (stripeEvent.type) {
397
+ case 'checkout.session.completed':
398
+ await handleCheckoutCompleted(stripeEvent.data.object);
399
+ break;
400
+ case 'customer.subscription.updated':
401
+ await handleSubscriptionUpdated(stripeEvent.data.object);
402
+ break;
403
+ case 'customer.subscription.deleted':
404
+ await handleSubscriptionDeleted(stripeEvent.data.object);
405
+ break;
406
+ case 'invoice.payment_succeeded':
407
+ await handlePaymentSucceeded(stripeEvent.data.object);
408
+ break;
409
+ case 'invoice.payment_failed':
410
+ await handlePaymentFailed(stripeEvent.data.object);
411
+ break;
412
+ default:
413
+ console.log('Unhandled event type:', stripeEvent.type);
414
+ }
415
+
416
+ // Mark as handled
417
+ await executeQuery(`
418
+ UPDATE rapport.stripe_webhook_events
419
+ SET handled = true, processed_at = CURRENT_TIMESTAMP
420
+ WHERE event_id = $1
421
+ `, [stripeEvent.id]);
422
+
423
+ } catch (processError) {
424
+ console.error('Error processing webhook:', processError);
425
+
426
+ // Record error
427
+ await executeQuery(`
428
+ UPDATE rapport.stripe_webhook_events
429
+ SET handled = false,
430
+ error = $2,
431
+ processed_at = CURRENT_TIMESTAMP
432
+ WHERE event_id = $1
433
+ `, [stripeEvent.id, processError.message]);
434
+
435
+ // Still return 200 to prevent Stripe retries for business logic errors
436
+ }
437
+
438
+ return {
439
+ statusCode: 200,
440
+ headers: { 'Content-Type': 'application/json' },
441
+ body: JSON.stringify({ received: true })
442
+ };
443
+
444
+ } catch (error) {
445
+ console.error('Webhook handler error:', error);
446
+ return {
447
+ statusCode: 500,
448
+ headers: { 'Content-Type': 'application/json' },
449
+ body: JSON.stringify({ error: 'Internal error' })
450
+ };
451
+ }
452
+ }
453
+
454
+ module.exports = { handler };