mcp-twin 1.3.0 → 1.4.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.example +6 -0
- package/dist/cloud/routes/billing.d.ts +7 -0
- package/dist/cloud/routes/billing.d.ts.map +1 -0
- package/dist/cloud/routes/billing.js +368 -0
- package/dist/cloud/routes/billing.js.map +1 -0
- package/dist/cloud/server.d.ts.map +1 -1
- package/dist/cloud/server.js +24 -6
- package/dist/cloud/server.js.map +1 -1
- package/dist/cloud/stripe.d.ts +60 -0
- package/dist/cloud/stripe.d.ts.map +1 -0
- package/dist/cloud/stripe.js +157 -0
- package/dist/cloud/stripe.js.map +1 -0
- package/package.json +3 -1
- package/src/cloud/routes/billing.ts +460 -0
- package/src/cloud/server.ts +26 -6
- package/src/cloud/stripe.ts +192 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Stripe Integration
|
|
4
|
+
* MCP Twin Cloud
|
|
5
|
+
*/
|
|
6
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
7
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
8
|
+
};
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.TIER_TO_PRICE = exports.PRICE_TO_TIER = exports.PRICE_IDS = void 0;
|
|
11
|
+
exports.getStripe = getStripe;
|
|
12
|
+
exports.isStripeConfigured = isStripeConfigured;
|
|
13
|
+
exports.getOrCreateCustomer = getOrCreateCustomer;
|
|
14
|
+
exports.createCheckoutSession = createCheckoutSession;
|
|
15
|
+
exports.createPortalSession = createPortalSession;
|
|
16
|
+
exports.getSubscription = getSubscription;
|
|
17
|
+
exports.cancelSubscription = cancelSubscription;
|
|
18
|
+
exports.getInvoices = getInvoices;
|
|
19
|
+
exports.constructWebhookEvent = constructWebhookEvent;
|
|
20
|
+
const stripe_1 = __importDefault(require("stripe"));
|
|
21
|
+
// Initialize Stripe
|
|
22
|
+
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
|
|
23
|
+
let stripe = null;
|
|
24
|
+
function getStripe() {
|
|
25
|
+
if (!stripe) {
|
|
26
|
+
if (!stripeSecretKey) {
|
|
27
|
+
throw new Error('STRIPE_SECRET_KEY environment variable is required');
|
|
28
|
+
}
|
|
29
|
+
stripe = new stripe_1.default(stripeSecretKey);
|
|
30
|
+
}
|
|
31
|
+
return stripe;
|
|
32
|
+
}
|
|
33
|
+
function isStripeConfigured() {
|
|
34
|
+
return !!stripeSecretKey;
|
|
35
|
+
}
|
|
36
|
+
// Price IDs - Set these in your Stripe dashboard
|
|
37
|
+
exports.PRICE_IDS = {
|
|
38
|
+
starter_monthly: process.env.STRIPE_PRICE_STARTER || 'price_starter_monthly',
|
|
39
|
+
pro_monthly: process.env.STRIPE_PRICE_PRO || 'price_pro_monthly',
|
|
40
|
+
};
|
|
41
|
+
// Tier mapping from Stripe price to our tier
|
|
42
|
+
exports.PRICE_TO_TIER = {
|
|
43
|
+
[exports.PRICE_IDS.starter_monthly]: 'starter',
|
|
44
|
+
[exports.PRICE_IDS.pro_monthly]: 'pro',
|
|
45
|
+
};
|
|
46
|
+
// Tier to price mapping
|
|
47
|
+
exports.TIER_TO_PRICE = {
|
|
48
|
+
starter: exports.PRICE_IDS.starter_monthly,
|
|
49
|
+
pro: exports.PRICE_IDS.pro_monthly,
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Create or get Stripe customer for user
|
|
53
|
+
*/
|
|
54
|
+
async function getOrCreateCustomer(userId, email, existingCustomerId) {
|
|
55
|
+
const stripe = getStripe();
|
|
56
|
+
if (existingCustomerId) {
|
|
57
|
+
return existingCustomerId;
|
|
58
|
+
}
|
|
59
|
+
const customer = await stripe.customers.create({
|
|
60
|
+
email,
|
|
61
|
+
metadata: {
|
|
62
|
+
userId,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
return customer.id;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Create a checkout session for subscription
|
|
69
|
+
*/
|
|
70
|
+
async function createCheckoutSession(customerId, priceId, successUrl, cancelUrl) {
|
|
71
|
+
const stripe = getStripe();
|
|
72
|
+
const session = await stripe.checkout.sessions.create({
|
|
73
|
+
customer: customerId,
|
|
74
|
+
payment_method_types: ['card'],
|
|
75
|
+
line_items: [
|
|
76
|
+
{
|
|
77
|
+
price: priceId,
|
|
78
|
+
quantity: 1,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
mode: 'subscription',
|
|
82
|
+
success_url: successUrl,
|
|
83
|
+
cancel_url: cancelUrl,
|
|
84
|
+
allow_promotion_codes: true,
|
|
85
|
+
});
|
|
86
|
+
return session;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Create a billing portal session
|
|
90
|
+
*/
|
|
91
|
+
async function createPortalSession(customerId, returnUrl) {
|
|
92
|
+
const stripe = getStripe();
|
|
93
|
+
const session = await stripe.billingPortal.sessions.create({
|
|
94
|
+
customer: customerId,
|
|
95
|
+
return_url: returnUrl,
|
|
96
|
+
});
|
|
97
|
+
return session;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Get subscription details
|
|
101
|
+
*/
|
|
102
|
+
async function getSubscription(subscriptionId) {
|
|
103
|
+
const stripe = getStripe();
|
|
104
|
+
try {
|
|
105
|
+
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
|
106
|
+
return subscription;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Cancel subscription
|
|
114
|
+
*/
|
|
115
|
+
async function cancelSubscription(subscriptionId, immediately = false) {
|
|
116
|
+
const stripe = getStripe();
|
|
117
|
+
if (immediately) {
|
|
118
|
+
return stripe.subscriptions.cancel(subscriptionId);
|
|
119
|
+
}
|
|
120
|
+
// Cancel at period end
|
|
121
|
+
return stripe.subscriptions.update(subscriptionId, {
|
|
122
|
+
cancel_at_period_end: true,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Get customer's invoices
|
|
127
|
+
*/
|
|
128
|
+
async function getInvoices(customerId, limit = 10) {
|
|
129
|
+
const stripe = getStripe();
|
|
130
|
+
const invoices = await stripe.invoices.list({
|
|
131
|
+
customer: customerId,
|
|
132
|
+
limit,
|
|
133
|
+
});
|
|
134
|
+
return invoices.data;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Construct webhook event from request
|
|
138
|
+
*/
|
|
139
|
+
function constructWebhookEvent(payload, signature, webhookSecret) {
|
|
140
|
+
const stripe = getStripe();
|
|
141
|
+
return stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
|
142
|
+
}
|
|
143
|
+
exports.default = {
|
|
144
|
+
getStripe,
|
|
145
|
+
isStripeConfigured,
|
|
146
|
+
getOrCreateCustomer,
|
|
147
|
+
createCheckoutSession,
|
|
148
|
+
createPortalSession,
|
|
149
|
+
getSubscription,
|
|
150
|
+
cancelSubscription,
|
|
151
|
+
getInvoices,
|
|
152
|
+
constructWebhookEvent,
|
|
153
|
+
PRICE_IDS: exports.PRICE_IDS,
|
|
154
|
+
PRICE_TO_TIER: exports.PRICE_TO_TIER,
|
|
155
|
+
TIER_TO_PRICE: exports.TIER_TO_PRICE,
|
|
156
|
+
};
|
|
157
|
+
//# sourceMappingURL=stripe.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stripe.js","sourceRoot":"","sources":["../../src/cloud/stripe.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-twin",
|
|
3
3
|
"displayName": "MCP Twin - Hot Reload",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.4.0",
|
|
5
5
|
"description": "Zero-downtime MCP server management. CLI + Cloud API for multi-tenant hosting, observability, and billing.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Prax Labs",
|
|
@@ -98,6 +98,7 @@
|
|
|
98
98
|
"@types/jsonwebtoken": "^9.0.10",
|
|
99
99
|
"@types/node": "^20.19.27",
|
|
100
100
|
"@types/pg": "^8.16.0",
|
|
101
|
+
"@types/stripe": "^8.0.416",
|
|
101
102
|
"@types/uuid": "^10.0.0",
|
|
102
103
|
"jest": "^29.0.0",
|
|
103
104
|
"typescript": "^5.0.0"
|
|
@@ -111,6 +112,7 @@
|
|
|
111
112
|
"helmet": "^8.1.0",
|
|
112
113
|
"jsonwebtoken": "^9.0.3",
|
|
113
114
|
"pg": "^8.16.3",
|
|
115
|
+
"stripe": "^20.1.0",
|
|
114
116
|
"uuid": "^13.0.0"
|
|
115
117
|
}
|
|
116
118
|
}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing Routes
|
|
3
|
+
* MCP Twin Cloud
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Router, Request, Response } from 'express';
|
|
7
|
+
import { query, queryOne } from '../db';
|
|
8
|
+
import { authenticateApiKey, User } from '../auth';
|
|
9
|
+
import {
|
|
10
|
+
isStripeConfigured,
|
|
11
|
+
getOrCreateCustomer,
|
|
12
|
+
createCheckoutSession,
|
|
13
|
+
createPortalSession,
|
|
14
|
+
getInvoices,
|
|
15
|
+
constructWebhookEvent,
|
|
16
|
+
TIER_TO_PRICE,
|
|
17
|
+
PRICE_TO_TIER,
|
|
18
|
+
} from '../stripe';
|
|
19
|
+
|
|
20
|
+
const router = Router();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* GET /api/billing/status
|
|
24
|
+
* Get billing status and current plan
|
|
25
|
+
*/
|
|
26
|
+
router.get('/status', authenticateApiKey, async (req: Request, res: Response) => {
|
|
27
|
+
try {
|
|
28
|
+
if (!isStripeConfigured()) {
|
|
29
|
+
res.json({
|
|
30
|
+
configured: false,
|
|
31
|
+
message: 'Stripe is not configured. Set STRIPE_SECRET_KEY to enable billing.',
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const user = await queryOne<User>(
|
|
37
|
+
'SELECT id, email, tier, stripe_customer_id, stripe_subscription_id FROM users WHERE id = $1',
|
|
38
|
+
[req.user!.id]
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (!user) {
|
|
42
|
+
res.status(404).json({
|
|
43
|
+
error: { code: 'NOT_FOUND', message: 'User not found' },
|
|
44
|
+
});
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
res.json({
|
|
49
|
+
configured: true,
|
|
50
|
+
tier: user.tier,
|
|
51
|
+
hasSubscription: !!user.stripe_subscription_id,
|
|
52
|
+
customerId: user.stripe_customer_id ? `cus_...${user.stripe_customer_id.slice(-4)}` : null,
|
|
53
|
+
});
|
|
54
|
+
} catch (error: any) {
|
|
55
|
+
console.error('[Billing] Status error:', error);
|
|
56
|
+
res.status(500).json({
|
|
57
|
+
error: { code: 'INTERNAL_ERROR', message: 'Failed to get billing status' },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* GET /api/billing/plans
|
|
64
|
+
* Get available plans
|
|
65
|
+
*/
|
|
66
|
+
router.get('/plans', async (req: Request, res: Response) => {
|
|
67
|
+
res.json({
|
|
68
|
+
plans: [
|
|
69
|
+
{
|
|
70
|
+
id: 'free',
|
|
71
|
+
name: 'Free',
|
|
72
|
+
price: 0,
|
|
73
|
+
interval: null,
|
|
74
|
+
features: [
|
|
75
|
+
'1 twin',
|
|
76
|
+
'10,000 requests/month',
|
|
77
|
+
'24-hour log retention',
|
|
78
|
+
'Community support',
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'starter',
|
|
83
|
+
name: 'Starter',
|
|
84
|
+
price: 29,
|
|
85
|
+
interval: 'month',
|
|
86
|
+
priceId: TIER_TO_PRICE.starter,
|
|
87
|
+
features: [
|
|
88
|
+
'5 twins',
|
|
89
|
+
'100,000 requests/month',
|
|
90
|
+
'7-day log retention',
|
|
91
|
+
'Email support',
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: 'pro',
|
|
96
|
+
name: 'Professional',
|
|
97
|
+
price: 149,
|
|
98
|
+
interval: 'month',
|
|
99
|
+
priceId: TIER_TO_PRICE.pro,
|
|
100
|
+
features: [
|
|
101
|
+
'Unlimited twins',
|
|
102
|
+
'1,000,000 requests/month',
|
|
103
|
+
'30-day log retention',
|
|
104
|
+
'Priority support',
|
|
105
|
+
'Custom integrations',
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'enterprise',
|
|
110
|
+
name: 'Enterprise',
|
|
111
|
+
price: null,
|
|
112
|
+
interval: null,
|
|
113
|
+
features: [
|
|
114
|
+
'Unlimited everything',
|
|
115
|
+
'90-day log retention',
|
|
116
|
+
'Dedicated support',
|
|
117
|
+
'SLA guarantee',
|
|
118
|
+
'SSO/SAML',
|
|
119
|
+
'Custom contracts',
|
|
120
|
+
],
|
|
121
|
+
contactSales: true,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* POST /api/billing/checkout
|
|
129
|
+
* Create a Stripe checkout session
|
|
130
|
+
*/
|
|
131
|
+
router.post('/checkout', authenticateApiKey, async (req: Request, res: Response) => {
|
|
132
|
+
try {
|
|
133
|
+
if (!isStripeConfigured()) {
|
|
134
|
+
res.status(400).json({
|
|
135
|
+
error: {
|
|
136
|
+
code: 'STRIPE_NOT_CONFIGURED',
|
|
137
|
+
message: 'Stripe is not configured',
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const { plan, successUrl, cancelUrl } = req.body;
|
|
144
|
+
|
|
145
|
+
if (!plan || !successUrl || !cancelUrl) {
|
|
146
|
+
res.status(400).json({
|
|
147
|
+
error: {
|
|
148
|
+
code: 'VALIDATION_ERROR',
|
|
149
|
+
message: 'plan, successUrl, and cancelUrl are required',
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const priceId = TIER_TO_PRICE[plan];
|
|
156
|
+
if (!priceId) {
|
|
157
|
+
res.status(400).json({
|
|
158
|
+
error: {
|
|
159
|
+
code: 'INVALID_PLAN',
|
|
160
|
+
message: `Invalid plan: ${plan}. Valid plans: starter, pro`,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const user = await queryOne<User>(
|
|
167
|
+
'SELECT id, email, stripe_customer_id FROM users WHERE id = $1',
|
|
168
|
+
[req.user!.id]
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
if (!user) {
|
|
172
|
+
res.status(404).json({
|
|
173
|
+
error: { code: 'NOT_FOUND', message: 'User not found' },
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Get or create Stripe customer
|
|
179
|
+
const customerId = await getOrCreateCustomer(
|
|
180
|
+
user.id,
|
|
181
|
+
user.email,
|
|
182
|
+
user.stripe_customer_id
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Update user with customer ID if new
|
|
186
|
+
if (!user.stripe_customer_id) {
|
|
187
|
+
await query(
|
|
188
|
+
'UPDATE users SET stripe_customer_id = $1 WHERE id = $2',
|
|
189
|
+
[customerId, user.id]
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Create checkout session
|
|
194
|
+
const session = await createCheckoutSession(
|
|
195
|
+
customerId,
|
|
196
|
+
priceId,
|
|
197
|
+
successUrl,
|
|
198
|
+
cancelUrl
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
res.json({
|
|
202
|
+
sessionId: session.id,
|
|
203
|
+
url: session.url,
|
|
204
|
+
});
|
|
205
|
+
} catch (error: any) {
|
|
206
|
+
console.error('[Billing] Checkout error:', error);
|
|
207
|
+
res.status(500).json({
|
|
208
|
+
error: { code: 'INTERNAL_ERROR', message: 'Failed to create checkout session' },
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* POST /api/billing/portal
|
|
215
|
+
* Create a Stripe billing portal session
|
|
216
|
+
*/
|
|
217
|
+
router.post('/portal', authenticateApiKey, async (req: Request, res: Response) => {
|
|
218
|
+
try {
|
|
219
|
+
if (!isStripeConfigured()) {
|
|
220
|
+
res.status(400).json({
|
|
221
|
+
error: {
|
|
222
|
+
code: 'STRIPE_NOT_CONFIGURED',
|
|
223
|
+
message: 'Stripe is not configured',
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const { returnUrl } = req.body;
|
|
230
|
+
|
|
231
|
+
if (!returnUrl) {
|
|
232
|
+
res.status(400).json({
|
|
233
|
+
error: {
|
|
234
|
+
code: 'VALIDATION_ERROR',
|
|
235
|
+
message: 'returnUrl is required',
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const user = await queryOne<User>(
|
|
242
|
+
'SELECT stripe_customer_id FROM users WHERE id = $1',
|
|
243
|
+
[req.user!.id]
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (!user?.stripe_customer_id) {
|
|
247
|
+
res.status(400).json({
|
|
248
|
+
error: {
|
|
249
|
+
code: 'NO_SUBSCRIPTION',
|
|
250
|
+
message: 'No billing account found. Subscribe to a plan first.',
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const session = await createPortalSession(user.stripe_customer_id, returnUrl);
|
|
257
|
+
|
|
258
|
+
res.json({
|
|
259
|
+
url: session.url,
|
|
260
|
+
});
|
|
261
|
+
} catch (error: any) {
|
|
262
|
+
console.error('[Billing] Portal error:', error);
|
|
263
|
+
res.status(500).json({
|
|
264
|
+
error: { code: 'INTERNAL_ERROR', message: 'Failed to create portal session' },
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* GET /api/billing/invoices
|
|
271
|
+
* Get user's invoices
|
|
272
|
+
*/
|
|
273
|
+
router.get('/invoices', authenticateApiKey, async (req: Request, res: Response) => {
|
|
274
|
+
try {
|
|
275
|
+
if (!isStripeConfigured()) {
|
|
276
|
+
res.json({ invoices: [] });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const user = await queryOne<User>(
|
|
281
|
+
'SELECT stripe_customer_id FROM users WHERE id = $1',
|
|
282
|
+
[req.user!.id]
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
if (!user?.stripe_customer_id) {
|
|
286
|
+
res.json({ invoices: [] });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const invoices = await getInvoices(user.stripe_customer_id);
|
|
291
|
+
|
|
292
|
+
res.json({
|
|
293
|
+
invoices: invoices.map((inv) => ({
|
|
294
|
+
id: inv.id,
|
|
295
|
+
number: inv.number,
|
|
296
|
+
status: inv.status,
|
|
297
|
+
amount: inv.amount_due / 100,
|
|
298
|
+
currency: inv.currency,
|
|
299
|
+
created: new Date(inv.created * 1000).toISOString(),
|
|
300
|
+
pdfUrl: inv.invoice_pdf,
|
|
301
|
+
hostedUrl: inv.hosted_invoice_url,
|
|
302
|
+
})),
|
|
303
|
+
});
|
|
304
|
+
} catch (error: any) {
|
|
305
|
+
console.error('[Billing] Invoices error:', error);
|
|
306
|
+
res.status(500).json({
|
|
307
|
+
error: { code: 'INTERNAL_ERROR', message: 'Failed to get invoices' },
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* POST /api/billing/webhook
|
|
314
|
+
* Handle Stripe webhooks
|
|
315
|
+
*/
|
|
316
|
+
router.post(
|
|
317
|
+
'/webhook',
|
|
318
|
+
// Use raw body for webhook signature verification
|
|
319
|
+
async (req: Request, res: Response) => {
|
|
320
|
+
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
321
|
+
|
|
322
|
+
if (!webhookSecret) {
|
|
323
|
+
console.error('[Billing] STRIPE_WEBHOOK_SECRET not configured');
|
|
324
|
+
res.status(400).json({ error: 'Webhook not configured' });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const signature = req.headers['stripe-signature'] as string;
|
|
329
|
+
|
|
330
|
+
if (!signature) {
|
|
331
|
+
res.status(400).json({ error: 'Missing stripe-signature header' });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let event;
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
// req.body should be raw buffer for webhook verification
|
|
339
|
+
const rawBody = req.body;
|
|
340
|
+
event = constructWebhookEvent(rawBody, signature, webhookSecret);
|
|
341
|
+
} catch (err: any) {
|
|
342
|
+
console.error('[Billing] Webhook signature verification failed:', err.message);
|
|
343
|
+
res.status(400).json({ error: `Webhook Error: ${err.message}` });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
console.log(`[Billing] Webhook received: ${event.type}`);
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
switch (event.type) {
|
|
351
|
+
case 'checkout.session.completed': {
|
|
352
|
+
const session = event.data.object as any;
|
|
353
|
+
await handleCheckoutComplete(session);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
case 'customer.subscription.created':
|
|
358
|
+
case 'customer.subscription.updated': {
|
|
359
|
+
const subscription = event.data.object as any;
|
|
360
|
+
await handleSubscriptionUpdate(subscription);
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
case 'customer.subscription.deleted': {
|
|
365
|
+
const subscription = event.data.object as any;
|
|
366
|
+
await handleSubscriptionDeleted(subscription);
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
case 'invoice.payment_failed': {
|
|
371
|
+
const invoice = event.data.object as any;
|
|
372
|
+
await handlePaymentFailed(invoice);
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
default:
|
|
377
|
+
console.log(`[Billing] Unhandled event type: ${event.type}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
res.json({ received: true });
|
|
381
|
+
} catch (error: any) {
|
|
382
|
+
console.error('[Billing] Webhook handler error:', error);
|
|
383
|
+
res.status(500).json({ error: 'Webhook handler failed' });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Handle checkout.session.completed
|
|
390
|
+
*/
|
|
391
|
+
async function handleCheckoutComplete(session: any): Promise<void> {
|
|
392
|
+
const customerId = session.customer;
|
|
393
|
+
const subscriptionId = session.subscription;
|
|
394
|
+
|
|
395
|
+
console.log(`[Billing] Checkout complete for customer ${customerId}`);
|
|
396
|
+
|
|
397
|
+
// Update user with subscription ID
|
|
398
|
+
await query(
|
|
399
|
+
`UPDATE users
|
|
400
|
+
SET stripe_subscription_id = $1, updated_at = NOW()
|
|
401
|
+
WHERE stripe_customer_id = $2`,
|
|
402
|
+
[subscriptionId, customerId]
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Handle subscription created/updated
|
|
408
|
+
*/
|
|
409
|
+
async function handleSubscriptionUpdate(subscription: any): Promise<void> {
|
|
410
|
+
const customerId = subscription.customer;
|
|
411
|
+
const status = subscription.status;
|
|
412
|
+
const priceId = subscription.items.data[0]?.price?.id;
|
|
413
|
+
|
|
414
|
+
console.log(`[Billing] Subscription update: ${customerId} -> ${status} (${priceId})`);
|
|
415
|
+
|
|
416
|
+
if (status === 'active' || status === 'trialing') {
|
|
417
|
+
// Determine tier from price
|
|
418
|
+
const tier = PRICE_TO_TIER[priceId] || 'free';
|
|
419
|
+
|
|
420
|
+
await query(
|
|
421
|
+
`UPDATE users
|
|
422
|
+
SET tier = $1, stripe_subscription_id = $2, updated_at = NOW()
|
|
423
|
+
WHERE stripe_customer_id = $3`,
|
|
424
|
+
[tier, subscription.id, customerId]
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
console.log(`[Billing] User upgraded to ${tier}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Handle subscription deleted
|
|
433
|
+
*/
|
|
434
|
+
async function handleSubscriptionDeleted(subscription: any): Promise<void> {
|
|
435
|
+
const customerId = subscription.customer;
|
|
436
|
+
|
|
437
|
+
console.log(`[Billing] Subscription deleted for ${customerId}`);
|
|
438
|
+
|
|
439
|
+
// Downgrade to free tier
|
|
440
|
+
await query(
|
|
441
|
+
`UPDATE users
|
|
442
|
+
SET tier = 'free', stripe_subscription_id = NULL, updated_at = NOW()
|
|
443
|
+
WHERE stripe_customer_id = $1`,
|
|
444
|
+
[customerId]
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Handle payment failed
|
|
450
|
+
*/
|
|
451
|
+
async function handlePaymentFailed(invoice: any): Promise<void> {
|
|
452
|
+
const customerId = invoice.customer;
|
|
453
|
+
|
|
454
|
+
console.log(`[Billing] Payment failed for ${customerId}`);
|
|
455
|
+
|
|
456
|
+
// Could send email notification, etc.
|
|
457
|
+
// For now just log it
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export default router;
|