@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
package/EXAMPLES.md
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
# Event Handler Examples
|
|
2
|
+
|
|
3
|
+
Real-world examples for each Stripe webhook event type.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Subscription Events](#subscription-events)
|
|
8
|
+
- [Invoice Events](#invoice-events)
|
|
9
|
+
- [Payment Events](#payment-events)
|
|
10
|
+
- [Customer Events](#customer-events)
|
|
11
|
+
- [Payment Method Events](#payment-method-events)
|
|
12
|
+
- [Checkout Events](#checkout-events)
|
|
13
|
+
- [Complete Use Cases](#complete-use-cases)
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Subscription Events
|
|
18
|
+
|
|
19
|
+
### `customer.subscription.created`
|
|
20
|
+
|
|
21
|
+
**When it fires:** A new subscription is created (via Checkout or API).
|
|
22
|
+
|
|
23
|
+
**Common use cases:**
|
|
24
|
+
- Grant user access to premium features
|
|
25
|
+
- Send welcome email
|
|
26
|
+
- Start onboarding flow
|
|
27
|
+
- Track conversion metrics
|
|
28
|
+
|
|
29
|
+
```javascript
|
|
30
|
+
'customer.subscription.created': async (event, fastify, stripe) => {
|
|
31
|
+
const subscription = event.data.object;
|
|
32
|
+
const customer = await stripe.customers.retrieve(subscription.customer);
|
|
33
|
+
|
|
34
|
+
// Update database
|
|
35
|
+
await fastify.prisma.user.update({
|
|
36
|
+
where: { email: customer.email },
|
|
37
|
+
data: {
|
|
38
|
+
stripeCustomerId: subscription.customer,
|
|
39
|
+
stripeSubscriptionId: subscription.id,
|
|
40
|
+
subscriptionStatus: subscription.status,
|
|
41
|
+
planId: subscription.items.data[0]?.price.id,
|
|
42
|
+
trialEndsAt: subscription.trial_end
|
|
43
|
+
? new Date(subscription.trial_end * 1000)
|
|
44
|
+
: null,
|
|
45
|
+
subscriptionStartedAt: new Date(),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Send welcome email
|
|
50
|
+
await fastify.email.send(
|
|
51
|
+
customer.email,
|
|
52
|
+
'Welcome to Premium!',
|
|
53
|
+
`
|
|
54
|
+
<h1>Welcome aboard! 🎉</h1>
|
|
55
|
+
<p>Your subscription is now active.</p>
|
|
56
|
+
<p>Plan: ${subscription.items.data[0]?.price.nickname}</p>
|
|
57
|
+
`
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Send Slack notification
|
|
61
|
+
await fastify.slack.send(`New subscriber: ${customer.email}`);
|
|
62
|
+
|
|
63
|
+
fastify.log.info({
|
|
64
|
+
event: 'subscription_created',
|
|
65
|
+
customerId: subscription.customer,
|
|
66
|
+
plan: subscription.items.data[0]?.price.id,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
### `customer.subscription.updated`
|
|
74
|
+
|
|
75
|
+
**When it fires:** Subscription changes (status, plan, quantity, etc.).
|
|
76
|
+
|
|
77
|
+
**Common use cases:**
|
|
78
|
+
- Handle plan upgrades/downgrades
|
|
79
|
+
- Update feature access
|
|
80
|
+
- Track status changes
|
|
81
|
+
- Handle trial conversions
|
|
82
|
+
|
|
83
|
+
```javascript
|
|
84
|
+
'customer.subscription.updated': async (event, fastify, stripe) => {
|
|
85
|
+
const subscription = event.data.object;
|
|
86
|
+
const previous = event.data.previous_attributes || {};
|
|
87
|
+
|
|
88
|
+
// Check what changed
|
|
89
|
+
const statusChanged = 'status' in previous;
|
|
90
|
+
const planChanged = 'items' in previous;
|
|
91
|
+
|
|
92
|
+
// Update database
|
|
93
|
+
await fastify.prisma.user.update({
|
|
94
|
+
where: { stripeSubscriptionId: subscription.id },
|
|
95
|
+
data: {
|
|
96
|
+
subscriptionStatus: subscription.status,
|
|
97
|
+
planId: subscription.items.data[0]?.price.id,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Handle status changes
|
|
102
|
+
if (statusChanged) {
|
|
103
|
+
const oldStatus = previous.status;
|
|
104
|
+
const newStatus = subscription.status;
|
|
105
|
+
|
|
106
|
+
// Trial converted to paid
|
|
107
|
+
if (newStatus === 'active' && oldStatus === 'trialing') {
|
|
108
|
+
await fastify.email.send(
|
|
109
|
+
subscription.customer.email,
|
|
110
|
+
'Trial Converted!',
|
|
111
|
+
'<p>Your trial has successfully converted to a paid subscription.</p>'
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
fastify.log.info('Trial converted to paid', {
|
|
115
|
+
subscriptionId: subscription.id,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Subscription past due
|
|
120
|
+
if (newStatus === 'past_due') {
|
|
121
|
+
await fastify.email.send(
|
|
122
|
+
subscription.customer.email,
|
|
123
|
+
'Payment Issue',
|
|
124
|
+
'<p>We had trouble processing your payment. Please update your payment method.</p>'
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
await fastify.sms.send(
|
|
128
|
+
subscription.customer.phone,
|
|
129
|
+
'Payment failed for your subscription. Please update your card.'
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
fastify.log.warn('Subscription past due', {
|
|
133
|
+
subscriptionId: subscription.id,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Subscription canceled
|
|
138
|
+
if (newStatus === 'canceled') {
|
|
139
|
+
await fastify.prisma.user.update({
|
|
140
|
+
where: { stripeSubscriptionId: subscription.id },
|
|
141
|
+
data: { hasAccess: false },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
fastify.log.info('Subscription canceled', {
|
|
145
|
+
subscriptionId: subscription.id,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Handle plan changes (upgrade/downgrade)
|
|
151
|
+
if (planChanged) {
|
|
152
|
+
const oldPlan = previous.items?.data[0]?.price.id;
|
|
153
|
+
const newPlan = subscription.items.data[0]?.price.id;
|
|
154
|
+
|
|
155
|
+
await fastify.email.send(
|
|
156
|
+
subscription.customer.email,
|
|
157
|
+
'Plan Updated',
|
|
158
|
+
`<p>Your plan has been updated from ${oldPlan} to ${newPlan}.</p>`
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
fastify.log.info('Plan changed', {
|
|
162
|
+
subscriptionId: subscription.id,
|
|
163
|
+
oldPlan,
|
|
164
|
+
newPlan,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
### `customer.subscription.deleted`
|
|
173
|
+
|
|
174
|
+
**When it fires:** Subscription is canceled (immediately or at period end).
|
|
175
|
+
|
|
176
|
+
**Common use cases:**
|
|
177
|
+
- Revoke access to features
|
|
178
|
+
- Send cancellation feedback survey
|
|
179
|
+
- Trigger win-back campaign
|
|
180
|
+
- Clean up resources
|
|
181
|
+
|
|
182
|
+
```javascript
|
|
183
|
+
'customer.subscription.deleted': async (event, fastify, stripe) => {
|
|
184
|
+
const subscription = event.data.object;
|
|
185
|
+
const customer = await stripe.customers.retrieve(subscription.customer);
|
|
186
|
+
|
|
187
|
+
// Revoke access
|
|
188
|
+
await fastify.prisma.user.update({
|
|
189
|
+
where: { stripeSubscriptionId: subscription.id },
|
|
190
|
+
data: {
|
|
191
|
+
subscriptionStatus: 'canceled',
|
|
192
|
+
hasAccess: false,
|
|
193
|
+
canceledAt: new Date(),
|
|
194
|
+
subscriptionId: null,
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Send cancellation email with feedback survey
|
|
199
|
+
await fastify.email.send(
|
|
200
|
+
customer.email,
|
|
201
|
+
"We're sorry to see you go",
|
|
202
|
+
`
|
|
203
|
+
<h1>Subscription Canceled</h1>
|
|
204
|
+
<p>Your subscription has been canceled and will end on
|
|
205
|
+
${new Date(subscription.current_period_end * 1000).toLocaleDateString()}.</p>
|
|
206
|
+
|
|
207
|
+
<p>We'd love your feedback:</p>
|
|
208
|
+
<a href="https://example.com/feedback?user=${customer.id}">
|
|
209
|
+
Take our 2-minute survey
|
|
210
|
+
</a>
|
|
211
|
+
|
|
212
|
+
<p>You can reactivate anytime at https://example.com/billing</p>
|
|
213
|
+
`
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Schedule win-back email for 7 days later
|
|
217
|
+
await fastify.scheduleEmail({
|
|
218
|
+
to: customer.email,
|
|
219
|
+
subject: 'Special offer to return',
|
|
220
|
+
sendAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Notify team
|
|
224
|
+
await fastify.slack.send(
|
|
225
|
+
`Customer canceled: ${customer.email} - Plan: ${subscription.items.data[0]?.price.nickname}`
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
fastify.log.info({
|
|
229
|
+
event: 'subscription_canceled',
|
|
230
|
+
customerId: subscription.customer,
|
|
231
|
+
reason: subscription.cancellation_details?.reason,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
### `customer.subscription.trial_will_end`
|
|
239
|
+
|
|
240
|
+
**When it fires:** 3 days before trial ends.
|
|
241
|
+
|
|
242
|
+
**Common use cases:**
|
|
243
|
+
- Send trial ending reminder
|
|
244
|
+
- Encourage conversion
|
|
245
|
+
- Highlight value
|
|
246
|
+
|
|
247
|
+
```javascript
|
|
248
|
+
'customer.subscription.trial_will_end': async (event, fastify, stripe) => {
|
|
249
|
+
const subscription = event.data.object;
|
|
250
|
+
const customer = await stripe.customers.retrieve(subscription.customer);
|
|
251
|
+
const trialEnd = new Date(subscription.trial_end * 1000);
|
|
252
|
+
const daysRemaining = Math.ceil(
|
|
253
|
+
(subscription.trial_end * 1000 - Date.now()) / (1000 * 60 * 60 * 24)
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Send email reminder
|
|
257
|
+
await fastify.email.send(
|
|
258
|
+
customer.email,
|
|
259
|
+
`Your trial ends in ${daysRemaining} days`,
|
|
260
|
+
`
|
|
261
|
+
<h1>Your trial is ending soon!</h1>
|
|
262
|
+
<p>Your trial ends on ${trialEnd.toLocaleDateString()}.</p>
|
|
263
|
+
|
|
264
|
+
<h2>What happens next?</h2>
|
|
265
|
+
<p>Your card will be charged ${subscription.items.data[0]?.price.unit_amount / 100}
|
|
266
|
+
${subscription.currency.toUpperCase()} to continue your subscription.</p>
|
|
267
|
+
|
|
268
|
+
<p><a href="https://example.com/billing">Manage your subscription</a></p>
|
|
269
|
+
<p><a href="https://example.com/cancel">Cancel anytime</a></p>
|
|
270
|
+
`
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// Send SMS reminder
|
|
274
|
+
if (customer.phone) {
|
|
275
|
+
await fastify.sms.send(
|
|
276
|
+
customer.phone,
|
|
277
|
+
`Your trial ends in ${daysRemaining} days. Keep your access by doing nothing, or cancel anytime.`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Track conversion intent
|
|
282
|
+
await fastify.analytics.track(customer.id, 'trial_ending_soon', {
|
|
283
|
+
daysRemaining,
|
|
284
|
+
plan: subscription.items.data[0]?.price.id,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
fastify.log.info({
|
|
288
|
+
event: 'trial_ending_soon',
|
|
289
|
+
customerId: subscription.customer,
|
|
290
|
+
daysRemaining,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
### `customer.subscription.paused` / `customer.subscription.resumed`
|
|
298
|
+
|
|
299
|
+
**When it fires:** Subscription is paused or resumed.
|
|
300
|
+
|
|
301
|
+
**Common use cases:**
|
|
302
|
+
- Update user access
|
|
303
|
+
- Send confirmation
|
|
304
|
+
- Track churn prevention
|
|
305
|
+
|
|
306
|
+
```javascript
|
|
307
|
+
'customer.subscription.paused': async (event, fastify, stripe) => {
|
|
308
|
+
const subscription = event.data.object;
|
|
309
|
+
|
|
310
|
+
await fastify.prisma.user.update({
|
|
311
|
+
where: { stripeSubscriptionId: subscription.id },
|
|
312
|
+
data: {
|
|
313
|
+
subscriptionStatus: 'paused',
|
|
314
|
+
hasAccess: false,
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
fastify.log.info('Subscription paused', {
|
|
319
|
+
subscriptionId: subscription.id,
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
'customer.subscription.resumed': async (event, fastify, stripe) => {
|
|
324
|
+
const subscription = event.data.object;
|
|
325
|
+
|
|
326
|
+
await fastify.prisma.user.update({
|
|
327
|
+
where: { stripeSubscriptionId: subscription.id },
|
|
328
|
+
data: {
|
|
329
|
+
subscriptionStatus: 'active',
|
|
330
|
+
hasAccess: true,
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const customer = await stripe.customers.retrieve(subscription.customer);
|
|
335
|
+
await fastify.email.send(
|
|
336
|
+
customer.email,
|
|
337
|
+
'Subscription Resumed',
|
|
338
|
+
'<p>Welcome back! Your subscription has been resumed.</p>'
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
fastify.log.info('Subscription resumed', {
|
|
342
|
+
subscriptionId: subscription.id,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Invoice Events
|
|
350
|
+
|
|
351
|
+
### `invoice.paid`
|
|
352
|
+
|
|
353
|
+
**When it fires:** Invoice payment succeeds.
|
|
354
|
+
|
|
355
|
+
**Common use cases:**
|
|
356
|
+
- Send receipt
|
|
357
|
+
- Reset failed payment counters
|
|
358
|
+
- Grant/extend access
|
|
359
|
+
- Track revenue
|
|
360
|
+
|
|
361
|
+
```javascript
|
|
362
|
+
'invoice.paid': async (event, fastify, stripe) => {
|
|
363
|
+
const invoice = event.data.object;
|
|
364
|
+
const customer = await stripe.customers.retrieve(invoice.customer);
|
|
365
|
+
|
|
366
|
+
// Update database
|
|
367
|
+
await fastify.prisma.user.update({
|
|
368
|
+
where: { stripeCustomerId: invoice.customer },
|
|
369
|
+
data: {
|
|
370
|
+
failedPaymentCount: 0,
|
|
371
|
+
lastSuccessfulPayment: new Date(),
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// For subscription invoices
|
|
376
|
+
if (invoice.subscription) {
|
|
377
|
+
const isFirstPayment = invoice.billing_reason === 'subscription_create';
|
|
378
|
+
const isRenewal = invoice.billing_reason === 'subscription_cycle';
|
|
379
|
+
|
|
380
|
+
if (isRenewal) {
|
|
381
|
+
// Track renewal
|
|
382
|
+
await fastify.analytics.track(customer.id, 'subscription_renewed', {
|
|
383
|
+
amount: invoice.amount_paid / 100,
|
|
384
|
+
plan: invoice.lines.data[0]?.price?.id,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Send renewal confirmation
|
|
388
|
+
await fastify.email.send(
|
|
389
|
+
customer.email,
|
|
390
|
+
'Payment Received - Subscription Renewed',
|
|
391
|
+
`
|
|
392
|
+
<h1>Thank you for your payment!</h1>
|
|
393
|
+
<p>Amount: $${invoice.amount_paid / 100}</p>
|
|
394
|
+
<p>Your subscription has been renewed for another billing period.</p>
|
|
395
|
+
<a href="${invoice.hosted_invoice_url}">View Receipt</a>
|
|
396
|
+
`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Track revenue
|
|
402
|
+
await fastify.analytics.revenue({
|
|
403
|
+
customerId: customer.id,
|
|
404
|
+
amount: invoice.amount_paid / 100,
|
|
405
|
+
currency: invoice.currency,
|
|
406
|
+
invoiceId: invoice.id,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
fastify.log.info({
|
|
410
|
+
event: 'payment_successful',
|
|
411
|
+
customerId: invoice.customer,
|
|
412
|
+
amount: invoice.amount_paid / 100,
|
|
413
|
+
billingReason: invoice.billing_reason,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
### `invoice.payment_failed`
|
|
421
|
+
|
|
422
|
+
**When it fires:** Invoice payment fails.
|
|
423
|
+
|
|
424
|
+
**Common use cases:**
|
|
425
|
+
- Send payment failure notification
|
|
426
|
+
- Track failed payment attempts
|
|
427
|
+
- Suspend access after multiple failures
|
|
428
|
+
- Update payment method
|
|
429
|
+
|
|
430
|
+
```javascript
|
|
431
|
+
'invoice.payment_failed': async (event, fastify, stripe) => {
|
|
432
|
+
const invoice = event.data.object;
|
|
433
|
+
const customer = await stripe.customers.retrieve(invoice.customer);
|
|
434
|
+
|
|
435
|
+
// Track failed payment
|
|
436
|
+
const user = await fastify.prisma.user.update({
|
|
437
|
+
where: { stripeCustomerId: invoice.customer },
|
|
438
|
+
data: {
|
|
439
|
+
failedPaymentCount: { increment: 1 },
|
|
440
|
+
lastFailedPayment: new Date(),
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Send email notification
|
|
445
|
+
await fastify.email.send(
|
|
446
|
+
customer.email,
|
|
447
|
+
'Payment Failed - Action Required',
|
|
448
|
+
`
|
|
449
|
+
<h1>We couldn't process your payment</h1>
|
|
450
|
+
<p>Amount: $${invoice.amount_due / 100}</p>
|
|
451
|
+
<p>Attempt: ${invoice.attempt_count} of 4</p>
|
|
452
|
+
|
|
453
|
+
${invoice.next_payment_attempt
|
|
454
|
+
? `<p>We'll retry on ${new Date(invoice.next_payment_attempt * 1000).toLocaleDateString()}</p>`
|
|
455
|
+
: '<p>No more automatic retries.</p>'
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
<a href="https://example.com/billing/update">Update Payment Method</a>
|
|
459
|
+
`
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
// Send SMS for urgent notification
|
|
463
|
+
if (customer.phone) {
|
|
464
|
+
await fastify.sms.send(
|
|
465
|
+
customer.phone,
|
|
466
|
+
`Payment failed for your subscription. Update your payment method: https://example.com/billing`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Suspend access after 3 failed attempts
|
|
471
|
+
if (user.failedPaymentCount >= 3) {
|
|
472
|
+
await fastify.prisma.user.update({
|
|
473
|
+
where: { id: user.id },
|
|
474
|
+
data: {
|
|
475
|
+
hasAccess: false,
|
|
476
|
+
accountSuspended: true,
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
await fastify.email.send(
|
|
481
|
+
customer.email,
|
|
482
|
+
'Account Suspended',
|
|
483
|
+
'<p>Your account has been suspended due to multiple failed payments.</p>'
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
fastify.log.warn('Account suspended', {
|
|
487
|
+
customerId: invoice.customer,
|
|
488
|
+
failedAttempts: user.failedPaymentCount,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Notify team for high-value customers
|
|
493
|
+
if (invoice.amount_due > 10000) { // $100+
|
|
494
|
+
await fastify.slack.send(
|
|
495
|
+
`⚠️ Payment failed for high-value customer: ${customer.email} - $${invoice.amount_due / 100}`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
fastify.log.error({
|
|
500
|
+
event: 'payment_failed',
|
|
501
|
+
customerId: invoice.customer,
|
|
502
|
+
amount: invoice.amount_due / 100,
|
|
503
|
+
attemptCount: invoice.attempt_count,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
---
|
|
509
|
+
|
|
510
|
+
### `invoice.upcoming`
|
|
511
|
+
|
|
512
|
+
**When it fires:** 7 days before invoice is finalized.
|
|
513
|
+
|
|
514
|
+
**Common use cases:**
|
|
515
|
+
- Send billing reminder
|
|
516
|
+
- Allow payment method update
|
|
517
|
+
- Prevent surprise charges
|
|
518
|
+
|
|
519
|
+
```javascript
|
|
520
|
+
'invoice.upcoming': async (event, fastify, stripe) => {
|
|
521
|
+
const invoice = event.data.object;
|
|
522
|
+
const customer = await stripe.customers.retrieve(invoice.customer);
|
|
523
|
+
const billingDate = new Date(invoice.period_end * 1000);
|
|
524
|
+
const daysUntilBilling = Math.ceil(
|
|
525
|
+
(invoice.period_end * 1000 - Date.now()) / (1000 * 60 * 60 * 24)
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
// Send upcoming charge email
|
|
529
|
+
await fastify.email.send(
|
|
530
|
+
customer.email,
|
|
531
|
+
`Upcoming Charge: $${invoice.amount_due / 100}`,
|
|
532
|
+
`
|
|
533
|
+
<h1>Upcoming Charge Notification</h1>
|
|
534
|
+
<p>Your card will be charged <strong>$${invoice.amount_due / 100}</strong>
|
|
535
|
+
on ${billingDate.toLocaleDateString()} (in ${daysUntilBilling} days).</p>
|
|
536
|
+
|
|
537
|
+
<h2>Billing Details:</h2>
|
|
538
|
+
<ul>
|
|
539
|
+
${invoice.lines.data.map(line => `
|
|
540
|
+
<li>${line.description}: $${line.amount / 100}</li>
|
|
541
|
+
`).join('')}
|
|
542
|
+
</ul>
|
|
543
|
+
|
|
544
|
+
<p><a href="https://example.com/billing">Manage Billing</a></p>
|
|
545
|
+
<p><a href="https://example.com/billing/update">Update Payment Method</a></p>
|
|
546
|
+
`
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
// Check for usage-based pricing and notify if high
|
|
550
|
+
const usageLineItems = invoice.lines.data.filter(
|
|
551
|
+
line => line.price?.billing_scheme === 'tiered'
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
if (usageLineItems.length > 0) {
|
|
555
|
+
const usageAmount = usageLineItems.reduce((sum, item) => sum + item.amount, 0);
|
|
556
|
+
|
|
557
|
+
if (usageAmount > 5000) { // $50+ in usage
|
|
558
|
+
await fastify.email.send(
|
|
559
|
+
customer.email,
|
|
560
|
+
'High Usage Alert',
|
|
561
|
+
`<p>Your usage this period is higher than usual: $${usageAmount / 100}</p>`
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
fastify.log.info({
|
|
567
|
+
event: 'upcoming_invoice',
|
|
568
|
+
customerId: invoice.customer,
|
|
569
|
+
amount: invoice.amount_due / 100,
|
|
570
|
+
daysUntilBilling,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
## Payment Events
|
|
578
|
+
|
|
579
|
+
### `payment_intent.succeeded`
|
|
580
|
+
|
|
581
|
+
**When it fires:** One-time payment succeeds.
|
|
582
|
+
|
|
583
|
+
**Common use cases:**
|
|
584
|
+
- Fulfill order
|
|
585
|
+
- Send confirmation
|
|
586
|
+
- Grant access to purchased item
|
|
587
|
+
|
|
588
|
+
```javascript
|
|
589
|
+
'payment_intent.succeeded': async (event, fastify, stripe) => {
|
|
590
|
+
const paymentIntent = event.data.object;
|
|
591
|
+
const metadata = paymentIntent.metadata;
|
|
592
|
+
|
|
593
|
+
// Different handling based on what was purchased
|
|
594
|
+
if (metadata.type === 'course_purchase') {
|
|
595
|
+
await fastify.prisma.enrollment.create({
|
|
596
|
+
data: {
|
|
597
|
+
userId: metadata.userId,
|
|
598
|
+
courseId: metadata.courseId,
|
|
599
|
+
paymentIntentId: paymentIntent.id,
|
|
600
|
+
amount: paymentIntent.amount / 100,
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const customer = await stripe.customers.retrieve(paymentIntent.customer);
|
|
605
|
+
await fastify.email.send(
|
|
606
|
+
customer.email,
|
|
607
|
+
'Course Access Granted!',
|
|
608
|
+
`
|
|
609
|
+
<h1>Welcome to the course!</h1>
|
|
610
|
+
<p>Your payment of $${paymentIntent.amount / 100} has been received.</p>
|
|
611
|
+
<a href="https://example.com/courses/${metadata.courseId}">Start Learning</a>
|
|
612
|
+
`
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (metadata.type === 'credit_purchase') {
|
|
617
|
+
await fastify.prisma.user.update({
|
|
618
|
+
where: { id: metadata.userId },
|
|
619
|
+
data: {
|
|
620
|
+
credits: { increment: parseInt(metadata.credits) },
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
fastify.log.info({
|
|
626
|
+
event: 'payment_succeeded',
|
|
627
|
+
paymentIntentId: paymentIntent.id,
|
|
628
|
+
amount: paymentIntent.amount / 100,
|
|
629
|
+
type: metadata.type,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
---
|
|
635
|
+
|
|
636
|
+
## Customer Events
|
|
637
|
+
|
|
638
|
+
### `customer.created`
|
|
639
|
+
|
|
640
|
+
**When it fires:** New customer created in Stripe.
|
|
641
|
+
|
|
642
|
+
**Common use cases:**
|
|
643
|
+
- Sync customer to CRM
|
|
644
|
+
- Create user record
|
|
645
|
+
- Send welcome message
|
|
646
|
+
|
|
647
|
+
```javascript
|
|
648
|
+
'customer.created': async (event, fastify, stripe) => {
|
|
649
|
+
const customer = event.data.object;
|
|
650
|
+
|
|
651
|
+
// Create or update user in database
|
|
652
|
+
await fastify.prisma.user.upsert({
|
|
653
|
+
where: { email: customer.email },
|
|
654
|
+
update: {
|
|
655
|
+
stripeCustomerId: customer.id,
|
|
656
|
+
},
|
|
657
|
+
create: {
|
|
658
|
+
email: customer.email,
|
|
659
|
+
name: customer.name,
|
|
660
|
+
stripeCustomerId: customer.id,
|
|
661
|
+
},
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// Sync to CRM
|
|
665
|
+
await fastify.crm.createContact({
|
|
666
|
+
email: customer.email,
|
|
667
|
+
name: customer.name,
|
|
668
|
+
stripeId: customer.id,
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
fastify.log.info({
|
|
672
|
+
event: 'customer_created',
|
|
673
|
+
customerId: customer.id,
|
|
674
|
+
email: customer.email,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
---
|
|
680
|
+
|
|
681
|
+
## Checkout Events
|
|
682
|
+
|
|
683
|
+
### `checkout.session.completed`
|
|
684
|
+
|
|
685
|
+
**When it fires:** Checkout session completes successfully.
|
|
686
|
+
|
|
687
|
+
**Common use cases:**
|
|
688
|
+
- Provision access
|
|
689
|
+
- Send welcome email
|
|
690
|
+
- Track conversion
|
|
691
|
+
|
|
692
|
+
```javascript
|
|
693
|
+
'checkout.session.completed': async (event, fastify, stripe) => {
|
|
694
|
+
const session = event.data.object;
|
|
695
|
+
|
|
696
|
+
if (session.mode === 'subscription') {
|
|
697
|
+
// Subscription checkout
|
|
698
|
+
const subscription = await stripe.subscriptions.retrieve(session.subscription);
|
|
699
|
+
|
|
700
|
+
await fastify.prisma.user.update({
|
|
701
|
+
where: { email: session.customer_details.email },
|
|
702
|
+
data: {
|
|
703
|
+
stripeCustomerId: session.customer,
|
|
704
|
+
stripeSubscriptionId: session.subscription,
|
|
705
|
+
subscriptionStatus: subscription.status,
|
|
706
|
+
hasAccess: true,
|
|
707
|
+
onboardedAt: new Date(),
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// Send welcome email
|
|
712
|
+
await fastify.email.send(
|
|
713
|
+
session.customer_details.email,
|
|
714
|
+
'Welcome to Premium!',
|
|
715
|
+
`
|
|
716
|
+
<h1>Welcome aboard! 🎉</h1>
|
|
717
|
+
<p>Your subscription is now active.</p>
|
|
718
|
+
<a href="https://example.com/dashboard">Get Started</a>
|
|
719
|
+
`
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
// Track conversion
|
|
723
|
+
await fastify.analytics.track(session.customer, 'subscription_started', {
|
|
724
|
+
plan: subscription.items.data[0]?.price.id,
|
|
725
|
+
amount: subscription.items.data[0]?.price.unit_amount / 100,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (session.mode === 'payment') {
|
|
730
|
+
// One-time payment
|
|
731
|
+
const paymentIntent = await stripe.paymentIntents.retrieve(session.payment_intent);
|
|
732
|
+
|
|
733
|
+
// Handle based on metadata
|
|
734
|
+
// ... (similar to payment_intent.succeeded)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
fastify.log.info({
|
|
738
|
+
event: 'checkout_completed',
|
|
739
|
+
sessionId: session.id,
|
|
740
|
+
customerId: session.customer,
|
|
741
|
+
mode: session.mode,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
---
|
|
747
|
+
|
|
748
|
+
## Complete Use Cases
|
|
749
|
+
|
|
750
|
+
### SaaS Subscription Flow
|
|
751
|
+
|
|
752
|
+
```javascript
|
|
753
|
+
const saasHandlers = {
|
|
754
|
+
// 1. User subscribes
|
|
755
|
+
'checkout.session.completed': async (event, fastify, stripe) => {
|
|
756
|
+
const session = event.data.object;
|
|
757
|
+
|
|
758
|
+
await fastify.prisma.user.update({
|
|
759
|
+
where: { email: session.customer_details.email },
|
|
760
|
+
data: {
|
|
761
|
+
stripeCustomerId: session.customer,
|
|
762
|
+
stripeSubscriptionId: session.subscription,
|
|
763
|
+
plan: session.metadata.plan,
|
|
764
|
+
hasAccess: true,
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
await fastify.email.sendTemplate(
|
|
769
|
+
session.customer_details.email,
|
|
770
|
+
'Welcome Email',
|
|
771
|
+
'd-welcome123',
|
|
772
|
+
{ name: session.customer_details.name }
|
|
773
|
+
);
|
|
774
|
+
},
|
|
775
|
+
|
|
776
|
+
// 2. Monthly renewal
|
|
777
|
+
'invoice.paid': async (event, fastify, stripe) => {
|
|
778
|
+
if (event.data.object.billing_reason === 'subscription_cycle') {
|
|
779
|
+
await fastify.analytics.track(event.data.object.customer, 'subscription_renewed');
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
|
|
783
|
+
// 3. Payment fails
|
|
784
|
+
'invoice.payment_failed': async (event, fastify, stripe) => {
|
|
785
|
+
const invoice = event.data.object;
|
|
786
|
+
|
|
787
|
+
if (invoice.attempt_count >= 3) {
|
|
788
|
+
await fastify.prisma.user.update({
|
|
789
|
+
where: { stripeCustomerId: invoice.customer },
|
|
790
|
+
data: { hasAccess: false },
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
|
|
795
|
+
// 4. User cancels
|
|
796
|
+
'customer.subscription.deleted': async (event, fastify, stripe) => {
|
|
797
|
+
await fastify.prisma.user.update({
|
|
798
|
+
where: { stripeSubscriptionId: event.data.object.id },
|
|
799
|
+
data: {
|
|
800
|
+
hasAccess: false,
|
|
801
|
+
canceledAt: new Date(),
|
|
802
|
+
},
|
|
803
|
+
});
|
|
804
|
+
},
|
|
805
|
+
};
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
### Course Platform
|
|
809
|
+
|
|
810
|
+
```javascript
|
|
811
|
+
const courseHandlers = {
|
|
812
|
+
'payment_intent.succeeded': async (event, fastify, stripe) => {
|
|
813
|
+
const pi = event.data.object;
|
|
814
|
+
const { courseId, userId } = pi.metadata;
|
|
815
|
+
|
|
816
|
+
// Grant access
|
|
817
|
+
await fastify.prisma.enrollment.create({
|
|
818
|
+
data: {
|
|
819
|
+
userId,
|
|
820
|
+
courseId,
|
|
821
|
+
enrolledAt: new Date(),
|
|
822
|
+
},
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
// Send course materials
|
|
826
|
+
const user = await fastify.prisma.user.findUnique({ where: { id: userId } });
|
|
827
|
+
const course = await fastify.prisma.course.findUnique({ where: { id: courseId } });
|
|
828
|
+
|
|
829
|
+
await fastify.email.send(
|
|
830
|
+
user.email,
|
|
831
|
+
`Welcome to ${course.title}!`,
|
|
832
|
+
`<a href="https://example.com/courses/${courseId}/lesson/1">Start Lesson 1</a>`
|
|
833
|
+
);
|
|
834
|
+
},
|
|
835
|
+
};
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
### Usage-Based Billing
|
|
839
|
+
|
|
840
|
+
```javascript
|
|
841
|
+
const usageHandlers = {
|
|
842
|
+
'invoice.upcoming': async (event, fastify, stripe) => {
|
|
843
|
+
const invoice = event.data.object;
|
|
844
|
+
|
|
845
|
+
// Alert customer about high usage
|
|
846
|
+
const usageItems = invoice.lines.data.filter(
|
|
847
|
+
line => line.type === 'subscription' && line.proration === false
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
const totalUsage = usageItems.reduce((sum, item) => sum + item.amount, 0);
|
|
851
|
+
|
|
852
|
+
if (totalUsage > 10000) {
|
|
853
|
+
const customer = await stripe.customers.retrieve(invoice.customer);
|
|
854
|
+
|
|
855
|
+
await fastify.email.send(
|
|
856
|
+
customer.email,
|
|
857
|
+
'High Usage Alert',
|
|
858
|
+
`Your usage this month is $${totalUsage / 100}. Review your usage to avoid surprises.`
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
};
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
---
|
|
866
|
+
|
|
867
|
+
## Import and Use Examples
|
|
868
|
+
|
|
869
|
+
```javascript
|
|
870
|
+
// In your server setup
|
|
871
|
+
import xStripe from '@xenterprises/fastify-xstripe';
|
|
872
|
+
import { formatAmount, getPlanName } from '@xenterprises/fastify-xstripe/helpers';
|
|
873
|
+
|
|
874
|
+
await fastify.register(xStripe, {
|
|
875
|
+
apiKey: process.env.STRIPE_API_KEY,
|
|
876
|
+
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
|
|
877
|
+
handlers: {
|
|
878
|
+
// Mix and match from examples above
|
|
879
|
+
...saasHandlers,
|
|
880
|
+
...customHandlers,
|
|
881
|
+
},
|
|
882
|
+
});
|
|
883
|
+
```
|