@dodopayments/opencode-plugin 0.2.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/.mcp.json +22 -0
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/opencode-plugin/README.md +42 -0
- package/opencode-plugin/index.js +30 -0
- package/opencode.json +28 -0
- package/package.json +53 -0
- package/skills/best-practices/SKILL.md +315 -0
- package/skills/billing-sdk/SKILL.md +501 -0
- package/skills/checkout-integration/SKILL.md +599 -0
- package/skills/credit-based-billing/SKILL.md +942 -0
- package/skills/license-keys/SKILL.md +591 -0
- package/skills/subscription-integration/SKILL.md +571 -0
- package/skills/usage-based-billing/SKILL.md +680 -0
- package/skills/webhook-integration/SKILL.md +529 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: subscription-integration
|
|
3
|
+
description: Guide for implementing subscription billing with Dodo Payments - trials, upgrades, downgrades, and on-demand billing.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Dodo Payments Subscription Integration
|
|
7
|
+
|
|
8
|
+
**Reference: [docs.dodopayments.com/developer-resources/subscription-integration-guide](https://docs.dodopayments.com/developer-resources/subscription-integration-guide)**
|
|
9
|
+
|
|
10
|
+
Implement recurring billing with trials, plan changes, and usage-based pricing.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
### 1. Create Subscription Product
|
|
17
|
+
In the dashboard (Products → Create Product):
|
|
18
|
+
- Select "Subscription" type
|
|
19
|
+
- Set billing interval (monthly, yearly, etc.)
|
|
20
|
+
- Configure pricing
|
|
21
|
+
|
|
22
|
+
### 2. Create Checkout Session
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import DodoPayments from 'dodopayments';
|
|
26
|
+
|
|
27
|
+
const client = new DodoPayments({
|
|
28
|
+
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const session = await client.checkoutSessions.create({
|
|
32
|
+
product_cart: [
|
|
33
|
+
{ product_id: 'prod_monthly_plan', quantity: 1 }
|
|
34
|
+
],
|
|
35
|
+
subscription_data: {
|
|
36
|
+
trial_period_days: 14, // Optional trial
|
|
37
|
+
},
|
|
38
|
+
customer: {
|
|
39
|
+
email: 'subscriber@example.com',
|
|
40
|
+
name: 'Jane Doe',
|
|
41
|
+
},
|
|
42
|
+
return_url: 'https://yoursite.com/success',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Redirect to session.checkout_url
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 3. Handle Webhook Events
|
|
49
|
+
```typescript
|
|
50
|
+
// subscription.active - Grant access
|
|
51
|
+
// subscription.cancelled - Schedule access revocation
|
|
52
|
+
// subscription.renewed - Log renewal
|
|
53
|
+
// payment.succeeded - Track payments
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Subscription Lifecycle
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
┌─────────────┐ ┌─────────┐ ┌────────┐
|
|
62
|
+
│ Created │ ──▶ │ Trial │ ──▶ │ Active │
|
|
63
|
+
└─────────────┘ └─────────┘ └────────┘
|
|
64
|
+
│
|
|
65
|
+
┌────────────────────┼────────────────────┐
|
|
66
|
+
▼ ▼ ▼
|
|
67
|
+
┌──────────┐ ┌───────────┐ ┌───────────┐
|
|
68
|
+
│ On Hold │ │ Cancelled │ │ Renewed │
|
|
69
|
+
└──────────┘ └───────────┘ └───────────┘
|
|
70
|
+
│ │
|
|
71
|
+
▼ ▼
|
|
72
|
+
┌──────────┐ ┌───────────┐
|
|
73
|
+
│ Failed │ │ Expired │
|
|
74
|
+
└──────────┘ └───────────┘
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Webhook Events
|
|
80
|
+
|
|
81
|
+
| Event | When | Action |
|
|
82
|
+
|-------|------|--------|
|
|
83
|
+
| `subscription.active` | Subscription starts | Grant access |
|
|
84
|
+
| `subscription.updated` | Any field changes | Sync state |
|
|
85
|
+
| `subscription.on_hold` | Payment fails | Notify user, retry |
|
|
86
|
+
| `subscription.renewed` | Successful renewal | Log, send receipt |
|
|
87
|
+
| `subscription.plan_changed` | Upgrade/downgrade | Update entitlements |
|
|
88
|
+
| `subscription.cancelled` | User cancels | Schedule end of access |
|
|
89
|
+
| `subscription.failed` | Mandate creation fails | Notify, retry options |
|
|
90
|
+
| `subscription.expired` | Term ends | Revoke access |
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Implementation Examples
|
|
95
|
+
|
|
96
|
+
### Full Subscription Handler
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
// app/api/webhooks/subscription/route.ts
|
|
100
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
101
|
+
import { prisma } from '@/lib/prisma';
|
|
102
|
+
|
|
103
|
+
export async function POST(req: NextRequest) {
|
|
104
|
+
const event = await req.json();
|
|
105
|
+
const data = event.data;
|
|
106
|
+
|
|
107
|
+
switch (event.type) {
|
|
108
|
+
case 'subscription.active':
|
|
109
|
+
await handleSubscriptionActive(data);
|
|
110
|
+
break;
|
|
111
|
+
case 'subscription.cancelled':
|
|
112
|
+
await handleSubscriptionCancelled(data);
|
|
113
|
+
break;
|
|
114
|
+
case 'subscription.on_hold':
|
|
115
|
+
await handleSubscriptionOnHold(data);
|
|
116
|
+
break;
|
|
117
|
+
case 'subscription.renewed':
|
|
118
|
+
await handleSubscriptionRenewed(data);
|
|
119
|
+
break;
|
|
120
|
+
case 'subscription.plan_changed':
|
|
121
|
+
await handlePlanChanged(data);
|
|
122
|
+
break;
|
|
123
|
+
case 'subscription.expired':
|
|
124
|
+
await handleSubscriptionExpired(data);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return NextResponse.json({ received: true });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function handleSubscriptionActive(data: any) {
|
|
132
|
+
const {
|
|
133
|
+
subscription_id,
|
|
134
|
+
customer,
|
|
135
|
+
product_id,
|
|
136
|
+
next_billing_date,
|
|
137
|
+
recurring_pre_tax_amount,
|
|
138
|
+
payment_frequency_interval,
|
|
139
|
+
} = data;
|
|
140
|
+
|
|
141
|
+
// Create or update user subscription
|
|
142
|
+
await prisma.subscription.upsert({
|
|
143
|
+
where: { externalId: subscription_id },
|
|
144
|
+
create: {
|
|
145
|
+
externalId: subscription_id,
|
|
146
|
+
userId: customer.customer_id,
|
|
147
|
+
email: customer.email,
|
|
148
|
+
productId: product_id,
|
|
149
|
+
status: 'active',
|
|
150
|
+
currentPeriodEnd: new Date(next_billing_date),
|
|
151
|
+
amount: recurring_pre_tax_amount,
|
|
152
|
+
interval: payment_frequency_interval,
|
|
153
|
+
},
|
|
154
|
+
update: {
|
|
155
|
+
status: 'active',
|
|
156
|
+
currentPeriodEnd: new Date(next_billing_date),
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Grant access
|
|
161
|
+
await prisma.user.update({
|
|
162
|
+
where: { id: customer.customer_id },
|
|
163
|
+
data: {
|
|
164
|
+
subscriptionStatus: 'active',
|
|
165
|
+
plan: product_id,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Send welcome email
|
|
170
|
+
await sendWelcomeEmail(customer.email, product_id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function handleSubscriptionCancelled(data: any) {
|
|
174
|
+
const { subscription_id, customer, cancelled_at, cancel_at_next_billing_date } = data;
|
|
175
|
+
|
|
176
|
+
await prisma.subscription.update({
|
|
177
|
+
where: { externalId: subscription_id },
|
|
178
|
+
data: {
|
|
179
|
+
status: 'cancelled',
|
|
180
|
+
cancelledAt: new Date(cancelled_at),
|
|
181
|
+
// Keep access until end of billing period if cancel_at_next_billing_date
|
|
182
|
+
accessEndsAt: cancel_at_next_billing_date
|
|
183
|
+
? new Date(data.next_billing_date)
|
|
184
|
+
: new Date(),
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Send cancellation email
|
|
189
|
+
await sendCancellationEmail(customer.email, cancel_at_next_billing_date);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function handleSubscriptionOnHold(data: any) {
|
|
193
|
+
const { subscription_id, customer } = data;
|
|
194
|
+
|
|
195
|
+
await prisma.subscription.update({
|
|
196
|
+
where: { externalId: subscription_id },
|
|
197
|
+
data: { status: 'on_hold' },
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Notify user about payment issue
|
|
201
|
+
await sendPaymentFailedEmail(customer.email);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function handleSubscriptionRenewed(data: any) {
|
|
205
|
+
const { subscription_id, next_billing_date } = data;
|
|
206
|
+
|
|
207
|
+
await prisma.subscription.update({
|
|
208
|
+
where: { externalId: subscription_id },
|
|
209
|
+
data: {
|
|
210
|
+
status: 'active',
|
|
211
|
+
currentPeriodEnd: new Date(next_billing_date),
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function handlePlanChanged(data: any) {
|
|
217
|
+
const { subscription_id, product_id, recurring_pre_tax_amount } = data;
|
|
218
|
+
|
|
219
|
+
await prisma.subscription.update({
|
|
220
|
+
where: { externalId: subscription_id },
|
|
221
|
+
data: {
|
|
222
|
+
productId: product_id,
|
|
223
|
+
amount: recurring_pre_tax_amount,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Update user entitlements based on new plan
|
|
228
|
+
await updateUserEntitlements(subscription_id, product_id);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function handleSubscriptionExpired(data: any) {
|
|
232
|
+
const { subscription_id, customer } = data;
|
|
233
|
+
|
|
234
|
+
await prisma.subscription.update({
|
|
235
|
+
where: { externalId: subscription_id },
|
|
236
|
+
data: { status: 'expired' },
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Revoke access
|
|
240
|
+
await prisma.user.update({
|
|
241
|
+
where: { id: customer.customer_id },
|
|
242
|
+
data: {
|
|
243
|
+
subscriptionStatus: 'expired',
|
|
244
|
+
plan: null,
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Subscription with Trial
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
const session = await client.checkoutSessions.create({
|
|
254
|
+
product_cart: [
|
|
255
|
+
{ product_id: 'prod_pro_monthly', quantity: 1 }
|
|
256
|
+
],
|
|
257
|
+
subscription_data: {
|
|
258
|
+
trial_period_days: 14,
|
|
259
|
+
},
|
|
260
|
+
customer: {
|
|
261
|
+
email: 'user@example.com',
|
|
262
|
+
name: 'John Doe',
|
|
263
|
+
},
|
|
264
|
+
return_url: 'https://yoursite.com/welcome',
|
|
265
|
+
});
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### Customer Portal for Self-Service
|
|
269
|
+
|
|
270
|
+
Allow customers to manage their subscription:
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
// Create portal session
|
|
274
|
+
const portal = await client.customers.createPortalSession({
|
|
275
|
+
customer_id: 'cust_xxxxx',
|
|
276
|
+
return_url: 'https://yoursite.com/account',
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Redirect to portal.url
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Portal features:
|
|
283
|
+
- View subscription details
|
|
284
|
+
- Update payment method
|
|
285
|
+
- Cancel subscription
|
|
286
|
+
- View billing history
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## On-Demand (Usage-Based) Subscriptions
|
|
291
|
+
|
|
292
|
+
For metered/usage-based billing:
|
|
293
|
+
|
|
294
|
+
### Create Subscription with Mandate
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
const session = await client.checkoutSessions.create({
|
|
298
|
+
product_cart: [
|
|
299
|
+
{ product_id: 'prod_usage_based', quantity: 1 }
|
|
300
|
+
],
|
|
301
|
+
customer: { email: 'user@example.com' },
|
|
302
|
+
return_url: 'https://yoursite.com/success',
|
|
303
|
+
});
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Charge for Usage
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
// When usage occurs, create a charge
|
|
310
|
+
const charge = await client.subscriptions.charge({
|
|
311
|
+
subscription_id: 'sub_xxxxx',
|
|
312
|
+
amount: 1500, // $15.00 in cents
|
|
313
|
+
description: 'API calls for January 2025',
|
|
314
|
+
});
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### Track Usage Events
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
// payment.succeeded - Charge succeeded
|
|
321
|
+
// payment.failed - Charge failed, implement retry logic
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## Subscriptions with Credit Entitlements
|
|
327
|
+
|
|
328
|
+
Attach credit entitlements to subscription products to grant credits each billing cycle:
|
|
329
|
+
|
|
330
|
+
### Setup
|
|
331
|
+
|
|
332
|
+
1. Create a credit entitlement (Dashboard → Products → Credits)
|
|
333
|
+
2. Create/edit a subscription product
|
|
334
|
+
3. In **Entitlements** section, click **Attach** next to Credits
|
|
335
|
+
4. Configure: credits per cycle, trial credits, proration, low balance threshold
|
|
336
|
+
|
|
337
|
+
### Checkout with Credits
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
// Product has credit entitlement attached (e.g., 10,000 AI tokens/month)
|
|
341
|
+
const session = await client.checkoutSessions.create({
|
|
342
|
+
product_cart: [
|
|
343
|
+
{ product_id: 'prod_pro_with_credits', quantity: 1 }
|
|
344
|
+
],
|
|
345
|
+
subscription_data: {
|
|
346
|
+
trial_period_days: 14, // Trial credits can differ from regular amount
|
|
347
|
+
},
|
|
348
|
+
customer: { email: 'user@example.com' },
|
|
349
|
+
return_url: 'https://yoursite.com/success',
|
|
350
|
+
});
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Credit Lifecycle per Cycle
|
|
354
|
+
|
|
355
|
+
Each billing cycle:
|
|
356
|
+
1. **New credits issued** — `credit.added` webhook fires
|
|
357
|
+
2. **Usage deducts credits** — Automatically via meters or manually via API
|
|
358
|
+
3. **Cycle ends** — Unused credits expire or roll over based on settings
|
|
359
|
+
4. **Overage handled** — Forgiven, billed, or carried as deficit
|
|
360
|
+
|
|
361
|
+
### Handle Credit Webhooks in Subscription Context
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
case 'credit.added':
|
|
365
|
+
// Credits issued with subscription renewal
|
|
366
|
+
await syncCreditBalance(data.customer_id, data.credit_entitlement_id, data.balance_after);
|
|
367
|
+
break;
|
|
368
|
+
case 'credit.balance_low':
|
|
369
|
+
// Notify customer or suggest upgrade
|
|
370
|
+
await sendLowBalanceAlert(data.customer_id, data.credit_entitlement_name, data.available_balance);
|
|
371
|
+
break;
|
|
372
|
+
case 'credit.deducted':
|
|
373
|
+
// Track consumption for analytics
|
|
374
|
+
await logCreditUsage(data.customer_id, data.amount);
|
|
375
|
+
break;
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### Plan Changes with Credits
|
|
379
|
+
|
|
380
|
+
When customers upgrade/downgrade, credit proration can be enabled:
|
|
381
|
+
- **Proration enabled**: Remaining credits are prorated based on time left in cycle
|
|
382
|
+
- **Proration disabled**: Credits continue as-is until next cycle
|
|
383
|
+
|
|
384
|
+
## Plan Changes
|
|
385
|
+
|
|
386
|
+
### Upgrade/Downgrade Flow
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
// Get available plans
|
|
390
|
+
const plans = await client.products.list({
|
|
391
|
+
type: 'subscription',
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// Change plan
|
|
395
|
+
await client.subscriptions.update({
|
|
396
|
+
subscription_id: 'sub_xxxxx',
|
|
397
|
+
product_id: 'prod_new_plan',
|
|
398
|
+
proration_behavior: 'create_prorations', // or 'none'
|
|
399
|
+
});
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Handling `subscription.plan_changed`
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
async function handlePlanChanged(data: any) {
|
|
406
|
+
const { subscription_id, product_id, customer } = data;
|
|
407
|
+
|
|
408
|
+
// Map product to features/limits
|
|
409
|
+
const planFeatures = getPlanFeatures(product_id);
|
|
410
|
+
|
|
411
|
+
await prisma.user.update({
|
|
412
|
+
where: { externalId: customer.customer_id },
|
|
413
|
+
data: {
|
|
414
|
+
plan: product_id,
|
|
415
|
+
features: planFeatures,
|
|
416
|
+
apiLimit: planFeatures.apiLimit,
|
|
417
|
+
storageLimit: planFeatures.storageLimit,
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## Access Control Pattern
|
|
426
|
+
|
|
427
|
+
### Middleware Example (Next.js)
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
// middleware.ts
|
|
431
|
+
import { NextResponse } from 'next/server';
|
|
432
|
+
import type { NextRequest } from 'next/server';
|
|
433
|
+
|
|
434
|
+
export async function middleware(request: NextRequest) {
|
|
435
|
+
// Check subscription status
|
|
436
|
+
const session = await getSession(request);
|
|
437
|
+
|
|
438
|
+
if (!session?.user) {
|
|
439
|
+
return NextResponse.redirect(new URL('/login', request.url));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const subscription = await getSubscription(session.user.id);
|
|
443
|
+
|
|
444
|
+
// Check if accessing premium feature
|
|
445
|
+
if (request.nextUrl.pathname.startsWith('/dashboard/pro')) {
|
|
446
|
+
if (!subscription || subscription.status !== 'active') {
|
|
447
|
+
return NextResponse.redirect(new URL('/pricing', request.url));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Check if plan includes this feature
|
|
451
|
+
if (!subscription.features.includes('pro')) {
|
|
452
|
+
return NextResponse.redirect(new URL('/upgrade', request.url));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return NextResponse.next();
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### React Hook for Subscription State
|
|
461
|
+
|
|
462
|
+
```typescript
|
|
463
|
+
// hooks/useSubscription.ts
|
|
464
|
+
import useSWR from 'swr';
|
|
465
|
+
|
|
466
|
+
export function useSubscription() {
|
|
467
|
+
const { data, error, mutate } = useSWR('/api/subscription', fetcher);
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
subscription: data,
|
|
471
|
+
isLoading: !error && !data,
|
|
472
|
+
isError: error,
|
|
473
|
+
isActive: data?.status === 'active',
|
|
474
|
+
isPro: data?.plan?.includes('pro'),
|
|
475
|
+
refresh: mutate,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Usage in component
|
|
480
|
+
function PremiumFeature() {
|
|
481
|
+
const { isActive, isPro } = useSubscription();
|
|
482
|
+
|
|
483
|
+
if (!isActive) {
|
|
484
|
+
return <UpgradePrompt />;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (!isPro) {
|
|
488
|
+
return <ProUpgradePrompt />;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return <ActualFeature />;
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
## Common Patterns
|
|
498
|
+
|
|
499
|
+
### Grace Period for Failed Payments
|
|
500
|
+
|
|
501
|
+
```typescript
|
|
502
|
+
async function handleSubscriptionOnHold(data: any) {
|
|
503
|
+
const gracePeriodDays = 7;
|
|
504
|
+
|
|
505
|
+
await prisma.subscription.update({
|
|
506
|
+
where: { externalId: data.subscription_id },
|
|
507
|
+
data: {
|
|
508
|
+
status: 'on_hold',
|
|
509
|
+
gracePeriodEnds: new Date(Date.now() + gracePeriodDays * 24 * 60 * 60 * 1000),
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Schedule job to revoke access after grace period
|
|
514
|
+
await scheduleAccessRevocation(data.subscription_id, gracePeriodDays);
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### Prorated Upgrades
|
|
519
|
+
|
|
520
|
+
When upgrading mid-cycle:
|
|
521
|
+
```typescript
|
|
522
|
+
// Dodo handles proration automatically
|
|
523
|
+
// Customer pays difference for remaining days
|
|
524
|
+
await client.subscriptions.update({
|
|
525
|
+
subscription_id: 'sub_xxxxx',
|
|
526
|
+
product_id: 'prod_higher_plan',
|
|
527
|
+
proration_behavior: 'create_prorations',
|
|
528
|
+
});
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
### Cancellation with End-of-Period Access
|
|
532
|
+
|
|
533
|
+
```typescript
|
|
534
|
+
// subscription.cancelled event includes:
|
|
535
|
+
// - cancel_at_next_billing_date: boolean
|
|
536
|
+
// - next_billing_date: string (when access should end)
|
|
537
|
+
|
|
538
|
+
if (data.cancel_at_next_billing_date) {
|
|
539
|
+
// Keep access until next_billing_date
|
|
540
|
+
await scheduleAccessRevocation(
|
|
541
|
+
data.subscription_id,
|
|
542
|
+
new Date(data.next_billing_date)
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
---
|
|
548
|
+
|
|
549
|
+
## Testing
|
|
550
|
+
|
|
551
|
+
### Test Scenarios
|
|
552
|
+
1. New subscription → `subscription.active`
|
|
553
|
+
2. Renewal success → `subscription.renewed` + `payment.succeeded`
|
|
554
|
+
3. Renewal failure → `subscription.on_hold` + `payment.failed`
|
|
555
|
+
4. Plan upgrade → `subscription.plan_changed`
|
|
556
|
+
5. Cancellation → `subscription.cancelled`
|
|
557
|
+
6. Expiration → `subscription.expired`
|
|
558
|
+
|
|
559
|
+
### Test in Dashboard
|
|
560
|
+
Use test mode and trigger events manually from the webhook settings.
|
|
561
|
+
|
|
562
|
+
---
|
|
563
|
+
|
|
564
|
+
## Resources
|
|
565
|
+
|
|
566
|
+
- [Subscription Guide](https://docs.dodopayments.com/developer-resources/subscription-integration-guide)
|
|
567
|
+
- [On-Demand Subscriptions](https://docs.dodopayments.com/developer-resources/ondemand-subscriptions)
|
|
568
|
+
- [Webhook Events](https://docs.dodopayments.com/developer-resources/webhooks/intents/subscription)
|
|
569
|
+
- [Customer Portal](https://docs.dodopayments.com/developer-resources/customer-portal)
|
|
570
|
+
- [Credit-Based Billing](https://docs.dodopayments.com/features/credit-based-billing)
|
|
571
|
+
- [Credit Webhook Events](https://docs.dodopayments.com/developer-resources/webhooks/intents/credit)
|