lokicms-plugin-stripe 1.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/src/index.ts ADDED
@@ -0,0 +1,1092 @@
1
+ /**
2
+ * Stripe Plugin for LokiCMS
3
+ * Provides payment processing and subscription management via Stripe
4
+ */
5
+
6
+ import { Hono } from 'hono';
7
+ import { z } from 'zod';
8
+ import { StripeService } from './stripe-service';
9
+ import type {
10
+ PluginDefinition,
11
+ PluginAPI,
12
+ StripeConfig,
13
+ ContentTypeRegistration,
14
+ } from './types';
15
+
16
+ // ============================================================================
17
+ // Content Types
18
+ // ============================================================================
19
+
20
+ // Content type slugs get prefixed with plugin name by LokiCMS
21
+ const CT_PRODUCT = 'stripe-stripe-product';
22
+ const CT_PRICE = 'stripe-stripe-price';
23
+ const CT_SUBSCRIPTION = 'stripe-stripe-subscription';
24
+ const CT_INVOICE = 'stripe-stripe-invoice';
25
+ const CT_CUSTOMER = 'stripe-stripe-customer';
26
+
27
+ // Convert Stripe IDs to valid slugs (prod_xxx -> prod-xxx)
28
+ function toSlug(stripeId: string): string {
29
+ return stripeId.toLowerCase().replace(/_/g, '-');
30
+ }
31
+
32
+ const CONTENT_TYPES: ContentTypeRegistration[] = [
33
+ {
34
+ name: 'Stripe Product',
35
+ slug: CT_PRODUCT,
36
+ description: 'Products synced from Stripe',
37
+ fields: [
38
+ { name: 'stripeId', label: 'Stripe ID', type: 'text', required: true },
39
+ { name: 'name', label: 'Name', type: 'text', required: true },
40
+ { name: 'description', label: 'Description', type: 'textarea' },
41
+ { name: 'active', label: 'Active', type: 'boolean' },
42
+ { name: 'metadata', label: 'Metadata', type: 'json' },
43
+ ],
44
+ titleField: 'name',
45
+ },
46
+ {
47
+ name: 'Stripe Price',
48
+ slug: CT_PRICE,
49
+ description: 'Prices synced from Stripe',
50
+ fields: [
51
+ { name: 'stripeId', label: 'Stripe ID', type: 'text', required: true },
52
+ { name: 'productId', label: 'Product ID', type: 'text', required: true },
53
+ { name: 'active', label: 'Active', type: 'boolean' },
54
+ { name: 'currency', label: 'Currency', type: 'text', required: true },
55
+ { name: 'unitAmount', label: 'Unit Amount (cents)', type: 'number', required: true },
56
+ { name: 'type', label: 'Type', type: 'select', validation: { options: ['one_time', 'recurring'] } },
57
+ { name: 'interval', label: 'Interval', type: 'select', validation: { options: ['day', 'week', 'month', 'year'] } },
58
+ { name: 'intervalCount', label: 'Interval Count', type: 'number' },
59
+ { name: 'trialPeriodDays', label: 'Trial Period (days)', type: 'number' },
60
+ ],
61
+ titleField: 'stripeId',
62
+ },
63
+ {
64
+ name: 'Stripe Subscription',
65
+ slug: CT_SUBSCRIPTION,
66
+ description: 'Customer subscriptions from Stripe',
67
+ fields: [
68
+ { name: 'stripeId', label: 'Stripe ID', type: 'text', required: true },
69
+ { name: 'customerId', label: 'Customer ID', type: 'text', required: true },
70
+ { name: 'customerEmail', label: 'Customer Email', type: 'email' },
71
+ { name: 'priceId', label: 'Price ID', type: 'text', required: true },
72
+ { name: 'status', label: 'Status', type: 'select', validation: {
73
+ options: ['active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'trialing', 'unpaid', 'paused']
74
+ }},
75
+ { name: 'currentPeriodStart', label: 'Current Period Start', type: 'datetime' },
76
+ { name: 'currentPeriodEnd', label: 'Current Period End', type: 'datetime' },
77
+ { name: 'cancelAtPeriodEnd', label: 'Cancel at Period End', type: 'boolean' },
78
+ { name: 'canceledAt', label: 'Canceled At', type: 'datetime' },
79
+ { name: 'trialStart', label: 'Trial Start', type: 'datetime' },
80
+ { name: 'trialEnd', label: 'Trial End', type: 'datetime' },
81
+ { name: 'userId', label: 'LokiCMS User ID', type: 'text' },
82
+ ],
83
+ titleField: 'stripeId',
84
+ },
85
+ {
86
+ name: 'Stripe Invoice',
87
+ slug: CT_INVOICE,
88
+ description: 'Invoices from Stripe',
89
+ fields: [
90
+ { name: 'stripeId', label: 'Stripe ID', type: 'text', required: true },
91
+ { name: 'customerId', label: 'Customer ID', type: 'text', required: true },
92
+ { name: 'subscriptionId', label: 'Subscription ID', type: 'text' },
93
+ { name: 'status', label: 'Status', type: 'select', validation: {
94
+ options: ['draft', 'open', 'paid', 'void', 'uncollectible']
95
+ }},
96
+ { name: 'amountDue', label: 'Amount Due (cents)', type: 'number' },
97
+ { name: 'amountPaid', label: 'Amount Paid (cents)', type: 'number' },
98
+ { name: 'currency', label: 'Currency', type: 'text' },
99
+ { name: 'hostedInvoiceUrl', label: 'Invoice URL', type: 'url' },
100
+ { name: 'invoicePdf', label: 'PDF URL', type: 'url' },
101
+ { name: 'paidAt', label: 'Paid At', type: 'datetime' },
102
+ ],
103
+ titleField: 'stripeId',
104
+ },
105
+ {
106
+ name: 'Stripe Customer',
107
+ slug: CT_CUSTOMER,
108
+ description: 'Customers synced from Stripe',
109
+ fields: [
110
+ { name: 'stripeId', label: 'Stripe ID', type: 'text', required: true },
111
+ { name: 'email', label: 'Email', type: 'email', required: true },
112
+ { name: 'name', label: 'Name', type: 'text' },
113
+ { name: 'userId', label: 'LokiCMS User ID', type: 'text' },
114
+ { name: 'metadata', label: 'Metadata', type: 'json' },
115
+ ],
116
+ titleField: 'email',
117
+ },
118
+ ];
119
+
120
+ // ============================================================================
121
+ // MCP Tool Schemas
122
+ // ============================================================================
123
+
124
+ const CreateCheckoutSchema = z.object({
125
+ priceId: z.string().describe('The Stripe Price ID'),
126
+ customerEmail: z.string().email().optional().describe('Customer email'),
127
+ customerId: z.string().optional().describe('Existing Stripe Customer ID'),
128
+ userId: z.string().optional().describe('LokiCMS User ID to associate'),
129
+ mode: z.enum(['payment', 'subscription']).default('subscription'),
130
+ quantity: z.number().positive().optional().default(1),
131
+ trialDays: z.number().positive().optional().describe('Trial period in days'),
132
+ });
133
+
134
+ const CreatePortalSchema = z.object({
135
+ customerId: z.string().describe('Stripe Customer ID'),
136
+ returnUrl: z.string().url().optional().describe('Return URL after portal'),
137
+ });
138
+
139
+ const GetSubscriptionSchema = z.object({
140
+ subscriptionId: z.string().describe('Stripe Subscription ID'),
141
+ });
142
+
143
+ const CancelSubscriptionSchema = z.object({
144
+ subscriptionId: z.string().describe('Stripe Subscription ID'),
145
+ atPeriodEnd: z.boolean().default(true).describe('Cancel at end of billing period'),
146
+ });
147
+
148
+ const ListProductsSchema = z.object({
149
+ active: z.boolean().optional().describe('Filter by active status'),
150
+ limit: z.number().positive().max(100).optional().default(20),
151
+ });
152
+
153
+ const ListPricesSchema = z.object({
154
+ productId: z.string().optional().describe('Filter by product'),
155
+ active: z.boolean().optional().describe('Filter by active status'),
156
+ type: z.enum(['one_time', 'recurring']).optional().describe('Filter by price type'),
157
+ limit: z.number().positive().max(100).optional().default(20),
158
+ });
159
+
160
+ const ListSubscriptionsSchema = z.object({
161
+ customerId: z.string().optional().describe('Filter by customer'),
162
+ status: z.string().optional().describe('Filter by status'),
163
+ limit: z.number().positive().max(100).optional().default(20),
164
+ });
165
+
166
+ const ListInvoicesSchema = z.object({
167
+ customerId: z.string().optional().describe('Filter by customer'),
168
+ subscriptionId: z.string().optional().describe('Filter by subscription'),
169
+ status: z.string().optional().describe('Filter by status'),
170
+ limit: z.number().positive().max(100).optional().default(20),
171
+ });
172
+
173
+ const SyncProductsSchema = z.object({
174
+ active: z.boolean().optional().default(true).describe('Only sync active products'),
175
+ });
176
+
177
+ // ============================================================================
178
+ // Plugin Definition
179
+ // ============================================================================
180
+
181
+ let stripeService: StripeService | null = null;
182
+ let pluginApi: PluginAPI | null = null;
183
+
184
+ const stripePlugin: PluginDefinition = {
185
+ name: 'stripe',
186
+ displayName: 'Stripe Payments',
187
+ version: '1.0.0',
188
+ description: 'Accept payments and manage subscriptions with Stripe',
189
+
190
+ lifecycle: {
191
+ onLoad: () => {
192
+ console.log('[Stripe] Plugin loaded');
193
+ },
194
+ onEnable: () => {
195
+ console.log('[Stripe] Plugin enabled');
196
+ },
197
+ onDisable: () => {
198
+ console.log('[Stripe] Plugin disabled');
199
+ stripeService = null;
200
+ },
201
+ },
202
+
203
+ setup: async (api: PluginAPI) => {
204
+ pluginApi = api;
205
+ const config = api.config.getAll() as unknown as StripeConfig;
206
+
207
+ // Validate configuration
208
+ if (!config.secretKey) {
209
+ api.logger.error('Stripe secret key not configured');
210
+ return;
211
+ }
212
+
213
+ // Initialize Stripe service
214
+ stripeService = new StripeService({
215
+ secretKey: config.secretKey,
216
+ webhookSecret: config.webhookSecret || '',
217
+ successUrl: config.successUrl || 'https://localhost:3000/checkout/success',
218
+ cancelUrl: config.cancelUrl || 'https://localhost:3000/checkout/cancel',
219
+ portalReturnUrl: config.portalReturnUrl || 'https://localhost:3000/account',
220
+ currency: config.currency || 'usd',
221
+ });
222
+
223
+ api.logger.info('Stripe service initialized');
224
+
225
+ // Register content types
226
+ for (const contentType of CONTENT_TYPES) {
227
+ try {
228
+ await api.contentTypes.register(contentType);
229
+ api.logger.debug(`Registered content type: ${contentType.slug}`);
230
+ } catch (error) {
231
+ api.logger.debug(`Content type ${contentType.slug} may already exist`);
232
+ }
233
+ }
234
+
235
+ // Register routes
236
+ const routes = createRoutes(api);
237
+ api.routes.register(routes);
238
+ api.logger.info(`Routes registered at ${api.routes.getBasePath()}`);
239
+
240
+ // Register MCP tools
241
+ registerMCPTools(api);
242
+ api.logger.info('MCP tools registered');
243
+
244
+ // Register hooks for user-customer sync
245
+ api.hooks.on('user:afterCreate', async (payload) => {
246
+ const user = payload as { id: string; email: string; name?: string };
247
+ if (stripeService && config.secretKey) {
248
+ try {
249
+ const customer = await stripeService.createCustomer({
250
+ email: user.email,
251
+ name: user.name,
252
+ metadata: { lokicms_user_id: user.id },
253
+ });
254
+ api.logger.info(`Created Stripe customer ${customer.id} for user ${user.id}`);
255
+ } catch (error) {
256
+ api.logger.error(`Failed to create Stripe customer for user ${user.id}`);
257
+ }
258
+ }
259
+ });
260
+
261
+ api.logger.info('Stripe plugin setup complete');
262
+ },
263
+ };
264
+
265
+ // ============================================================================
266
+ // Routes
267
+ // ============================================================================
268
+
269
+ function createRoutes(api: PluginAPI): Hono {
270
+ const app = new Hono();
271
+
272
+ // Health check
273
+ app.get('/health', (c) => {
274
+ return c.json({
275
+ status: 'ok',
276
+ service: 'stripe',
277
+ configured: stripeService !== null,
278
+ });
279
+ });
280
+
281
+ // Create checkout session
282
+ app.post('/checkout', async (c) => {
283
+ if (!stripeService) {
284
+ return c.json({ error: 'Stripe not configured' }, 500);
285
+ }
286
+
287
+ try {
288
+ const body = await c.req.json();
289
+ const parsed = CreateCheckoutSchema.parse(body);
290
+
291
+ const session = await stripeService.createCheckoutSession({
292
+ priceId: parsed.priceId,
293
+ customerEmail: parsed.customerEmail,
294
+ customerId: parsed.customerId,
295
+ userId: parsed.userId,
296
+ mode: parsed.mode,
297
+ quantity: parsed.quantity,
298
+ trialPeriodDays: parsed.trialDays,
299
+ });
300
+
301
+ return c.json(session);
302
+ } catch (error) {
303
+ api.logger.error('Checkout error:', error);
304
+ return c.json({ error: 'Failed to create checkout session' }, 400);
305
+ }
306
+ });
307
+
308
+ // Create customer portal session
309
+ app.post('/portal', async (c) => {
310
+ if (!stripeService) {
311
+ return c.json({ error: 'Stripe not configured' }, 500);
312
+ }
313
+
314
+ try {
315
+ const body = await c.req.json();
316
+ const parsed = CreatePortalSchema.parse(body);
317
+
318
+ const session = await stripeService.createPortalSession({
319
+ customerId: parsed.customerId,
320
+ returnUrl: parsed.returnUrl,
321
+ });
322
+
323
+ return c.json(session);
324
+ } catch (error) {
325
+ api.logger.error('Portal error:', error);
326
+ return c.json({ error: 'Failed to create portal session' }, 400);
327
+ }
328
+ });
329
+
330
+ // List products
331
+ app.get('/products', async (c) => {
332
+ if (!stripeService) {
333
+ return c.json({ error: 'Stripe not configured' }, 500);
334
+ }
335
+
336
+ try {
337
+ const active = c.req.query('active');
338
+ const limit = parseInt(c.req.query('limit') || '20');
339
+
340
+ const products = await stripeService.listProducts({
341
+ active: active !== undefined ? active === 'true' : undefined,
342
+ limit,
343
+ });
344
+
345
+ return c.json({ products });
346
+ } catch (error) {
347
+ api.logger.error('List products error:', error);
348
+ return c.json({ error: 'Failed to list products' }, 500);
349
+ }
350
+ });
351
+
352
+ // List prices
353
+ app.get('/prices', async (c) => {
354
+ if (!stripeService) {
355
+ return c.json({ error: 'Stripe not configured' }, 500);
356
+ }
357
+
358
+ try {
359
+ const productId = c.req.query('productId');
360
+ const active = c.req.query('active');
361
+ const type = c.req.query('type') as 'one_time' | 'recurring' | undefined;
362
+ const limit = parseInt(c.req.query('limit') || '20');
363
+
364
+ const prices = await stripeService.listPrices({
365
+ productId,
366
+ active: active !== undefined ? active === 'true' : undefined,
367
+ type,
368
+ limit,
369
+ });
370
+
371
+ return c.json({ prices });
372
+ } catch (error) {
373
+ api.logger.error('List prices error:', error);
374
+ return c.json({ error: 'Failed to list prices' }, 500);
375
+ }
376
+ });
377
+
378
+ // Get subscription
379
+ app.get('/subscriptions/:id', async (c) => {
380
+ if (!stripeService) {
381
+ return c.json({ error: 'Stripe not configured' }, 500);
382
+ }
383
+
384
+ try {
385
+ const subscription = await stripeService.getSubscription(c.req.param('id'));
386
+ if (!subscription) {
387
+ return c.json({ error: 'Subscription not found' }, 404);
388
+ }
389
+ return c.json(subscription);
390
+ } catch (error) {
391
+ api.logger.error('Get subscription error:', error);
392
+ return c.json({ error: 'Failed to get subscription' }, 500);
393
+ }
394
+ });
395
+
396
+ // List subscriptions
397
+ app.get('/subscriptions', async (c) => {
398
+ if (!stripeService) {
399
+ return c.json({ error: 'Stripe not configured' }, 500);
400
+ }
401
+
402
+ try {
403
+ const customerId = c.req.query('customerId');
404
+ const status = c.req.query('status');
405
+ const limit = parseInt(c.req.query('limit') || '20');
406
+
407
+ const subscriptions = await stripeService.listSubscriptions({
408
+ customerId,
409
+ status,
410
+ limit,
411
+ });
412
+
413
+ return c.json({ subscriptions });
414
+ } catch (error) {
415
+ api.logger.error('List subscriptions error:', error);
416
+ return c.json({ error: 'Failed to list subscriptions' }, 500);
417
+ }
418
+ });
419
+
420
+ // Cancel subscription
421
+ app.post('/subscriptions/:id/cancel', async (c) => {
422
+ if (!stripeService) {
423
+ return c.json({ error: 'Stripe not configured' }, 500);
424
+ }
425
+
426
+ try {
427
+ const body = await c.req.json().catch(() => ({}));
428
+ const atPeriodEnd = body.atPeriodEnd !== false;
429
+
430
+ const subscription = await stripeService.cancelSubscription(
431
+ c.req.param('id'),
432
+ { atPeriodEnd }
433
+ );
434
+
435
+ return c.json(subscription);
436
+ } catch (error) {
437
+ api.logger.error('Cancel subscription error:', error);
438
+ return c.json({ error: 'Failed to cancel subscription' }, 500);
439
+ }
440
+ });
441
+
442
+ // Resume subscription
443
+ app.post('/subscriptions/:id/resume', async (c) => {
444
+ if (!stripeService) {
445
+ return c.json({ error: 'Stripe not configured' }, 500);
446
+ }
447
+
448
+ try {
449
+ const subscription = await stripeService.resumeSubscription(c.req.param('id'));
450
+ return c.json(subscription);
451
+ } catch (error) {
452
+ api.logger.error('Resume subscription error:', error);
453
+ return c.json({ error: 'Failed to resume subscription' }, 500);
454
+ }
455
+ });
456
+
457
+ // List invoices
458
+ app.get('/invoices', async (c) => {
459
+ if (!stripeService) {
460
+ return c.json({ error: 'Stripe not configured' }, 500);
461
+ }
462
+
463
+ try {
464
+ const customerId = c.req.query('customerId');
465
+ const subscriptionId = c.req.query('subscriptionId');
466
+ const status = c.req.query('status');
467
+ const limit = parseInt(c.req.query('limit') || '20');
468
+
469
+ const invoices = await stripeService.listInvoices({
470
+ customerId,
471
+ subscriptionId,
472
+ status,
473
+ limit,
474
+ });
475
+
476
+ return c.json({ invoices });
477
+ } catch (error) {
478
+ api.logger.error('List invoices error:', error);
479
+ return c.json({ error: 'Failed to list invoices' }, 500);
480
+ }
481
+ });
482
+
483
+ // Webhook handler
484
+ app.post('/webhook', async (c) => {
485
+ if (!stripeService) {
486
+ return c.json({ error: 'Stripe not configured' }, 500);
487
+ }
488
+
489
+ try {
490
+ const signature = c.req.header('stripe-signature');
491
+ if (!signature) {
492
+ return c.json({ error: 'Missing stripe-signature header' }, 400);
493
+ }
494
+
495
+ const rawBody = await c.req.text();
496
+ const event = stripeService.verifyWebhookSignature(rawBody, signature);
497
+
498
+ // Process webhook event
499
+ await handleWebhookEvent(event, api);
500
+
501
+ return c.json({ received: true });
502
+ } catch (error) {
503
+ api.logger.error('Webhook error:', error);
504
+ return c.json({ error: 'Webhook verification failed' }, 400);
505
+ }
506
+ });
507
+
508
+ // Sync products from Stripe to LokiCMS
509
+ app.post('/sync/products', async (c) => {
510
+ if (!stripeService) {
511
+ return c.json({ error: 'Stripe not configured' }, 500);
512
+ }
513
+
514
+ try {
515
+ const products = await stripeService.listProducts({ active: true });
516
+ let synced = 0;
517
+
518
+ for (const product of products) {
519
+ try {
520
+ // Check if exists
521
+ const slug = toSlug(product.id);
522
+ const existing = await api.services.entries.findBySlug(
523
+ CT_PRODUCT,
524
+ slug
525
+ );
526
+
527
+ const entryData = {
528
+ contentTypeSlug: CT_PRODUCT,
529
+ title: product.name,
530
+ slug,
531
+ content: {
532
+ stripeId: product.id,
533
+ name: product.name,
534
+ description: product.description,
535
+ active: product.active,
536
+ metadata: product.metadata,
537
+ },
538
+ status: 'published',
539
+ };
540
+
541
+ if (existing) {
542
+ await api.services.entries.update((existing as { id: string }).id, entryData);
543
+ } else {
544
+ await api.services.entries.create(entryData, 'system', 'Stripe Sync');
545
+ }
546
+ synced++;
547
+ } catch (err) {
548
+ api.logger.error(`Failed to sync product ${product.id}:`, err);
549
+ }
550
+ }
551
+
552
+ return c.json({ synced, total: products.length });
553
+ } catch (error) {
554
+ api.logger.error('Sync products error:', error);
555
+ return c.json({ error: 'Failed to sync products' }, 500);
556
+ }
557
+ });
558
+
559
+ // Sync prices from Stripe to LokiCMS
560
+ app.post('/sync/prices', async (c) => {
561
+ if (!stripeService) {
562
+ return c.json({ error: 'Stripe not configured' }, 500);
563
+ }
564
+
565
+ try {
566
+ const prices = await stripeService.listPrices({ active: true });
567
+ let synced = 0;
568
+
569
+ for (const price of prices) {
570
+ try {
571
+ const slug = toSlug(price.id);
572
+ const existing = await api.services.entries.findBySlug(
573
+ CT_PRICE,
574
+ slug
575
+ );
576
+
577
+ const entryData = {
578
+ contentTypeSlug: CT_PRICE,
579
+ title: `${price.id} - ${price.unitAmount / 100} ${price.currency.toUpperCase()}`,
580
+ slug,
581
+ content: {
582
+ stripeId: price.id,
583
+ productId: price.productId,
584
+ active: price.active,
585
+ currency: price.currency,
586
+ unitAmount: price.unitAmount,
587
+ type: price.type,
588
+ interval: price.interval,
589
+ intervalCount: price.intervalCount,
590
+ trialPeriodDays: price.trialPeriodDays,
591
+ },
592
+ status: 'published',
593
+ };
594
+
595
+ if (existing) {
596
+ await api.services.entries.update((existing as { id: string }).id, entryData);
597
+ } else {
598
+ await api.services.entries.create(entryData, 'system', 'Stripe Sync');
599
+ }
600
+ synced++;
601
+ } catch (err) {
602
+ api.logger.error(`Failed to sync price ${price.id}:`, err);
603
+ }
604
+ }
605
+
606
+ return c.json({ synced, total: prices.length });
607
+ } catch (error) {
608
+ api.logger.error('Sync prices error:', error);
609
+ return c.json({ error: 'Failed to sync prices' }, 500);
610
+ }
611
+ });
612
+
613
+ return app;
614
+ }
615
+
616
+ // ============================================================================
617
+ // Webhook Event Handler
618
+ // ============================================================================
619
+
620
+ async function handleWebhookEvent(
621
+ event: { id: string; type: string; data: { object: unknown } },
622
+ api: PluginAPI
623
+ ): Promise<void> {
624
+ api.logger.info(`Processing webhook: ${event.type}`);
625
+
626
+ switch (event.type) {
627
+ case 'customer.subscription.created':
628
+ case 'customer.subscription.updated':
629
+ await syncSubscription(event.data.object as SubscriptionObject, api);
630
+ break;
631
+
632
+ case 'customer.subscription.deleted':
633
+ await handleSubscriptionDeleted(event.data.object as SubscriptionObject, api);
634
+ break;
635
+
636
+ case 'invoice.paid':
637
+ case 'invoice.payment_failed':
638
+ case 'invoice.created':
639
+ await syncInvoice(event.data.object as InvoiceObject, api);
640
+ break;
641
+
642
+ case 'checkout.session.completed':
643
+ await handleCheckoutCompleted(event.data.object as CheckoutObject, api);
644
+ break;
645
+
646
+ case 'product.created':
647
+ case 'product.updated':
648
+ await syncProduct(event.data.object as ProductObject, api);
649
+ break;
650
+
651
+ case 'price.created':
652
+ case 'price.updated':
653
+ await syncPrice(event.data.object as PriceObject, api);
654
+ break;
655
+
656
+ default:
657
+ api.logger.debug(`Unhandled webhook event: ${event.type}`);
658
+ }
659
+ }
660
+
661
+ // Type helpers for webhook objects
662
+ interface SubscriptionObject {
663
+ id: string;
664
+ customer: string;
665
+ items: { data: Array<{ price: { id: string } }> };
666
+ status: string;
667
+ current_period_start: number;
668
+ current_period_end: number;
669
+ cancel_at_period_end: boolean;
670
+ canceled_at?: number;
671
+ trial_start?: number;
672
+ trial_end?: number;
673
+ metadata?: Record<string, string>;
674
+ }
675
+
676
+ interface InvoiceObject {
677
+ id: string;
678
+ customer: string;
679
+ subscription?: string;
680
+ status: string;
681
+ amount_due: number;
682
+ amount_paid: number;
683
+ currency: string;
684
+ hosted_invoice_url?: string;
685
+ invoice_pdf?: string;
686
+ status_transitions?: { paid_at?: number };
687
+ }
688
+
689
+ interface CheckoutObject {
690
+ id: string;
691
+ customer: string;
692
+ subscription?: string;
693
+ client_reference_id?: string;
694
+ metadata?: Record<string, string>;
695
+ }
696
+
697
+ interface ProductObject {
698
+ id: string;
699
+ name: string;
700
+ description?: string;
701
+ active: boolean;
702
+ metadata?: Record<string, string>;
703
+ }
704
+
705
+ interface PriceObject {
706
+ id: string;
707
+ product: string;
708
+ active: boolean;
709
+ currency: string;
710
+ unit_amount: number;
711
+ type: string;
712
+ recurring?: {
713
+ interval: string;
714
+ interval_count: number;
715
+ trial_period_days?: number;
716
+ };
717
+ }
718
+
719
+ async function syncSubscription(sub: SubscriptionObject, api: PluginAPI): Promise<void> {
720
+ try {
721
+ const slug = toSlug(sub.id);
722
+ const existing = await api.services.entries.findBySlug(CT_SUBSCRIPTION, slug);
723
+
724
+ // Get customer email if possible
725
+ let customerEmail = '';
726
+ if (stripeService) {
727
+ const customer = await stripeService.getCustomer(sub.customer);
728
+ customerEmail = customer?.email || '';
729
+ }
730
+
731
+ const entryData = {
732
+ contentTypeSlug: CT_SUBSCRIPTION,
733
+ title: sub.id,
734
+ slug,
735
+ content: {
736
+ stripeId: sub.id,
737
+ customerId: sub.customer,
738
+ customerEmail,
739
+ priceId: sub.items.data[0]?.price.id,
740
+ status: sub.status,
741
+ currentPeriodStart: sub.current_period_start * 1000,
742
+ currentPeriodEnd: sub.current_period_end * 1000,
743
+ cancelAtPeriodEnd: sub.cancel_at_period_end,
744
+ canceledAt: sub.canceled_at ? sub.canceled_at * 1000 : null,
745
+ trialStart: sub.trial_start ? sub.trial_start * 1000 : null,
746
+ trialEnd: sub.trial_end ? sub.trial_end * 1000 : null,
747
+ userId: sub.metadata?.lokicms_user_id,
748
+ },
749
+ status: 'published',
750
+ };
751
+
752
+ if (existing) {
753
+ await api.services.entries.update((existing as { id: string }).id, entryData);
754
+ } else {
755
+ await api.services.entries.create(entryData, 'system', 'Stripe Webhook');
756
+ }
757
+
758
+ api.logger.info(`Synced subscription: ${sub.id}`);
759
+ } catch (error) {
760
+ api.logger.error(`Failed to sync subscription ${sub.id}:`, error);
761
+ }
762
+ }
763
+
764
+ async function handleSubscriptionDeleted(sub: SubscriptionObject, api: PluginAPI): Promise<void> {
765
+ try {
766
+ const slug = toSlug(sub.id);
767
+ const existing = await api.services.entries.findBySlug(CT_SUBSCRIPTION, slug);
768
+ if (existing) {
769
+ await api.services.entries.update((existing as { id: string }).id, {
770
+ content: { status: 'canceled' },
771
+ status: 'archived',
772
+ });
773
+ api.logger.info(`Marked subscription as deleted: ${sub.id}`);
774
+ }
775
+ } catch (error) {
776
+ api.logger.error(`Failed to handle subscription deletion ${sub.id}:`, error);
777
+ }
778
+ }
779
+
780
+ async function syncInvoice(inv: InvoiceObject, api: PluginAPI): Promise<void> {
781
+ try {
782
+ const slug = toSlug(inv.id);
783
+ const existing = await api.services.entries.findBySlug(CT_INVOICE, slug);
784
+
785
+ const entryData = {
786
+ contentTypeSlug: CT_INVOICE,
787
+ title: inv.id,
788
+ slug,
789
+ content: {
790
+ stripeId: inv.id,
791
+ customerId: inv.customer,
792
+ subscriptionId: inv.subscription,
793
+ status: inv.status,
794
+ amountDue: inv.amount_due,
795
+ amountPaid: inv.amount_paid,
796
+ currency: inv.currency,
797
+ hostedInvoiceUrl: inv.hosted_invoice_url,
798
+ invoicePdf: inv.invoice_pdf,
799
+ paidAt: inv.status_transitions?.paid_at ? inv.status_transitions.paid_at * 1000 : null,
800
+ },
801
+ status: 'published',
802
+ };
803
+
804
+ if (existing) {
805
+ await api.services.entries.update((existing as { id: string }).id, entryData);
806
+ } else {
807
+ await api.services.entries.create(entryData, 'system', 'Stripe Webhook');
808
+ }
809
+
810
+ api.logger.info(`Synced invoice: ${inv.id}`);
811
+ } catch (error) {
812
+ api.logger.error(`Failed to sync invoice ${inv.id}:`, error);
813
+ }
814
+ }
815
+
816
+ async function handleCheckoutCompleted(checkout: CheckoutObject, api: PluginAPI): Promise<void> {
817
+ try {
818
+ // If there's a user ID, link the customer
819
+ if (checkout.client_reference_id && checkout.customer) {
820
+ const customerSlug = toSlug(checkout.customer);
821
+ const existing = await api.services.entries.findBySlug(
822
+ CT_CUSTOMER,
823
+ customerSlug
824
+ );
825
+
826
+ if (!existing && stripeService) {
827
+ const customer = await stripeService.getCustomer(checkout.customer);
828
+ if (customer) {
829
+ await api.services.entries.create({
830
+ contentTypeSlug: CT_CUSTOMER,
831
+ title: customer.email,
832
+ slug: toSlug(customer.id),
833
+ content: {
834
+ stripeId: customer.id,
835
+ email: customer.email,
836
+ name: customer.name,
837
+ userId: checkout.client_reference_id,
838
+ metadata: customer.metadata,
839
+ },
840
+ status: 'published',
841
+ }, 'system', 'Stripe Webhook');
842
+ }
843
+ }
844
+ }
845
+
846
+ api.logger.info(`Checkout completed: ${checkout.id}`);
847
+ } catch (error) {
848
+ api.logger.error(`Failed to handle checkout ${checkout.id}:`, error);
849
+ }
850
+ }
851
+
852
+ async function syncProduct(product: ProductObject, api: PluginAPI): Promise<void> {
853
+ try {
854
+ const slug = toSlug(product.id);
855
+ const existing = await api.services.entries.findBySlug(CT_PRODUCT, slug);
856
+
857
+ const entryData = {
858
+ contentTypeSlug: CT_PRODUCT,
859
+ title: product.name,
860
+ slug,
861
+ content: {
862
+ stripeId: product.id,
863
+ name: product.name,
864
+ description: product.description,
865
+ active: product.active,
866
+ metadata: product.metadata,
867
+ },
868
+ status: 'published',
869
+ };
870
+
871
+ if (existing) {
872
+ await api.services.entries.update((existing as { id: string }).id, entryData);
873
+ } else {
874
+ await api.services.entries.create(entryData, 'system', 'Stripe Webhook');
875
+ }
876
+
877
+ api.logger.info(`Synced product: ${product.id}`);
878
+ } catch (error) {
879
+ api.logger.error(`Failed to sync product ${product.id}:`, error);
880
+ }
881
+ }
882
+
883
+ async function syncPrice(price: PriceObject, api: PluginAPI): Promise<void> {
884
+ try {
885
+ const slug = toSlug(price.id);
886
+ const existing = await api.services.entries.findBySlug(CT_PRICE, slug);
887
+
888
+ const entryData = {
889
+ contentTypeSlug: CT_PRICE,
890
+ title: `${price.id} - ${price.unit_amount / 100} ${price.currency.toUpperCase()}`,
891
+ slug,
892
+ content: {
893
+ stripeId: price.id,
894
+ productId: price.product,
895
+ active: price.active,
896
+ currency: price.currency,
897
+ unitAmount: price.unit_amount,
898
+ type: price.type,
899
+ interval: price.recurring?.interval,
900
+ intervalCount: price.recurring?.interval_count,
901
+ trialPeriodDays: price.recurring?.trial_period_days,
902
+ },
903
+ status: 'published',
904
+ };
905
+
906
+ if (existing) {
907
+ await api.services.entries.update((existing as { id: string }).id, entryData);
908
+ } else {
909
+ await api.services.entries.create(entryData, 'system', 'Stripe Webhook');
910
+ }
911
+
912
+ api.logger.info(`Synced price: ${price.id}`);
913
+ } catch (error) {
914
+ api.logger.error(`Failed to sync price ${price.id}:`, error);
915
+ }
916
+ }
917
+
918
+ // ============================================================================
919
+ // MCP Tools
920
+ // ============================================================================
921
+
922
+ function registerMCPTools(api: PluginAPI): void {
923
+ // Create checkout session
924
+ api.mcp.registerTool('create_checkout', {
925
+ description: 'Create a Stripe checkout session for payment or subscription',
926
+ inputSchema: CreateCheckoutSchema,
927
+ handler: async (args) => {
928
+ if (!stripeService) throw new Error('Stripe not configured');
929
+ const params = args as z.infer<typeof CreateCheckoutSchema>;
930
+ return await stripeService.createCheckoutSession({
931
+ priceId: params.priceId,
932
+ customerEmail: params.customerEmail,
933
+ customerId: params.customerId,
934
+ userId: params.userId,
935
+ mode: params.mode,
936
+ quantity: params.quantity,
937
+ trialPeriodDays: params.trialDays,
938
+ });
939
+ },
940
+ });
941
+
942
+ // Create customer portal
943
+ api.mcp.registerTool('create_portal', {
944
+ description: 'Create a Stripe customer portal session for subscription management',
945
+ inputSchema: CreatePortalSchema,
946
+ handler: async (args) => {
947
+ if (!stripeService) throw new Error('Stripe not configured');
948
+ const params = args as z.infer<typeof CreatePortalSchema>;
949
+ return await stripeService.createPortalSession({
950
+ customerId: params.customerId,
951
+ returnUrl: params.returnUrl,
952
+ });
953
+ },
954
+ });
955
+
956
+ // Get subscription
957
+ api.mcp.registerTool('get_subscription', {
958
+ description: 'Get details of a Stripe subscription',
959
+ inputSchema: GetSubscriptionSchema,
960
+ handler: async (args) => {
961
+ if (!stripeService) throw new Error('Stripe not configured');
962
+ const params = args as z.infer<typeof GetSubscriptionSchema>;
963
+ return await stripeService.getSubscription(params.subscriptionId);
964
+ },
965
+ });
966
+
967
+ // Cancel subscription
968
+ api.mcp.registerTool('cancel_subscription', {
969
+ description: 'Cancel a Stripe subscription',
970
+ inputSchema: CancelSubscriptionSchema,
971
+ handler: async (args) => {
972
+ if (!stripeService) throw new Error('Stripe not configured');
973
+ const params = args as z.infer<typeof CancelSubscriptionSchema>;
974
+ return await stripeService.cancelSubscription(params.subscriptionId, {
975
+ atPeriodEnd: params.atPeriodEnd,
976
+ });
977
+ },
978
+ });
979
+
980
+ // List products
981
+ api.mcp.registerTool('list_products', {
982
+ description: 'List Stripe products',
983
+ inputSchema: ListProductsSchema,
984
+ handler: async (args) => {
985
+ if (!stripeService) throw new Error('Stripe not configured');
986
+ const params = args as z.infer<typeof ListProductsSchema>;
987
+ return await stripeService.listProducts({
988
+ active: params.active,
989
+ limit: params.limit,
990
+ });
991
+ },
992
+ });
993
+
994
+ // List prices
995
+ api.mcp.registerTool('list_prices', {
996
+ description: 'List Stripe prices',
997
+ inputSchema: ListPricesSchema,
998
+ handler: async (args) => {
999
+ if (!stripeService) throw new Error('Stripe not configured');
1000
+ const params = args as z.infer<typeof ListPricesSchema>;
1001
+ return await stripeService.listPrices({
1002
+ productId: params.productId,
1003
+ active: params.active,
1004
+ type: params.type,
1005
+ limit: params.limit,
1006
+ });
1007
+ },
1008
+ });
1009
+
1010
+ // List subscriptions
1011
+ api.mcp.registerTool('list_subscriptions', {
1012
+ description: 'List Stripe subscriptions',
1013
+ inputSchema: ListSubscriptionsSchema,
1014
+ handler: async (args) => {
1015
+ if (!stripeService) throw new Error('Stripe not configured');
1016
+ const params = args as z.infer<typeof ListSubscriptionsSchema>;
1017
+ return await stripeService.listSubscriptions({
1018
+ customerId: params.customerId,
1019
+ status: params.status,
1020
+ limit: params.limit,
1021
+ });
1022
+ },
1023
+ });
1024
+
1025
+ // List invoices
1026
+ api.mcp.registerTool('list_invoices', {
1027
+ description: 'List Stripe invoices',
1028
+ inputSchema: ListInvoicesSchema,
1029
+ handler: async (args) => {
1030
+ if (!stripeService) throw new Error('Stripe not configured');
1031
+ const params = args as z.infer<typeof ListInvoicesSchema>;
1032
+ return await stripeService.listInvoices({
1033
+ customerId: params.customerId,
1034
+ subscriptionId: params.subscriptionId,
1035
+ status: params.status,
1036
+ limit: params.limit,
1037
+ });
1038
+ },
1039
+ });
1040
+
1041
+ // Sync products
1042
+ api.mcp.registerTool('sync_products', {
1043
+ description: 'Sync products from Stripe to LokiCMS content entries',
1044
+ inputSchema: SyncProductsSchema,
1045
+ handler: async (args) => {
1046
+ if (!stripeService || !pluginApi) throw new Error('Stripe not configured');
1047
+ const params = args as z.infer<typeof SyncProductsSchema>;
1048
+ const products = await stripeService.listProducts({ active: params.active });
1049
+ let synced = 0;
1050
+
1051
+ for (const product of products) {
1052
+ try {
1053
+ const slug = toSlug(product.id);
1054
+ const existing = await pluginApi.services.entries.findBySlug(
1055
+ CT_PRODUCT,
1056
+ slug
1057
+ );
1058
+
1059
+ const entryData = {
1060
+ contentTypeSlug: CT_PRODUCT,
1061
+ title: product.name,
1062
+ slug,
1063
+ content: {
1064
+ stripeId: product.id,
1065
+ name: product.name,
1066
+ description: product.description,
1067
+ active: product.active,
1068
+ metadata: product.metadata,
1069
+ },
1070
+ status: 'published',
1071
+ };
1072
+
1073
+ if (existing) {
1074
+ await pluginApi.services.entries.update(
1075
+ (existing as { id: string }).id,
1076
+ entryData
1077
+ );
1078
+ } else {
1079
+ await pluginApi.services.entries.create(entryData, 'system', 'MCP Sync');
1080
+ }
1081
+ synced++;
1082
+ } catch {
1083
+ // Continue with next product
1084
+ }
1085
+ }
1086
+
1087
+ return { synced, total: products.length };
1088
+ },
1089
+ });
1090
+ }
1091
+
1092
+ export default stripePlugin;