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.
@@ -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();