lsh-framework 1.3.2 → 1.4.1
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 +43 -3
- package/README.md +25 -4
- package/dist/cli.js +6 -0
- package/dist/commands/config.js +240 -0
- package/dist/daemon/saas-api-routes.js +778 -0
- package/dist/daemon/saas-api-server.js +225 -0
- package/dist/lib/config-manager.js +321 -0
- package/dist/lib/database-persistence.js +75 -3
- package/dist/lib/env-validator.js +17 -0
- package/dist/lib/local-storage-adapter.js +493 -0
- package/dist/lib/saas-audit.js +213 -0
- package/dist/lib/saas-auth.js +427 -0
- package/dist/lib/saas-billing.js +402 -0
- package/dist/lib/saas-email.js +402 -0
- package/dist/lib/saas-encryption.js +220 -0
- package/dist/lib/saas-organizations.js +592 -0
- package/dist/lib/saas-secrets.js +378 -0
- package/dist/lib/saas-types.js +108 -0
- package/dist/lib/supabase-client.js +77 -11
- package/package.json +13 -2
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSH SaaS Billing Service
|
|
3
|
+
* Stripe integration for subscriptions and billing
|
|
4
|
+
*/
|
|
5
|
+
import { getSupabaseClient } from './supabase-client.js';
|
|
6
|
+
import { auditLogger } from './saas-audit.js';
|
|
7
|
+
/**
|
|
8
|
+
* Stripe Pricing IDs (set via environment variables)
|
|
9
|
+
*/
|
|
10
|
+
export const STRIPE_PRICE_IDS = {
|
|
11
|
+
pro_monthly: process.env.STRIPE_PRICE_PRO_MONTHLY || '',
|
|
12
|
+
pro_yearly: process.env.STRIPE_PRICE_PRO_YEARLY || '',
|
|
13
|
+
enterprise_monthly: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY || '',
|
|
14
|
+
enterprise_yearly: process.env.STRIPE_PRICE_ENTERPRISE_YEARLY || '',
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Billing Service
|
|
18
|
+
*/
|
|
19
|
+
export class BillingService {
|
|
20
|
+
supabase = getSupabaseClient();
|
|
21
|
+
stripeSecretKey = process.env.STRIPE_SECRET_KEY || '';
|
|
22
|
+
stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET || '';
|
|
23
|
+
stripeApiUrl = 'https://api.stripe.com/v1';
|
|
24
|
+
/**
|
|
25
|
+
* Create Stripe customer
|
|
26
|
+
*/
|
|
27
|
+
async createStripeCustomer(params) {
|
|
28
|
+
if (!this.stripeSecretKey) {
|
|
29
|
+
throw new Error('STRIPE_SECRET_KEY not configured');
|
|
30
|
+
}
|
|
31
|
+
const formData = new URLSearchParams();
|
|
32
|
+
formData.append('email', params.email);
|
|
33
|
+
if (params.name) {
|
|
34
|
+
formData.append('name', params.name);
|
|
35
|
+
}
|
|
36
|
+
formData.append('metadata[organization_id]', params.organizationId);
|
|
37
|
+
const response = await fetch(`${this.stripeApiUrl}/customers`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: {
|
|
40
|
+
Authorization: `Bearer ${this.stripeSecretKey}`,
|
|
41
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
42
|
+
},
|
|
43
|
+
body: formData,
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
const error = await response.text();
|
|
47
|
+
throw new Error(`Failed to create Stripe customer: ${error}`);
|
|
48
|
+
}
|
|
49
|
+
const customer = await response.json();
|
|
50
|
+
// Update organization with Stripe customer ID
|
|
51
|
+
await this.supabase
|
|
52
|
+
.from('organizations')
|
|
53
|
+
.update({ stripe_customer_id: customer.id })
|
|
54
|
+
.eq('id', params.organizationId);
|
|
55
|
+
return customer.id;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Create checkout session
|
|
59
|
+
*/
|
|
60
|
+
async createCheckoutSession(params) {
|
|
61
|
+
if (!this.stripeSecretKey) {
|
|
62
|
+
throw new Error('STRIPE_SECRET_KEY not configured');
|
|
63
|
+
}
|
|
64
|
+
// Get price ID
|
|
65
|
+
const priceKey = `${params.tier}_${params.billingPeriod}`;
|
|
66
|
+
const priceId = STRIPE_PRICE_IDS[priceKey];
|
|
67
|
+
if (!priceId) {
|
|
68
|
+
throw new Error(`No Stripe price configured for ${params.tier} ${params.billingPeriod}`);
|
|
69
|
+
}
|
|
70
|
+
const formData = new URLSearchParams();
|
|
71
|
+
formData.append('mode', 'subscription');
|
|
72
|
+
formData.append('line_items[0][price]', priceId);
|
|
73
|
+
formData.append('line_items[0][quantity]', '1');
|
|
74
|
+
formData.append('success_url', params.successUrl);
|
|
75
|
+
formData.append('cancel_url', params.cancelUrl);
|
|
76
|
+
formData.append('metadata[organization_id]', params.organizationId);
|
|
77
|
+
formData.append('subscription_data[metadata][organization_id]', params.organizationId);
|
|
78
|
+
if (params.customerId) {
|
|
79
|
+
formData.append('customer', params.customerId);
|
|
80
|
+
}
|
|
81
|
+
const response = await fetch(`${this.stripeApiUrl}/checkout/sessions`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
Authorization: `Bearer ${this.stripeSecretKey}`,
|
|
85
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
86
|
+
},
|
|
87
|
+
body: formData,
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const error = await response.text();
|
|
91
|
+
throw new Error(`Failed to create checkout session: ${error}`);
|
|
92
|
+
}
|
|
93
|
+
const session = await response.json();
|
|
94
|
+
return {
|
|
95
|
+
sessionId: session.id,
|
|
96
|
+
url: session.url,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Create billing portal session
|
|
101
|
+
*/
|
|
102
|
+
async createPortalSession(customerId, returnUrl) {
|
|
103
|
+
if (!this.stripeSecretKey) {
|
|
104
|
+
throw new Error('STRIPE_SECRET_KEY not configured');
|
|
105
|
+
}
|
|
106
|
+
const formData = new URLSearchParams();
|
|
107
|
+
formData.append('customer', customerId);
|
|
108
|
+
formData.append('return_url', returnUrl);
|
|
109
|
+
const response = await fetch(`${this.stripeApiUrl}/billing_portal/sessions`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: {
|
|
112
|
+
Authorization: `Bearer ${this.stripeSecretKey}`,
|
|
113
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
114
|
+
},
|
|
115
|
+
body: formData,
|
|
116
|
+
});
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
const error = await response.text();
|
|
119
|
+
throw new Error(`Failed to create portal session: ${error}`);
|
|
120
|
+
}
|
|
121
|
+
const session = await response.json();
|
|
122
|
+
return session.url;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Handle Stripe webhook
|
|
126
|
+
*/
|
|
127
|
+
async handleWebhook(payload, signature) {
|
|
128
|
+
if (!this.stripeWebhookSecret) {
|
|
129
|
+
throw new Error('STRIPE_WEBHOOK_SECRET not configured');
|
|
130
|
+
}
|
|
131
|
+
// Verify webhook signature
|
|
132
|
+
const event = this.verifyWebhookSignature(payload, signature);
|
|
133
|
+
// Handle different event types
|
|
134
|
+
switch (event.type) {
|
|
135
|
+
case 'checkout.session.completed':
|
|
136
|
+
await this.handleCheckoutCompleted(event.data.object);
|
|
137
|
+
break;
|
|
138
|
+
case 'customer.subscription.created':
|
|
139
|
+
case 'customer.subscription.updated':
|
|
140
|
+
await this.handleSubscriptionUpdated(event.data.object);
|
|
141
|
+
break;
|
|
142
|
+
case 'customer.subscription.deleted':
|
|
143
|
+
await this.handleSubscriptionDeleted(event.data.object);
|
|
144
|
+
break;
|
|
145
|
+
case 'invoice.paid':
|
|
146
|
+
await this.handleInvoicePaid(event.data.object);
|
|
147
|
+
break;
|
|
148
|
+
case 'invoice.payment_failed':
|
|
149
|
+
await this.handleInvoicePaymentFailed(event.data.object);
|
|
150
|
+
break;
|
|
151
|
+
default:
|
|
152
|
+
console.log(`Unhandled webhook event: ${event.type}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Verify webhook signature
|
|
157
|
+
*/
|
|
158
|
+
verifyWebhookSignature(payload, signature) {
|
|
159
|
+
// In production, use Stripe's webhook signature verification
|
|
160
|
+
// For now, just parse the payload
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(payload);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
throw new Error('Invalid webhook payload');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Handle checkout completed
|
|
170
|
+
*/
|
|
171
|
+
async handleCheckoutCompleted(session) {
|
|
172
|
+
const organizationId = session.metadata?.organization_id;
|
|
173
|
+
if (!organizationId) {
|
|
174
|
+
console.error('No organization_id in checkout session metadata');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// Subscription will be created via customer.subscription.created event
|
|
178
|
+
console.log(`Checkout completed for organization ${organizationId}`);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Handle subscription updated
|
|
182
|
+
*/
|
|
183
|
+
async handleSubscriptionUpdated(subscription) {
|
|
184
|
+
const organizationId = subscription.metadata?.organization_id;
|
|
185
|
+
if (!organizationId) {
|
|
186
|
+
console.error('No organization_id in subscription metadata');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// Determine tier from price ID
|
|
190
|
+
const priceId = subscription.items?.data[0]?.price?.id;
|
|
191
|
+
const tier = this.getTierFromPriceId(priceId);
|
|
192
|
+
// Upsert subscription
|
|
193
|
+
const { error } = await this.supabase
|
|
194
|
+
.from('subscriptions')
|
|
195
|
+
.upsert({
|
|
196
|
+
organization_id: organizationId,
|
|
197
|
+
stripe_subscription_id: subscription.id,
|
|
198
|
+
stripe_price_id: priceId,
|
|
199
|
+
stripe_product_id: subscription.items?.data[0]?.price?.product,
|
|
200
|
+
tier,
|
|
201
|
+
status: subscription.status,
|
|
202
|
+
current_period_start: new Date(subscription.current_period_start * 1000).toISOString(),
|
|
203
|
+
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
|
|
204
|
+
cancel_at_period_end: subscription.cancel_at_period_end,
|
|
205
|
+
trial_start: subscription.trial_start
|
|
206
|
+
? new Date(subscription.trial_start * 1000).toISOString()
|
|
207
|
+
: null,
|
|
208
|
+
trial_end: subscription.trial_end
|
|
209
|
+
? new Date(subscription.trial_end * 1000).toISOString()
|
|
210
|
+
: null,
|
|
211
|
+
}, { onConflict: 'stripe_subscription_id' });
|
|
212
|
+
if (error) {
|
|
213
|
+
console.error('Failed to upsert subscription:', error);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// Update organization tier
|
|
217
|
+
await this.supabase
|
|
218
|
+
.from('organizations')
|
|
219
|
+
.update({
|
|
220
|
+
subscription_tier: tier,
|
|
221
|
+
subscription_status: subscription.status,
|
|
222
|
+
subscription_expires_at: new Date(subscription.current_period_end * 1000).toISOString(),
|
|
223
|
+
})
|
|
224
|
+
.eq('id', organizationId);
|
|
225
|
+
await auditLogger.log({
|
|
226
|
+
organizationId,
|
|
227
|
+
action: 'billing.subscription_updated',
|
|
228
|
+
resourceType: 'subscription',
|
|
229
|
+
resourceId: subscription.id,
|
|
230
|
+
newValue: { tier, status: subscription.status },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Handle subscription deleted
|
|
235
|
+
*/
|
|
236
|
+
async handleSubscriptionDeleted(subscription) {
|
|
237
|
+
const organizationId = subscription.metadata?.organization_id;
|
|
238
|
+
if (!organizationId) {
|
|
239
|
+
console.error('No organization_id in subscription metadata');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// Mark subscription as canceled
|
|
243
|
+
await this.supabase
|
|
244
|
+
.from('subscriptions')
|
|
245
|
+
.update({
|
|
246
|
+
status: 'canceled',
|
|
247
|
+
canceled_at: new Date().toISOString(),
|
|
248
|
+
})
|
|
249
|
+
.eq('stripe_subscription_id', subscription.id);
|
|
250
|
+
// Downgrade organization to free tier
|
|
251
|
+
await this.supabase
|
|
252
|
+
.from('organizations')
|
|
253
|
+
.update({
|
|
254
|
+
subscription_tier: 'free',
|
|
255
|
+
subscription_status: 'canceled',
|
|
256
|
+
})
|
|
257
|
+
.eq('id', organizationId);
|
|
258
|
+
await auditLogger.log({
|
|
259
|
+
organizationId,
|
|
260
|
+
action: 'billing.subscription_canceled',
|
|
261
|
+
resourceType: 'subscription',
|
|
262
|
+
resourceId: subscription.id,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Handle invoice paid
|
|
267
|
+
*/
|
|
268
|
+
async handleInvoicePaid(invoice) {
|
|
269
|
+
const organizationId = invoice.subscription_metadata?.organization_id;
|
|
270
|
+
if (!organizationId) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// Record invoice
|
|
274
|
+
await this.supabase.from('invoices').upsert({
|
|
275
|
+
organization_id: organizationId,
|
|
276
|
+
stripe_invoice_id: invoice.id,
|
|
277
|
+
number: invoice.number,
|
|
278
|
+
amount_due: invoice.amount_due,
|
|
279
|
+
amount_paid: invoice.amount_paid,
|
|
280
|
+
currency: invoice.currency?.toUpperCase() || 'USD',
|
|
281
|
+
status: 'paid',
|
|
282
|
+
invoice_date: new Date(invoice.created * 1000).toISOString(),
|
|
283
|
+
paid_at: new Date(invoice.status_transitions?.paid_at * 1000).toISOString(),
|
|
284
|
+
invoice_pdf_url: invoice.invoice_pdf,
|
|
285
|
+
}, { onConflict: 'stripe_invoice_id' });
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Handle invoice payment failed
|
|
289
|
+
*/
|
|
290
|
+
async handleInvoicePaymentFailed(invoice) {
|
|
291
|
+
const organizationId = invoice.subscription_metadata?.organization_id;
|
|
292
|
+
if (!organizationId) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
// Update organization status
|
|
296
|
+
await this.supabase
|
|
297
|
+
.from('organizations')
|
|
298
|
+
.update({
|
|
299
|
+
subscription_status: 'past_due',
|
|
300
|
+
})
|
|
301
|
+
.eq('id', organizationId);
|
|
302
|
+
await auditLogger.log({
|
|
303
|
+
organizationId,
|
|
304
|
+
action: 'billing.payment_failed',
|
|
305
|
+
resourceType: 'subscription',
|
|
306
|
+
metadata: { invoice_id: invoice.id },
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Get tier from Stripe price ID
|
|
311
|
+
*/
|
|
312
|
+
getTierFromPriceId(priceId) {
|
|
313
|
+
if (priceId === STRIPE_PRICE_IDS.pro_monthly ||
|
|
314
|
+
priceId === STRIPE_PRICE_IDS.pro_yearly) {
|
|
315
|
+
return 'pro';
|
|
316
|
+
}
|
|
317
|
+
if (priceId === STRIPE_PRICE_IDS.enterprise_monthly ||
|
|
318
|
+
priceId === STRIPE_PRICE_IDS.enterprise_yearly) {
|
|
319
|
+
return 'enterprise';
|
|
320
|
+
}
|
|
321
|
+
return 'free';
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Get subscription for organization
|
|
325
|
+
*/
|
|
326
|
+
async getOrganizationSubscription(organizationId) {
|
|
327
|
+
const { data, error } = await this.supabase
|
|
328
|
+
.from('subscriptions')
|
|
329
|
+
.select('*')
|
|
330
|
+
.eq('organization_id', organizationId)
|
|
331
|
+
.order('created_at', { ascending: false })
|
|
332
|
+
.limit(1)
|
|
333
|
+
.single();
|
|
334
|
+
if (error || !data) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
return this.mapDbSubscriptionToSubscription(data);
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Get invoices for organization
|
|
341
|
+
*/
|
|
342
|
+
async getOrganizationInvoices(organizationId) {
|
|
343
|
+
const { data, error } = await this.supabase
|
|
344
|
+
.from('invoices')
|
|
345
|
+
.select('*')
|
|
346
|
+
.eq('organization_id', organizationId)
|
|
347
|
+
.order('invoice_date', { ascending: false });
|
|
348
|
+
if (error) {
|
|
349
|
+
throw new Error(`Failed to get invoices: ${error.message}`);
|
|
350
|
+
}
|
|
351
|
+
return (data || []).map(this.mapDbInvoiceToInvoice);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Map database subscription to Subscription type
|
|
355
|
+
*/
|
|
356
|
+
mapDbSubscriptionToSubscription(dbSub) {
|
|
357
|
+
return {
|
|
358
|
+
id: dbSub.id,
|
|
359
|
+
organizationId: dbSub.organization_id,
|
|
360
|
+
stripeSubscriptionId: dbSub.stripe_subscription_id,
|
|
361
|
+
stripePriceId: dbSub.stripe_price_id,
|
|
362
|
+
stripeProductId: dbSub.stripe_product_id,
|
|
363
|
+
tier: dbSub.tier,
|
|
364
|
+
status: dbSub.status,
|
|
365
|
+
currentPeriodStart: dbSub.current_period_start
|
|
366
|
+
? new Date(dbSub.current_period_start)
|
|
367
|
+
: null,
|
|
368
|
+
currentPeriodEnd: dbSub.current_period_end ? new Date(dbSub.current_period_end) : null,
|
|
369
|
+
cancelAtPeriodEnd: dbSub.cancel_at_period_end,
|
|
370
|
+
trialStart: dbSub.trial_start ? new Date(dbSub.trial_start) : null,
|
|
371
|
+
trialEnd: dbSub.trial_end ? new Date(dbSub.trial_end) : null,
|
|
372
|
+
createdAt: new Date(dbSub.created_at),
|
|
373
|
+
updatedAt: new Date(dbSub.updated_at),
|
|
374
|
+
canceledAt: dbSub.canceled_at ? new Date(dbSub.canceled_at) : null,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Map database invoice to Invoice type
|
|
379
|
+
*/
|
|
380
|
+
mapDbInvoiceToInvoice(dbInvoice) {
|
|
381
|
+
return {
|
|
382
|
+
id: dbInvoice.id,
|
|
383
|
+
organizationId: dbInvoice.organization_id,
|
|
384
|
+
stripeInvoiceId: dbInvoice.stripe_invoice_id,
|
|
385
|
+
number: dbInvoice.number,
|
|
386
|
+
amountDue: dbInvoice.amount_due,
|
|
387
|
+
amountPaid: dbInvoice.amount_paid,
|
|
388
|
+
currency: dbInvoice.currency,
|
|
389
|
+
status: dbInvoice.status,
|
|
390
|
+
invoiceDate: new Date(dbInvoice.invoice_date),
|
|
391
|
+
dueDate: dbInvoice.due_date ? new Date(dbInvoice.due_date) : null,
|
|
392
|
+
paidAt: dbInvoice.paid_at ? new Date(dbInvoice.paid_at) : null,
|
|
393
|
+
invoicePdfUrl: dbInvoice.invoice_pdf_url,
|
|
394
|
+
createdAt: new Date(dbInvoice.created_at),
|
|
395
|
+
updatedAt: new Date(dbInvoice.updated_at),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Singleton instance
|
|
401
|
+
*/
|
|
402
|
+
export const billingService = new BillingService();
|