@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.
- package/README.md +300 -0
- package/hooks/README.md +494 -0
- package/hooks/pre-compact.js +392 -0
- package/hooks/session-start.js +264 -0
- package/package.json +90 -0
- package/scripts/harvest.js +561 -0
- package/scripts/init-project.js +437 -0
- package/scripts/inject.js +388 -0
- package/src/collaboration/CollaborationPrompt.js +460 -0
- package/src/core/AlertEngine.js +813 -0
- package/src/core/AlertNotifier.js +363 -0
- package/src/core/CorrelationAnalyzer.js +774 -0
- package/src/core/CurationEngine.js +688 -0
- package/src/core/LLMPatternDetector.js +508 -0
- package/src/core/LoadBearingDetector.js +242 -0
- package/src/core/NotificationService.js +1032 -0
- package/src/core/PatternValidator.js +355 -0
- package/src/core/README.md +160 -0
- package/src/core/RapportOrchestrator.js +446 -0
- package/src/core/RelevanceDetector.js +577 -0
- package/src/core/StandardsIngestion.js +575 -0
- package/src/core/TeamLoadBearingDetector.js +431 -0
- package/src/database/dbOperations.js +105 -0
- package/src/handlers/activity/activityGetMe.js +98 -0
- package/src/handlers/activity/activityGetTeam.js +130 -0
- package/src/handlers/alerts/alertsAcknowledge.js +91 -0
- package/src/handlers/alerts/alertsGet.js +250 -0
- package/src/handlers/collaborators/collaboratorAdd.js +201 -0
- package/src/handlers/collaborators/collaboratorInvite.js +218 -0
- package/src/handlers/collaborators/collaboratorList.js +88 -0
- package/src/handlers/collaborators/collaboratorRemove.js +127 -0
- package/src/handlers/collaborators/inviteAccept.js +122 -0
- package/src/handlers/context/contextGet.js +57 -0
- package/src/handlers/context/invariantsGet.js +74 -0
- package/src/handlers/context/loopsGet.js +82 -0
- package/src/handlers/context/notesCreate.js +74 -0
- package/src/handlers/context/purposeGet.js +78 -0
- package/src/handlers/correlations/correlationsDeveloperGet.js +226 -0
- package/src/handlers/correlations/correlationsGet.js +93 -0
- package/src/handlers/correlations/correlationsProjectGet.js +161 -0
- package/src/handlers/github/githubConnectionStatus.js +49 -0
- package/src/handlers/github/githubDiscoverPatterns.js +364 -0
- package/src/handlers/github/githubOAuthCallback.js +166 -0
- package/src/handlers/github/githubOAuthStart.js +59 -0
- package/src/handlers/github/githubPatternsReview.js +109 -0
- package/src/handlers/github/githubReposList.js +105 -0
- package/src/handlers/helpers/checkSuperAdmin.js +85 -0
- package/src/handlers/helpers/dbOperations.js +53 -0
- package/src/handlers/helpers/errorHandler.js +49 -0
- package/src/handlers/helpers/index.js +106 -0
- package/src/handlers/helpers/lambdaWrapper.js +60 -0
- package/src/handlers/helpers/responseUtil.js +55 -0
- package/src/handlers/helpers/subscriptionTiers.js +1168 -0
- package/src/handlers/notifications/getPreferences.js +84 -0
- package/src/handlers/notifications/sendNotification.js +170 -0
- package/src/handlers/notifications/updatePreferences.js +316 -0
- package/src/handlers/patterns/patternUsagePost.js +182 -0
- package/src/handlers/patterns/patternViolationPost.js +185 -0
- package/src/handlers/projects/projectCreate.js +107 -0
- package/src/handlers/projects/projectDelete.js +82 -0
- package/src/handlers/projects/projectGet.js +95 -0
- package/src/handlers/projects/projectUpdate.js +118 -0
- package/src/handlers/reports/aiLeverage.js +206 -0
- package/src/handlers/reports/engineeringInvestment.js +132 -0
- package/src/handlers/reports/riskForecast.js +186 -0
- package/src/handlers/reports/standardsRoi.js +162 -0
- package/src/handlers/scheduled/analyzeCorrelations.js +178 -0
- package/src/handlers/scheduled/analyzeGitHistory.js +510 -0
- package/src/handlers/scheduled/generateAlerts.js +135 -0
- package/src/handlers/scheduled/refreshActivity.js +21 -0
- package/src/handlers/scheduled/scanCompliance.js +334 -0
- package/src/handlers/sessions/sessionEndPost.js +180 -0
- package/src/handlers/sessions/sessionStandardsPost.js +135 -0
- package/src/handlers/stripe/addonManagePost.js +240 -0
- package/src/handlers/stripe/billingPortalPost.js +93 -0
- package/src/handlers/stripe/enterpriseCheckoutPost.js +272 -0
- package/src/handlers/stripe/seatsUpdatePost.js +185 -0
- package/src/handlers/stripe/subscriptionCancelDelete.js +169 -0
- package/src/handlers/stripe/subscriptionCreatePost.js +221 -0
- package/src/handlers/stripe/subscriptionUpdatePut.js +163 -0
- package/src/handlers/stripe/webhookPost.js +454 -0
- package/src/handlers/users/cognitoPostConfirmation.js +150 -0
- package/src/handlers/users/userEntitlementsGet.js +89 -0
- package/src/handlers/users/userGet.js +114 -0
- package/src/handlers/webhooks/githubWebhook.js +223 -0
- 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 };
|