@xenterprises/fastify-xstripe 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/server/app.js ADDED
@@ -0,0 +1,557 @@
1
+ // server/app.js - Example Fastify server with xStripe
2
+ import Fastify from 'fastify';
3
+ import xStripe from '../src/xStripe.js';
4
+
5
+ const fastify = Fastify({
6
+ logger: true,
7
+ // Enable raw body for webhook signature verification
8
+ rawBody: true,
9
+ });
10
+
11
+ // Register xStripe with custom handlers
12
+ await fastify.register(xStripe, {
13
+ apiKey: process.env.STRIPE_API_KEY,
14
+ webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
15
+ webhookPath: '/stripe/webhook',
16
+ handlers: {
17
+ // Custom handler for new subscriptions
18
+ 'customer.subscription.created': async (event, fastify, stripe) => {
19
+ const subscription = event.data.object;
20
+
21
+ fastify.log.info({
22
+ msg: '🎉 New subscription!',
23
+ customerId: subscription.customer,
24
+ subscriptionId: subscription.id,
25
+ plan: subscription.items.data[0]?.price.product,
26
+ status: subscription.status,
27
+ });
28
+
29
+ // Example: Update database (uncomment if using Prisma)
30
+ // await fastify.prisma.user.update({
31
+ // where: { stripeCustomerId: subscription.customer },
32
+ // data: {
33
+ // subscriptionId: subscription.id,
34
+ // subscriptionStatus: subscription.status,
35
+ // planId: subscription.items.data[0]?.price.id,
36
+ // trialEnd: subscription.trial_end
37
+ // ? new Date(subscription.trial_end * 1000)
38
+ // : null,
39
+ // },
40
+ // });
41
+ },
42
+
43
+ // Custom handler for subscription updates
44
+ 'customer.subscription.updated': async (event, fastify, stripe) => {
45
+ const subscription = event.data.object;
46
+ const previous = event.data.previous_attributes || {};
47
+
48
+ if ('status' in previous) {
49
+ fastify.log.info({
50
+ msg: 'Subscription status changed',
51
+ subscriptionId: subscription.id,
52
+ oldStatus: previous.status,
53
+ newStatus: subscription.status,
54
+ });
55
+
56
+ // Handle specific status transitions
57
+ if (subscription.status === 'active' && previous.status === 'trialing') {
58
+ fastify.log.info('✅ Trial converted to paid subscription');
59
+ }
60
+
61
+ if (subscription.status === 'past_due') {
62
+ fastify.log.warn('⚠️ Subscription past due - payment failed');
63
+ }
64
+
65
+ if (subscription.status === 'canceled') {
66
+ fastify.log.info('❌ Subscription canceled');
67
+ }
68
+ }
69
+ },
70
+
71
+ // Custom handler for failed payments
72
+ 'invoice.payment_failed': async (event, fastify, stripe) => {
73
+ const invoice = event.data.object;
74
+
75
+ fastify.log.error({
76
+ msg: '💳 Payment failed',
77
+ invoiceId: invoice.id,
78
+ customerId: invoice.customer,
79
+ amount: invoice.amount_due / 100,
80
+ attemptCount: invoice.attempt_count,
81
+ });
82
+
83
+ // Example: Track failed payments
84
+ // await fastify.prisma.user.update({
85
+ // where: { stripeCustomerId: invoice.customer },
86
+ // data: {
87
+ // failedPaymentCount: { increment: 1 },
88
+ // lastFailedPayment: new Date(),
89
+ // },
90
+ // });
91
+ },
92
+
93
+ // Custom handler for successful payments
94
+ 'invoice.paid': async (event, fastify, stripe) => {
95
+ const invoice = event.data.object;
96
+
97
+ fastify.log.info({
98
+ msg: '✅ Payment successful',
99
+ invoiceId: invoice.id,
100
+ customerId: invoice.customer,
101
+ amount: invoice.amount_paid / 100,
102
+ });
103
+
104
+ // Example: Reset failed payment count
105
+ // await fastify.prisma.user.update({
106
+ // where: { stripeCustomerId: invoice.customer },
107
+ // data: {
108
+ // failedPaymentCount: 0,
109
+ // lastSuccessfulPayment: new Date(),
110
+ // },
111
+ // });
112
+ },
113
+ },
114
+ });
115
+
116
+ // ============================================================================
117
+ // Example API Routes - Subscription Management
118
+ // ============================================================================
119
+
120
+ // Create subscription checkout session
121
+ fastify.post('/create-checkout-session', async (request, reply) => {
122
+ const { priceId, customerId } = request.body;
123
+
124
+ try {
125
+ const session = await fastify.stripe.checkout.sessions.create({
126
+ customer: customerId,
127
+ mode: 'subscription',
128
+ line_items: [{ price: priceId, quantity: 1 }],
129
+ success_url: `${process.env.DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`,
130
+ cancel_url: `${process.env.DOMAIN}/cancel`,
131
+ });
132
+
133
+ return { sessionId: session.id, url: session.url };
134
+ } catch (error) {
135
+ fastify.log.error(error);
136
+ return reply.code(500).send({ error: error.message });
137
+ }
138
+ });
139
+
140
+ // Create customer
141
+ fastify.post('/create-customer', async (request, reply) => {
142
+ const { email, name } = request.body;
143
+
144
+ try {
145
+ const customer = await fastify.stripe.customers.create({
146
+ email,
147
+ name,
148
+ });
149
+
150
+ return { customerId: customer.id };
151
+ } catch (error) {
152
+ fastify.log.error(error);
153
+ return reply.code(500).send({ error: error.message });
154
+ }
155
+ });
156
+
157
+ // Get subscription status
158
+ fastify.get('/subscription/:id', async (request, reply) => {
159
+ const { id } = request.params;
160
+
161
+ try {
162
+ const subscription = await fastify.stripe.subscriptions.retrieve(id);
163
+
164
+ return {
165
+ id: subscription.id,
166
+ status: subscription.status,
167
+ currentPeriodEnd: new Date(subscription.current_period_end * 1000),
168
+ cancelAtPeriodEnd: subscription.cancel_at_period_end,
169
+ };
170
+ } catch (error) {
171
+ fastify.log.error(error);
172
+ return reply.code(404).send({ error: 'Subscription not found' });
173
+ }
174
+ });
175
+
176
+ // Cancel subscription
177
+ fastify.post('/cancel-subscription/:id', async (request, reply) => {
178
+ const { id } = request.params;
179
+ const { immediately = false } = request.body;
180
+
181
+ try {
182
+ let subscription;
183
+
184
+ if (immediately) {
185
+ subscription = await fastify.stripe.subscriptions.cancel(id);
186
+ } else {
187
+ subscription = await fastify.stripe.subscriptions.update(id, {
188
+ cancel_at_period_end: true,
189
+ });
190
+ }
191
+
192
+ return { subscriptionId: subscription.id, status: subscription.status };
193
+ } catch (error) {
194
+ fastify.log.error(error);
195
+ return reply.code(500).send({ error: error.message });
196
+ }
197
+ });
198
+
199
+ // ============================================================================
200
+ // FEATURE 1: Plan Listing
201
+ // ============================================================================
202
+
203
+ /**
204
+ * GET /plans
205
+ * List all available products and their prices
206
+ * @returns Array of products with pricing information
207
+ */
208
+ fastify.get('/plans', async (request, reply) => { // request unused but available for future pagination params
209
+ try {
210
+ // Get all products
211
+ const products = await fastify.stripe.products.list({
212
+ active: true,
213
+ limit: 100,
214
+ });
215
+
216
+ // Get prices for each product
217
+ const plans = await Promise.all(
218
+ products.data.map(async (product) => {
219
+ const prices = await fastify.stripe.prices.list({
220
+ product: product.id,
221
+ active: true,
222
+ type: 'recurring',
223
+ });
224
+
225
+ return {
226
+ productId: product.id,
227
+ name: product.name,
228
+ description: product.description,
229
+ images: product.images,
230
+ metadata: product.metadata,
231
+ prices: prices.data.map((price) => ({
232
+ priceId: price.id,
233
+ amount: price.unit_amount / 100, // Convert cents to dollars
234
+ currency: price.currency.toUpperCase(),
235
+ interval: price.recurring?.interval,
236
+ intervalCount: price.recurring?.interval_count || 1,
237
+ trialPeriodDays: price.recurring?.trial_period_days,
238
+ nickname: price.nickname,
239
+ metadata: price.metadata,
240
+ })),
241
+ };
242
+ })
243
+ );
244
+
245
+ return { plans };
246
+ } catch (error) {
247
+ fastify.log.error(error);
248
+ return reply.code(500).send({ error: error.message });
249
+ }
250
+ });
251
+
252
+ /**
253
+ * GET /plans/:productId
254
+ * Get a specific product with all its prices
255
+ */
256
+ fastify.get('/plans/:productId', async (request, reply) => {
257
+ const { productId } = request.params;
258
+
259
+ try {
260
+ const product = await fastify.stripe.products.retrieve(productId);
261
+ const prices = await fastify.stripe.prices.list({
262
+ product: productId,
263
+ active: true,
264
+ });
265
+
266
+ return {
267
+ productId: product.id,
268
+ name: product.name,
269
+ description: product.description,
270
+ images: product.images,
271
+ metadata: product.metadata,
272
+ prices: prices.data.map((price) => ({
273
+ priceId: price.id,
274
+ amount: price.unit_amount / 100,
275
+ currency: price.currency.toUpperCase(),
276
+ interval: price.recurring?.interval,
277
+ intervalCount: price.recurring?.interval_count || 1,
278
+ trialPeriodDays: price.recurring?.trial_period_days,
279
+ nickname: price.nickname,
280
+ })),
281
+ };
282
+ } catch (error) {
283
+ fastify.log.error(error);
284
+ return reply.code(404).send({ error: 'Product not found' });
285
+ }
286
+ });
287
+
288
+ // ============================================================================
289
+ // FEATURE 2: One-Time Payments
290
+ // ============================================================================
291
+
292
+ /**
293
+ * POST /create-payment-session
294
+ * Create a checkout session for one-time payment
295
+ * @body { customerId, priceId, quantity, metadata }
296
+ */
297
+ fastify.post('/create-payment-session', async (request, reply) => {
298
+ const { customerId, priceId, quantity = 1, metadata = {} } = request.body;
299
+
300
+ try {
301
+ const session = await fastify.stripe.checkout.sessions.create({
302
+ customer: customerId,
303
+ mode: 'payment',
304
+ line_items: [
305
+ {
306
+ price: priceId,
307
+ quantity,
308
+ },
309
+ ],
310
+ success_url: `${process.env.DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`,
311
+ cancel_url: `${process.env.DOMAIN}/cancel`,
312
+ metadata,
313
+ });
314
+
315
+ return { sessionId: session.id, url: session.url };
316
+ } catch (error) {
317
+ fastify.log.error(error);
318
+ return reply.code(500).send({ error: error.message });
319
+ }
320
+ });
321
+
322
+ // ============================================================================
323
+ // FEATURE 3: Subscription Listing & Management
324
+ // ============================================================================
325
+
326
+ /**
327
+ * GET /customer/:customerId/subscriptions
328
+ * List all subscriptions for a customer
329
+ */
330
+ fastify.get('/customer/:customerId/subscriptions', async (request, reply) => {
331
+ const { customerId } = request.params;
332
+
333
+ try {
334
+ const subscriptions = await fastify.stripe.subscriptions.list({
335
+ customer: customerId,
336
+ limit: 100,
337
+ });
338
+
339
+ return {
340
+ subscriptions: subscriptions.data.map((sub) => ({
341
+ id: sub.id,
342
+ status: sub.status,
343
+ planId: sub.items.data[0]?.price.id,
344
+ planName: sub.items.data[0]?.price.nickname,
345
+ amount: sub.items.data[0]?.price.unit_amount / 100,
346
+ currency: sub.items.data[0]?.price.currency.toUpperCase(),
347
+ interval: sub.items.data[0]?.price.recurring?.interval,
348
+ createdAt: new Date(sub.created * 1000),
349
+ currentPeriodEnd: new Date(sub.current_period_end * 1000),
350
+ cancelAtPeriodEnd: sub.cancel_at_period_end,
351
+ canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null,
352
+ })),
353
+ total: subscriptions.data.length,
354
+ };
355
+ } catch (error) {
356
+ fastify.log.error(error);
357
+ return reply.code(500).send({ error: error.message });
358
+ }
359
+ });
360
+
361
+ /**
362
+ * POST /subscription/:id/update
363
+ * Update a subscription (change plan, apply coupon, etc.)
364
+ * @body { priceId, quantity, coupon, metadata, prorationBehavior }
365
+ */
366
+ fastify.post('/subscription/:id/update', async (request, reply) => {
367
+ const { id } = request.params;
368
+ const {
369
+ priceId,
370
+ quantity = 1,
371
+ coupon,
372
+ metadata,
373
+ prorationBehavior = 'create_prorations',
374
+ } = request.body;
375
+
376
+ try {
377
+ const subscription = await fastify.stripe.subscriptions.retrieve(id);
378
+ const itemId = subscription.items.data[0]?.id;
379
+
380
+ const updateData = {
381
+ items: [
382
+ {
383
+ id: itemId,
384
+ price: priceId,
385
+ quantity,
386
+ },
387
+ ],
388
+ proration_behavior: prorationBehavior, // 'create_prorations', 'none', 'always_invoice'
389
+ };
390
+
391
+ if (coupon) {
392
+ updateData.coupon = coupon;
393
+ }
394
+
395
+ if (metadata) {
396
+ updateData.metadata = metadata;
397
+ }
398
+
399
+ const updated = await fastify.stripe.subscriptions.update(id, updateData);
400
+
401
+ return {
402
+ id: updated.id,
403
+ status: updated.status,
404
+ priceId: updated.items.data[0]?.price.id,
405
+ amount: updated.items.data[0]?.price.unit_amount / 100,
406
+ nextBillingDate: new Date(updated.current_period_end * 1000),
407
+ message: 'Subscription updated successfully',
408
+ };
409
+ } catch (error) {
410
+ fastify.log.error(error);
411
+ return reply.code(500).send({ error: error.message });
412
+ }
413
+ });
414
+
415
+ // ============================================================================
416
+ // FEATURE 4: Payment Methods Management
417
+ // ============================================================================
418
+
419
+ /**
420
+ * GET /customer/:customerId/payment-methods
421
+ * List all payment methods for a customer
422
+ */
423
+ fastify.get('/customer/:customerId/payment-methods', async (request, reply) => {
424
+ const { customerId } = request.params;
425
+
426
+ try {
427
+ const paymentMethods = await fastify.stripe.paymentMethods.list({
428
+ customer: customerId,
429
+ type: 'card',
430
+ });
431
+
432
+ // Get default payment method from customer
433
+ const customer = await fastify.stripe.customers.retrieve(customerId);
434
+
435
+ return {
436
+ paymentMethods: paymentMethods.data.map((pm) => ({
437
+ id: pm.id,
438
+ type: pm.type,
439
+ isDefault: pm.id === customer.invoice_settings?.default_payment_method,
440
+ card: {
441
+ brand: pm.card?.brand,
442
+ last4: pm.card?.last4,
443
+ expMonth: pm.card?.exp_month,
444
+ expYear: pm.card?.exp_year,
445
+ },
446
+ billingDetails: pm.billing_details,
447
+ createdAt: new Date(pm.created * 1000),
448
+ })),
449
+ defaultPaymentMethodId: customer.invoice_settings?.default_payment_method,
450
+ total: paymentMethods.data.length,
451
+ };
452
+ } catch (error) {
453
+ fastify.log.error(error);
454
+ return reply.code(500).send({ error: error.message });
455
+ }
456
+ });
457
+
458
+ /**
459
+ * POST /customer/:customerId/payment-methods
460
+ * Add a new payment method to a customer (requires payment method ID from frontend)
461
+ * @body { paymentMethodId }
462
+ */
463
+ fastify.post('/customer/:customerId/payment-methods', async (request, reply) => {
464
+ const { customerId } = request.params;
465
+ const { paymentMethodId } = request.body;
466
+
467
+ try {
468
+ // Attach payment method to customer
469
+ await fastify.stripe.paymentMethods.attach(paymentMethodId, {
470
+ customer: customerId,
471
+ });
472
+
473
+ const paymentMethod = await fastify.stripe.paymentMethods.retrieve(
474
+ paymentMethodId
475
+ );
476
+
477
+ return {
478
+ id: paymentMethod.id,
479
+ type: paymentMethod.type,
480
+ card: {
481
+ brand: paymentMethod.card?.brand,
482
+ last4: paymentMethod.card?.last4,
483
+ expMonth: paymentMethod.card?.exp_month,
484
+ expYear: paymentMethod.card?.exp_year,
485
+ },
486
+ message: 'Payment method added successfully',
487
+ };
488
+ } catch (error) {
489
+ fastify.log.error(error);
490
+ return reply.code(500).send({ error: error.message });
491
+ }
492
+ });
493
+
494
+ /**
495
+ * POST /customer/:customerId/payment-methods/:paymentMethodId/default
496
+ * Set a payment method as default for a customer
497
+ */
498
+ fastify.post(
499
+ '/customer/:customerId/payment-methods/:paymentMethodId/default',
500
+ async (request, reply) => {
501
+ const { customerId, paymentMethodId } = request.params;
502
+
503
+ try {
504
+ const customer = await fastify.stripe.customers.update(customerId, {
505
+ invoice_settings: {
506
+ default_payment_method: paymentMethodId,
507
+ },
508
+ });
509
+
510
+ return {
511
+ customerId: customer.id,
512
+ defaultPaymentMethodId: customer.invoice_settings?.default_payment_method,
513
+ message: 'Default payment method updated successfully',
514
+ };
515
+ } catch (error) {
516
+ fastify.log.error(error);
517
+ return reply.code(500).send({ error: error.message });
518
+ }
519
+ }
520
+ );
521
+
522
+ /**
523
+ * DELETE /customer/:customerId/payment-methods/:paymentMethodId
524
+ * Remove a payment method from a customer
525
+ */
526
+ fastify.delete(
527
+ '/customer/:customerId/payment-methods/:paymentMethodId',
528
+ async (request, reply) => {
529
+ const { paymentMethodId } = request.params;
530
+
531
+ try {
532
+ await fastify.stripe.paymentMethods.detach(paymentMethodId);
533
+
534
+ return {
535
+ paymentMethodId,
536
+ message: 'Payment method removed successfully',
537
+ };
538
+ } catch (error) {
539
+ fastify.log.error(error);
540
+ return reply.code(500).send({ error: error.message });
541
+ }
542
+ }
543
+ );
544
+
545
+ // ============================================================================
546
+ // Health Check
547
+ // ============================================================================
548
+
549
+ fastify.get('/health', async () => {
550
+ return {
551
+ status: 'ok',
552
+ stripe: !!fastify.stripe,
553
+ timestamp: new Date().toISOString(),
554
+ };
555
+ });
556
+
557
+ export default fastify;