@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.
@@ -0,0 +1,220 @@
1
+ // src/utils/helpers.js
2
+
3
+ /**
4
+ * Helper utilities for working with Stripe webhooks
5
+ */
6
+
7
+ /**
8
+ * Extract customer email from various Stripe objects
9
+ */
10
+ export function getCustomerEmail(event, stripe) {
11
+ const obj = event.data.object;
12
+
13
+ // Direct email on object
14
+ if (obj.email) return obj.email;
15
+
16
+ // Customer object
17
+ if (obj.customer?.email) return obj.customer.email;
18
+
19
+ // Need to fetch customer
20
+ if (obj.customer && typeof obj.customer === 'string') {
21
+ return stripe.customers.retrieve(obj.customer)
22
+ .then(customer => customer.email)
23
+ .catch(() => null);
24
+ }
25
+
26
+ return null;
27
+ }
28
+
29
+ /**
30
+ * Format amount from cents to dollars
31
+ */
32
+ export function formatAmount(cents, currency = 'USD') {
33
+ const amount = cents / 100;
34
+ return new Intl.NumberFormat('en-US', {
35
+ style: 'currency',
36
+ currency,
37
+ }).format(amount);
38
+ }
39
+
40
+ /**
41
+ * Get subscription plan name from subscription object
42
+ */
43
+ export function getPlanName(subscription) {
44
+ const firstItem = subscription.items?.data?.[0];
45
+ if (!firstItem) return 'Unknown Plan';
46
+
47
+ const price = firstItem.price;
48
+ return price.nickname || price.product?.name || price.id;
49
+ }
50
+
51
+ /**
52
+ * Check if subscription is in trial
53
+ */
54
+ export function isInTrial(subscription) {
55
+ return subscription.status === 'trialing' && subscription.trial_end > Date.now() / 1000;
56
+ }
57
+
58
+ /**
59
+ * Check if subscription is active (including trialing)
60
+ */
61
+ export function isActiveSubscription(subscription) {
62
+ return ['active', 'trialing'].includes(subscription.status);
63
+ }
64
+
65
+ /**
66
+ * Get days until trial ends
67
+ */
68
+ export function getDaysUntilTrialEnd(subscription) {
69
+ if (!subscription.trial_end) return null;
70
+
71
+ const now = Date.now() / 1000;
72
+ const secondsRemaining = subscription.trial_end - now;
73
+ return Math.ceil(secondsRemaining / 86400);
74
+ }
75
+
76
+ /**
77
+ * Determine if this is a subscription renewal vs new subscription
78
+ */
79
+ export function isRenewal(event) {
80
+ if (event.type !== 'invoice.paid') return false;
81
+
82
+ const invoice = event.data.object;
83
+ return invoice.billing_reason === 'subscription_cycle';
84
+ }
85
+
86
+ /**
87
+ * Get subscription status display text
88
+ */
89
+ export function getSubscriptionStatusText(status) {
90
+ const statusMap = {
91
+ active: 'Active',
92
+ trialing: 'Trial',
93
+ past_due: 'Past Due',
94
+ canceled: 'Canceled',
95
+ unpaid: 'Unpaid',
96
+ incomplete: 'Incomplete',
97
+ incomplete_expired: 'Incomplete (Expired)',
98
+ paused: 'Paused',
99
+ };
100
+
101
+ return statusMap[status] || status;
102
+ }
103
+
104
+ /**
105
+ * Extract metadata from event
106
+ */
107
+ export function getMetadata(event) {
108
+ const obj = event.data.object;
109
+ return obj.metadata || {};
110
+ }
111
+
112
+ /**
113
+ * Check if event is a test event
114
+ */
115
+ export function isTestEvent(event) {
116
+ return event.livemode === false;
117
+ }
118
+
119
+ /**
120
+ * Get human-readable event description
121
+ */
122
+ export function getEventDescription(event) {
123
+ const descriptions = {
124
+ 'customer.subscription.created': 'New subscription started',
125
+ 'customer.subscription.updated': 'Subscription changed',
126
+ 'customer.subscription.deleted': 'Subscription canceled',
127
+ 'customer.subscription.trial_will_end': 'Trial ending soon',
128
+ 'invoice.paid': 'Payment received',
129
+ 'invoice.payment_failed': 'Payment failed',
130
+ 'invoice.upcoming': 'Upcoming payment',
131
+ 'payment_intent.succeeded': 'Payment succeeded',
132
+ 'payment_intent.payment_failed': 'Payment attempt failed',
133
+ 'checkout.session.completed': 'Checkout completed',
134
+ };
135
+
136
+ return descriptions[event.type] || event.type;
137
+ }
138
+
139
+ /**
140
+ * Calculate MRR (Monthly Recurring Revenue) from subscription
141
+ */
142
+ export function calculateMRR(subscription) {
143
+ const firstItem = subscription.items?.data?.[0];
144
+ if (!firstItem) return 0;
145
+
146
+ const price = firstItem.price;
147
+ const amount = price.unit_amount * (firstItem.quantity || 1);
148
+
149
+ // Convert to monthly amount
150
+ switch (price.recurring?.interval) {
151
+ case 'year':
152
+ return amount / 12;
153
+ case 'month':
154
+ return amount;
155
+ case 'week':
156
+ return amount * 4.33;
157
+ case 'day':
158
+ return amount * 30;
159
+ default:
160
+ return 0;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Get payment method type display text
166
+ */
167
+ export function getPaymentMethodType(paymentMethod) {
168
+ if (!paymentMethod) return 'Unknown';
169
+
170
+ const typeMap = {
171
+ card: 'Card',
172
+ bank_account: 'Bank Account',
173
+ sepa_debit: 'SEPA Direct Debit',
174
+ us_bank_account: 'US Bank Account',
175
+ paypal: 'PayPal',
176
+ };
177
+
178
+ return typeMap[paymentMethod.type] || paymentMethod.type;
179
+ }
180
+
181
+ /**
182
+ * Extract line items from invoice
183
+ */
184
+ export function getInvoiceLineItems(invoice) {
185
+ return invoice.lines?.data?.map(line => ({
186
+ description: line.description,
187
+ amount: line.amount,
188
+ quantity: line.quantity,
189
+ priceId: line.price?.id,
190
+ })) || [];
191
+ }
192
+
193
+ /**
194
+ * Check if invoice is for subscription vs one-time payment
195
+ */
196
+ export function isSubscriptionInvoice(invoice) {
197
+ return !!invoice.subscription;
198
+ }
199
+
200
+ /**
201
+ * Get next billing date from subscription
202
+ */
203
+ export function getNextBillingDate(subscription) {
204
+ if (!subscription.current_period_end) return null;
205
+ return new Date(subscription.current_period_end * 1000);
206
+ }
207
+
208
+ /**
209
+ * Format date from Unix timestamp
210
+ */
211
+ export function formatDate(unixTimestamp, locale = 'en-US') {
212
+ if (!unixTimestamp) return null;
213
+
214
+ const date = new Date(unixTimestamp * 1000);
215
+ return new Intl.DateTimeFormat(locale, {
216
+ year: 'numeric',
217
+ month: 'long',
218
+ day: 'numeric',
219
+ }).format(date);
220
+ }
@@ -0,0 +1,72 @@
1
+ // src/webhooks/webhooks.js
2
+ import { defaultHandlers } from "../handlers/defaultHandlers.js";
3
+
4
+ export async function setupWebhooks(fastify, options) {
5
+ const { stripe, webhookSecret, webhookPath, handlers } = options;
6
+
7
+ // Merge user handlers with default handlers
8
+ const eventHandlers = { ...defaultHandlers, ...handlers };
9
+
10
+ // Register webhook route
11
+ fastify.post(
12
+ webhookPath,
13
+ {
14
+ config: {
15
+ // Disable body parsing - we need raw body for signature verification
16
+ rawBody: true,
17
+ },
18
+ },
19
+ async (request, reply) => {
20
+ const sig = request.headers["stripe-signature"];
21
+
22
+ if (!sig) {
23
+ fastify.log.error("Missing stripe-signature header");
24
+ return reply.code(400).send({ error: "Missing stripe-signature header" });
25
+ }
26
+
27
+ let event;
28
+
29
+ try {
30
+ // Get raw body for signature verification
31
+ const rawBody = request.rawBody || request.body;
32
+
33
+ // Verify webhook signature
34
+ event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret);
35
+ } catch (err) {
36
+ fastify.log.error(`Webhook signature verification failed: ${err.message}`);
37
+ return reply.code(400).send({ error: `Webhook Error: ${err.message}` });
38
+ }
39
+
40
+ // Log the event
41
+ fastify.log.info(`Received Stripe webhook: ${event.type}`);
42
+
43
+ // Get handler for this event type
44
+ const handler = eventHandlers[event.type];
45
+
46
+ if (handler) {
47
+ try {
48
+ // Execute handler
49
+ await handler(event, fastify, stripe);
50
+ fastify.log.info(`Successfully processed ${event.type}`);
51
+ } catch (err) {
52
+ fastify.log.error(`Error processing ${event.type}: ${err.message}`);
53
+ // Return 200 to acknowledge receipt, even if processing failed
54
+ // This prevents Stripe from retrying immediately
55
+ return reply.code(200).send({
56
+ received: true,
57
+ processed: false,
58
+ error: err.message
59
+ });
60
+ }
61
+ } else {
62
+ fastify.log.warn(`No handler registered for event type: ${event.type}`);
63
+ }
64
+
65
+ // Always return 200 to acknowledge receipt
66
+ return reply.code(200).send({ received: true, processed: !!handler });
67
+ }
68
+ );
69
+
70
+ console.info(` ✅ Stripe Webhooks Enabled at ${webhookPath}`);
71
+ console.info(` 📋 Registered ${Object.keys(eventHandlers).length} event handlers`);
72
+ }
package/src/xStripe.js ADDED
@@ -0,0 +1,45 @@
1
+ // src/xStripe.js
2
+ import fp from "fastify-plugin";
3
+ import Stripe from "stripe";
4
+ import { setupWebhooks } from "./webhooks/webhooks.js";
5
+
6
+ async function xStripe(fastify, options) {
7
+ const {
8
+ apiKey,
9
+ webhookSecret,
10
+ webhookPath = "/stripe/webhook",
11
+ handlers = {},
12
+ apiVersion = "2024-11-20.acacia",
13
+ } = options;
14
+
15
+ // Validate required options
16
+ if (!apiKey) {
17
+ throw new Error("Stripe apiKey is required");
18
+ }
19
+
20
+ console.info("\n 💳 Starting xStripe...\n");
21
+
22
+ // Initialize Stripe client
23
+ const stripe = new Stripe(apiKey, { apiVersion });
24
+
25
+ // Decorate Fastify with Stripe client
26
+ fastify.decorate("stripe", stripe);
27
+
28
+ // Setup webhook handling if webhook secret is provided
29
+ if (webhookSecret) {
30
+ await setupWebhooks(fastify, {
31
+ stripe,
32
+ webhookSecret,
33
+ webhookPath,
34
+ handlers,
35
+ });
36
+ } else {
37
+ fastify.log.warn("⚠️ Stripe webhook secret not provided. Webhook handling disabled.");
38
+ }
39
+
40
+ console.info("\n 💳 xStripe Ready!\n");
41
+ }
42
+
43
+ export default fp(xStripe, {
44
+ name: "xStripe",
45
+ });