@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/.dockerignore +62 -0
- package/.env.example +116 -0
- package/API.md +574 -0
- package/CHANGELOG.md +96 -0
- package/EXAMPLES.md +883 -0
- package/LICENSE +15 -0
- package/MIGRATION.md +374 -0
- package/QUICK_START.md +179 -0
- package/README.md +331 -0
- package/SECURITY.md +465 -0
- package/TESTING.md +357 -0
- package/index.d.ts +309 -0
- package/package.json +53 -0
- package/server/app.js +557 -0
- package/src/handlers/defaultHandlers.js +355 -0
- package/src/handlers/exampleHandlers.js +278 -0
- package/src/handlers/index.js +8 -0
- package/src/index.js +10 -0
- package/src/utils/helpers.js +220 -0
- package/src/webhooks/webhooks.js +72 -0
- package/src/xStripe.js +45 -0
- package/test/handlers.test.js +959 -0
- package/test/xStripe.integration.test.js +409 -0
|
@@ -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
|
+
});
|