@dodopayments/opencode-plugin 0.5.1 → 0.6.1
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 +4 -14
- package/README.md +34 -19
- package/mcp.json +13 -0
- package/opencode-plugin/README.md +3 -3
- package/opencode-plugin/index.js +9 -7
- package/package.json +3 -2
- package/skills/better-auth-integration/SKILL.md +12 -3
- package/skills/billing-sdk/SKILL.md +66 -33
- package/skills/checkout-integration/SKILL.md +26 -20
- package/skills/credit-based-billing/SKILL.md +7 -8
- package/skills/customer-management/SKILL.md +25 -17
- package/skills/discounts-and-promotions/SKILL.md +20 -17
- package/skills/dodo-best-practices/SKILL.md +18 -5
- package/skills/dodo-mcp-usage/SKILL.md +152 -0
- package/skills/framework-adapters/SKILL.md +167 -35
- package/skills/license-keys/SKILL.md +142 -97
- package/skills/localized-pricing/SKILL.md +51 -29
- package/skills/mobile-checkout/SKILL.md +168 -118
- package/skills/product-catalog-management/SKILL.md +39 -16
- package/skills/refunds-and-disputes/SKILL.md +91 -71
- package/skills/subscription-integration/SKILL.md +62 -9
- package/skills/testing-and-go-live/SKILL.md +22 -15
- package/skills/usage-based-billing/SKILL.md +18 -15
- package/skills/webhook-integration/SKILL.md +27 -10
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: refunds-and-disputes
|
|
3
|
-
description:
|
|
3
|
+
description: Dodo Payments refunds and disputes, covering full and partial refunds, refund statuses, dispute and chargeback lifecycle events, evidence, and revoking or restoring customer access. Use when issuing a refund, handling refund or dispute webhooks, responding to a chargeback, or reconciling entitlements after a payment reversal.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Refunds and Disputes
|
|
@@ -23,7 +23,7 @@ This skill covers issuing refunds (full and partial), handling the dispute lifec
|
|
|
23
23
|
|
|
24
24
|
**Amounts** are always in the smallest currency unit (cents for USD, paise for INR, etc.).
|
|
25
25
|
|
|
26
|
-
**Access revocation** means removing the customer's ability to use the product or service. On a dispute, you typically revoke access while it's open. Restore it
|
|
26
|
+
**Access revocation** means removing the customer's ability to use the product or service. On a dispute, you typically revoke access while it's open. Restore it on `dispute.won` or `dispute.cancelled` (withdrawn, no further action needed); the losing outcomes (`dispute.accepted`, `dispute.lost`, `dispute.expired`) keep access revoked.
|
|
27
27
|
|
|
28
28
|
## Refunds
|
|
29
29
|
|
|
@@ -54,7 +54,7 @@ To refund only specific items from a payment, pass the `items` array with each i
|
|
|
54
54
|
const partialRefund = await client.refunds.create({
|
|
55
55
|
payment_id: 'pay_abc123',
|
|
56
56
|
items: [
|
|
57
|
-
{ item_id: 'item_1', amount:
|
|
57
|
+
{ item_id: 'item_1' }, // no amount: the whole item is refunded
|
|
58
58
|
{ item_id: 'item_2', amount: 2500 },
|
|
59
59
|
],
|
|
60
60
|
});
|
|
@@ -104,34 +104,63 @@ const client = new DodoPayments({
|
|
|
104
104
|
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
type WebhookEvent = ReturnType<typeof client.webhooks.unwrap>;
|
|
108
|
+
|
|
109
|
+
// Register ONE endpoint for every Dodo event. A second app.post('/webhook')
|
|
110
|
+
// would never run, because the first handler answers every request.
|
|
107
111
|
app.post('/webhook', async (req, res) => {
|
|
112
|
+
let event: WebhookEvent;
|
|
108
113
|
try {
|
|
109
|
-
|
|
114
|
+
event = client.webhooks.unwrap(req.body.toString(), {
|
|
110
115
|
headers: {
|
|
111
116
|
'webhook-id': req.headers['webhook-id'] as string,
|
|
112
117
|
'webhook-signature': req.headers['webhook-signature'] as string,
|
|
113
118
|
'webhook-timestamp': req.headers['webhook-timestamp'] as string,
|
|
114
119
|
},
|
|
115
120
|
});
|
|
121
|
+
} catch {
|
|
122
|
+
// Only signature verification failures reach this branch
|
|
123
|
+
res.status(401).json({ error: 'Invalid signature' });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
116
126
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
await revokeCustomerAccess(refund.customer.customer_id);
|
|
121
|
-
await updateRefundRecord(refund.refund_id, 'succeeded');
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (event.type === 'refund.failed') {
|
|
125
|
-
const refund = event.data;
|
|
126
|
-
// Refund failed; keep access active, alert support
|
|
127
|
-
await logRefundFailure(refund.refund_id, refund.reason);
|
|
128
|
-
}
|
|
129
|
-
|
|
127
|
+
try {
|
|
128
|
+
await handleRefundEvent(event);
|
|
129
|
+
await handleDisputeEvent(event); // defined under "Handle dispute webhooks"
|
|
130
130
|
res.json({ received: true });
|
|
131
131
|
} catch (error) {
|
|
132
|
-
|
|
132
|
+
// Any non-2xx response makes Dodo retry the delivery
|
|
133
|
+
console.error('Webhook handler failed:', error);
|
|
134
|
+
res.status(500).json({ error: 'Webhook handler failed' });
|
|
133
135
|
}
|
|
134
136
|
});
|
|
137
|
+
|
|
138
|
+
async function handleRefundEvent(event: WebhookEvent) {
|
|
139
|
+
if (event.type === 'refund.succeeded') {
|
|
140
|
+
const refund = event.data;
|
|
141
|
+
if (!refund.is_partial) {
|
|
142
|
+
// Full refund: nothing in the payment is still paid for
|
|
143
|
+
await revokeCustomerAccess(refund.customer.customer_id);
|
|
144
|
+
} else {
|
|
145
|
+
// Partial refund: keep access to items that are still paid for, and
|
|
146
|
+
// remove it for any item with nothing left to refund. Reading the line
|
|
147
|
+
// items covers every case - an omitted amount, an explicit amount equal
|
|
148
|
+
// to the item price, or several partial refunds that add up to it.
|
|
149
|
+
const { items } = await client.payments.retrieveLineItems(refund.payment_id);
|
|
150
|
+
const fullyRefundedItemIds = items
|
|
151
|
+
.filter((item) => item.refundable_amount === 0)
|
|
152
|
+
.map((item) => item.items_id);
|
|
153
|
+
await revokeItemAccess(refund.customer.customer_id, fullyRefundedItemIds);
|
|
154
|
+
}
|
|
155
|
+
await updateRefundRecord(refund.refund_id, 'succeeded');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (event.type === 'refund.failed') {
|
|
159
|
+
const refund = event.data;
|
|
160
|
+
// Refund failed; keep access active, alert support
|
|
161
|
+
await logRefundFailure(refund.refund_id, refund.reason);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
135
164
|
```
|
|
136
165
|
|
|
137
166
|
## Disputes
|
|
@@ -159,7 +188,7 @@ A dispute moves through seven events. Each event requires a different action fro
|
|
|
159
188
|
| `dispute.opened` | Customer initiated a chargeback | Record the dispute; consider revoking access immediately; gather evidence from your logs |
|
|
160
189
|
| `dispute.challenged` | You submitted evidence | Wait for the card network to review |
|
|
161
190
|
| `dispute.accepted` | You accepted (conceded) the dispute; funds go to the cardholder | Keep access revoked; mark the dispute as accepted in your records |
|
|
162
|
-
| `dispute.cancelled` |
|
|
191
|
+
| `dispute.cancelled` | The dispute was withdrawn or cancelled | No further action needed; restore access you revoked on `dispute.opened` |
|
|
163
192
|
| `dispute.expired` | Dispute window closed without resolution | Treat as lost; keep access revoked |
|
|
164
193
|
| `dispute.won` | You won the dispute | Funds are retained; restore access; update customer records |
|
|
165
194
|
| `dispute.lost` | You lost the dispute | Funds returned to cardholder; keep access revoked; reconcile your records |
|
|
@@ -174,66 +203,56 @@ async function resolveDisputeCustomerId(disputeId: string) {
|
|
|
174
203
|
return dispute.customer.customer_id;
|
|
175
204
|
}
|
|
176
205
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const dispute = event.data;
|
|
189
|
-
// Record the dispute and revoke access
|
|
190
|
-
await recordDispute(dispute.dispute_id, dispute.payment_id, dispute.amount);
|
|
191
|
-
const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
|
|
192
|
-
await revokeCustomerAccess(customerId);
|
|
193
|
-
// Gather evidence from your system and submit via dashboard
|
|
194
|
-
// (no evidence-submission API exists; use the Dodo dashboard)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (event.type === 'dispute.won') {
|
|
198
|
-
const dispute = event.data;
|
|
199
|
-
// Funds retained; restore normal state
|
|
200
|
-
await markDisputeResolved(dispute.dispute_id, 'won');
|
|
201
|
-
const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
|
|
202
|
-
await restoreCustomerAccess(customerId);
|
|
203
|
-
}
|
|
206
|
+
// Called from the single /webhook handler above, after signature verification
|
|
207
|
+
async function handleDisputeEvent(event: WebhookEvent) {
|
|
208
|
+
if (event.type === 'dispute.opened') {
|
|
209
|
+
const dispute = event.data;
|
|
210
|
+
// Record the dispute and revoke access
|
|
211
|
+
await recordDispute(dispute.dispute_id, dispute.payment_id, dispute.amount);
|
|
212
|
+
const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
|
|
213
|
+
await revokeCustomerAccess(customerId);
|
|
214
|
+
// Gather evidence from your system and submit via dashboard
|
|
215
|
+
// (no evidence-submission API exists; use the Dodo dashboard)
|
|
216
|
+
}
|
|
204
217
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
218
|
+
if (event.type === 'dispute.won') {
|
|
219
|
+
const dispute = event.data;
|
|
220
|
+
// Funds retained; restore normal state
|
|
221
|
+
await markDisputeResolved(dispute.dispute_id, 'won');
|
|
222
|
+
const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
|
|
223
|
+
await restoreCustomerAccess(customerId);
|
|
224
|
+
}
|
|
211
225
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
226
|
+
if (event.type === 'dispute.lost') {
|
|
227
|
+
const dispute = event.data;
|
|
228
|
+
// Funds returned to cardholder; keep access revoked
|
|
229
|
+
await markDisputeResolved(dispute.dispute_id, 'lost');
|
|
230
|
+
// Do NOT restore access
|
|
231
|
+
}
|
|
218
232
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
233
|
+
if (event.type === 'dispute.accepted') {
|
|
234
|
+
const dispute = event.data;
|
|
235
|
+
// Merchant conceded; funds go to the cardholder and access stays revoked
|
|
236
|
+
await markDisputeResolved(dispute.dispute_id, 'accepted');
|
|
237
|
+
// Do NOT restore access
|
|
238
|
+
}
|
|
224
239
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
240
|
+
if (event.type === 'dispute.cancelled') {
|
|
241
|
+
const dispute = event.data;
|
|
242
|
+
// The dispute was withdrawn; no further action is needed, so undo the
|
|
243
|
+
// revocation from dispute.opened
|
|
244
|
+
await markDisputeResolved(dispute.dispute_id, 'cancelled');
|
|
245
|
+
const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
|
|
246
|
+
await restoreCustomerAccess(customerId);
|
|
228
247
|
}
|
|
229
|
-
}
|
|
248
|
+
}
|
|
230
249
|
```
|
|
231
250
|
|
|
232
251
|
### Evidence submission
|
|
233
252
|
|
|
234
253
|
Dodo handles the card-network dispute process as Merchant of Record. You submit evidence through the Dodo dashboard, not via API. No evidence-submission API exists.
|
|
235
254
|
|
|
236
|
-
When a dispute opens, gather your evidence (order confirmation, delivery proof, customer communication, etc.) and upload it to the dashboard within the dispute window (
|
|
255
|
+
When a dispute opens, gather your evidence (order confirmation, delivery proof, customer communication, etc.) and upload it to the dashboard within the dispute window (10 days from when the dispute is created). The card network reviews your evidence and makes a final decision.
|
|
237
256
|
|
|
238
257
|
## Access revocation pattern
|
|
239
258
|
|
|
@@ -266,7 +285,8 @@ async function handleDisputeLifecycle(
|
|
|
266
285
|
break;
|
|
267
286
|
|
|
268
287
|
case 'dispute_cancelled':
|
|
269
|
-
//
|
|
288
|
+
// Withdrawn; no further action needed, so restore access
|
|
289
|
+
await restoreCustomerAccess(customerId);
|
|
270
290
|
break;
|
|
271
291
|
|
|
272
292
|
case 'dispute_expired':
|
|
@@ -319,9 +339,9 @@ async function reconcileRefund(refund) {
|
|
|
319
339
|
|
|
320
340
|
**Ignoring partial refunds when computing entitlements.** If a customer refunds only one item from a multi-item purchase, their entitlement to the other items remains valid. Track refunds by item, not just by payment.
|
|
321
341
|
|
|
322
|
-
**Restoring access on
|
|
342
|
+
**Restoring access on a losing outcome.** `dispute.accepted`, `dispute.lost`, and `dispute.expired` resolve (or typically resolve) in the cardholder's favor; keep access revoked. Restore access only on `dispute.won` or `dispute.cancelled` (the dispute was withdrawn and needs no further action).
|
|
323
343
|
|
|
324
|
-
**Submitting evidence after the dispute window closes.**
|
|
344
|
+
**Submitting evidence after the dispute window closes.** You have 10 days from when the dispute is created to respond. Set a calendar reminder and gather evidence immediately when a dispute opens.
|
|
325
345
|
|
|
326
346
|
**Assuming Dodo will handle access revocation.** Dodo handles the card-network process; you handle application access. Dodo won't revoke your customer's subscription or file access automatically.
|
|
327
347
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: subscription-integration
|
|
3
|
-
description:
|
|
3
|
+
description: Dodo Payments subscription lifecycle after checkout, covering trials, statuses like active, on_hold, and cancelled, plan upgrades and downgrades, proration, cancellation, failed-payment recovery, mandates, and on-demand charges. Use when managing recurring billing, plan changes, dunning, or off-session charging; use checkout-integration to start the purchase.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Dodo Payments Subscription Integration
|
|
@@ -19,7 +19,7 @@ Implement recurring billing with trials, plan changes, and on-demand charging. S
|
|
|
19
19
|
|
|
20
20
|
## Core Concepts
|
|
21
21
|
|
|
22
|
-
**Subscription lifecycle:** The
|
|
22
|
+
**Subscription lifecycle:** The eight subscription statuses are `pending`, `active`, `past_due`, `on_hold`, `paused`, `cancelled`, `failed`, and `expired`. A trialing subscription reports `active`; `subscription.renewed` is an event, not a status. A failed renewal moves a subscription to `past_due` (only if you configured a grace period; access is kept) or `on_hold` (access revoked); both are recoverable. `paused` is a deliberate freeze by you or the customer. `failed` is terminal and only happens when **initial creation** fails. Cancellation sets it to `cancelled` or schedules it to become `expired` at period end.
|
|
23
23
|
|
|
24
24
|
**Checkout Sessions:** The recommended path for creating subscriptions. A single-use hosted checkout that collects payment and customer data, then creates the subscription server-side.
|
|
25
25
|
|
|
@@ -89,12 +89,16 @@ const session = await client.checkoutSessions.create({
|
|
|
89
89
|
| Status | Meaning | Transitions |
|
|
90
90
|
|--------|---------|-----------|
|
|
91
91
|
| `pending` | Creation in progress | → `active`, `failed` |
|
|
92
|
-
| `active` | Actively renewing | → `on_hold`, `cancelled`, `expired` |
|
|
93
|
-
| `
|
|
92
|
+
| `active` | Actively renewing | → `past_due`, `on_hold`, `paused`, `cancelled`, `expired` |
|
|
93
|
+
| `past_due` | Renewal failed and the grace period is open; customer **keeps** access until `past_due_ends_at` | → `active` (debt settled), `on_hold` or `cancelled` (grace period ends, per your setting) |
|
|
94
|
+
| `on_hold` | Renewal/plan-change payment failed; renewals stopped, access revoked; recoverable | → `active` (payment method updated / retry succeeds), `cancelled` |
|
|
95
|
+
| `paused` | Deliberately paused by you or the customer; billing frozen, access revoked | → `active` (resumed), `cancelled` |
|
|
94
96
|
| `cancelled` | Will not renew | → `expired` (at period end) |
|
|
95
|
-
| `failed` | Initial mandate/payment failed; terminal | (no recovery) |
|
|
97
|
+
| `failed` | Initial mandate/payment failed at creation; terminal. Never reached from `on_hold` or exhausted retries | (no recovery) |
|
|
96
98
|
| `expired` | Subscription term ended | (terminal) |
|
|
97
99
|
|
|
100
|
+
`past_due` only occurs if you enable a grace period; without one, a failed renewal goes straight to `on_hold`.
|
|
101
|
+
|
|
98
102
|
**Trial period:** If `trial_period_days` is set, the subscription enters `active` immediately but charges nothing until the trial ends. The first charge occurs on the trial end date.
|
|
99
103
|
|
|
100
104
|
---
|
|
@@ -108,6 +112,20 @@ const subscription = await client.subscriptions.retrieve('sub_xxxxx');
|
|
|
108
112
|
console.log(subscription.status, subscription.next_billing_date);
|
|
109
113
|
```
|
|
110
114
|
|
|
115
|
+
### Pause and Resume
|
|
116
|
+
|
|
117
|
+
Pause and resume run through `status` on the update endpoint. Send it on its own: combining `paused`/`active` with any other field is rejected with `422`.
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
// Pause: renewals stop, access is revoked, next_billing_date shifts by the pause length
|
|
121
|
+
await client.subscriptions.update('sub_xxxxx', { status: 'paused' });
|
|
122
|
+
|
|
123
|
+
// Resume
|
|
124
|
+
await client.subscriptions.update('sub_xxxxx', { status: 'active' });
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Pausing emits `subscription.paused`; resuming emits `subscription.unpaused`. Customer self-service pause in the portal is opt-in (**Settings → Subscriptions → Allow Subscription Pause**).
|
|
128
|
+
|
|
111
129
|
### Update Payment Method
|
|
112
130
|
|
|
113
131
|
When a subscription is `on_hold` due to failed payment, the customer can update their payment method. This automatically charges any outstanding dues.
|
|
@@ -162,7 +180,7 @@ await client.subscriptions.changePlan('sub_xxxxx', {
|
|
|
162
180
|
|
|
163
181
|
| Mode | Upgrade | Downgrade | Billing date |
|
|
164
182
|
|------|---------|-----------|--------------|
|
|
165
|
-
| `prorated_immediately` |
|
|
183
|
+
| `prorated_immediately` | Credit for unused time on the old plan, then charge a **full** new cycle (net = full new cycle − unused credit) | Same formula; a credit only remains if the unused credit exceeds the new plan price | Resets to change date |
|
|
166
184
|
| `difference_immediately` | Full new-plan charge | Difference becomes credit | Resets |
|
|
167
185
|
| `full_immediately` | Full new-plan charge | Full new-plan charge, no credit | Resets |
|
|
168
186
|
| `do_not_bill` | No charge | No credit | Preserved |
|
|
@@ -172,6 +190,19 @@ await client.subscriptions.changePlan('sub_xxxxx', {
|
|
|
172
190
|
- `prevent_change`: Keep the old plan if the charge fails.
|
|
173
191
|
- `apply_change`: Apply the new plan even if payment fails (subscription may become `on_hold`).
|
|
174
192
|
|
|
193
|
+
**Scheduling:** `effective_at` defaults to `'immediately'`. Pass `effective_at: 'next_billing_date'` to schedule the change (typical for downgrades) - nothing is charged now, the customer keeps the current plan until the period ends, and the pending change appears on the subscription as `scheduled_change` (cancel it with `cancelChangePlan`).
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
await client.subscriptions.changePlan('sub_xxxxx', {
|
|
197
|
+
product_id: 'pdt_lower_tier',
|
|
198
|
+
quantity: 1,
|
|
199
|
+
proration_billing_mode: 'prorated_immediately',
|
|
200
|
+
effective_at: 'next_billing_date',
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Plan changes are rejected while the subscription is `past_due`.** Settle the renewal debt first.
|
|
205
|
+
|
|
175
206
|
### Preview Plan Change
|
|
176
207
|
|
|
177
208
|
Show the customer a quote before committing:
|
|
@@ -319,11 +350,16 @@ For full customer management (creating, updating, listing), see the `customer-ma
|
|
|
319
350
|
| Event | When | Action |
|
|
320
351
|
|-------|------|--------|
|
|
321
352
|
| `subscription.active` | Subscription becomes active, including a trial start or recovery | Grant access |
|
|
353
|
+
| `subscription.updated` | Any subscription field changes | Sync your stored copy (status, dates, metadata) |
|
|
322
354
|
| `subscription.renewed` | Successful renewal | Log renewal, send receipt |
|
|
355
|
+
| `subscription.past_due` | Renewal failed and the grace period opened (payload has `past_due_ends_at`) | Keep access, prompt for payment before the deadline |
|
|
323
356
|
| `subscription.on_hold` | Renewal/plan-change payment failed | Notify customer, offer recovery |
|
|
357
|
+
| `subscription.paused` | Subscription paused | Revoke access while paused |
|
|
358
|
+
| `subscription.unpaused` | Paused subscription resumed | Restore access |
|
|
324
359
|
| `subscription.plan_changed` | Plan upgraded/downgraded or add-ons changed | Update entitlements |
|
|
360
|
+
| `subscription.update_payment_method` | Payment method updated | Update stored payment details |
|
|
325
361
|
| `subscription.cancelled` | Customer cancels | Schedule access revocation per `cancel_at_next_billing_date` |
|
|
326
|
-
| `subscription.failed` | Initial
|
|
362
|
+
| `subscription.failed` | Initial creation failed (mandate creation) | Notify customer, offer a new subscription |
|
|
327
363
|
| `subscription.expired` | Subscription term ended | Revoke access |
|
|
328
364
|
|
|
329
365
|
Webhook signature verification, raw-body handling, durable processing, and idempotency are covered in the `webhook-integration` skill.
|
|
@@ -357,6 +393,22 @@ export async function POST(req: NextRequest) {
|
|
|
357
393
|
case 'subscription.on_hold':
|
|
358
394
|
await notifyPaymentFailed(event.data.customer.customer_id);
|
|
359
395
|
break;
|
|
396
|
+
case 'subscription.paused':
|
|
397
|
+
case 'subscription.unpaused': {
|
|
398
|
+
// Pause and resume can happen in quick succession, and handlers for
|
|
399
|
+
// different events can finish in any order. Don't trust the event type:
|
|
400
|
+
// re-read the subscription and apply its CURRENT status, scoped to this
|
|
401
|
+
// subscription (the customer may hold other active ones). Run this under
|
|
402
|
+
// a per-subscription lock if your handlers execute concurrently.
|
|
403
|
+
const current = await client.subscriptions.retrieve(event.data.subscription_id);
|
|
404
|
+
if (current.status === 'paused') {
|
|
405
|
+
await suspendSubscriptionAccess(current.subscription_id);
|
|
406
|
+
} else if (current.status === 'active' || current.status === 'past_due') {
|
|
407
|
+
// past_due keeps access during the payment grace period
|
|
408
|
+
await restoreSubscriptionAccess(current.subscription_id);
|
|
409
|
+
}
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
360
412
|
case 'subscription.cancelled':
|
|
361
413
|
if (event.data.cancel_at_next_billing_date) {
|
|
362
414
|
await scheduleAccessRevocation(event.data.subscription_id, new Date(event.data.next_billing_date));
|
|
@@ -459,9 +511,9 @@ await client.subscriptions.changePlan('sub_xxxxx', {
|
|
|
459
511
|
|
|
460
512
|
This will fail. Always specify a proration mode.
|
|
461
513
|
|
|
462
|
-
### 5. Confusing `on_hold` with
|
|
514
|
+
### 5. Confusing `on_hold` with `paused`
|
|
463
515
|
|
|
464
|
-
`on_hold`
|
|
516
|
+
`on_hold` is involuntary: a payment failed, and it is recovered by updating the payment method or a successful retry. `paused` is deliberate: you or the customer froze the subscription with `subscriptions.update(id, { status: 'paused' })`, and it is resumed with `{ status: 'active' }`. Handle `subscription.paused`/`subscription.unpaused` separately from `subscription.on_hold`.
|
|
465
517
|
|
|
466
518
|
### 6. Not Handling `cancel_at_next_billing_date`
|
|
467
519
|
|
|
@@ -481,6 +533,7 @@ if (data.cancel_at_next_billing_date) {
|
|
|
481
533
|
|
|
482
534
|
## Resources
|
|
483
535
|
|
|
536
|
+
- [Subscriptions (states, pause, grace period)](https://docs.dodopayments.com/features/subscription)
|
|
484
537
|
- [Subscription Integration Guide](https://docs.dodopayments.com/developer-resources/subscription-integration-guide)
|
|
485
538
|
- [Upgrade & Downgrade Guide](https://docs.dodopayments.com/developer-resources/subscription-upgrade-downgrade)
|
|
486
539
|
- [On-Demand Subscriptions](https://docs.dodopayments.com/developer-resources/ondemand-subscriptions)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: testing-and-go-live
|
|
3
|
-
description:
|
|
3
|
+
description: Dodo Payments test mode and production launch, covering test cards and payment methods, success and decline scenarios, renewal simulation, local webhook testing with the CLI, copying products from test to live, and a go-live checklist. Use when verifying payment flows, switching from test_mode to live_mode, or preparing to launch.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Testing and Go-Live
|
|
@@ -44,6 +44,8 @@ const client = new DodoPayments({
|
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
```python
|
|
47
|
+
import os
|
|
48
|
+
|
|
47
49
|
from dodopayments import DodoPayments
|
|
48
50
|
|
|
49
51
|
client = DodoPayments(
|
|
@@ -67,31 +69,34 @@ Get your test API key from the Dodo dashboard. Test keys are prefixed `dodo_test
|
|
|
67
69
|
|
|
68
70
|
### Card success and decline scenarios
|
|
69
71
|
|
|
70
|
-
Use these card numbers in test mode
|
|
72
|
+
Use these card numbers in test mode with expiry **06/32** (or **12/34**) and CVV **123**.
|
|
71
73
|
|
|
72
74
|
| Scenario | Card Number | Result |
|
|
73
75
|
|---|---|---|
|
|
74
76
|
| Success | `4242 4242 4242 4242` | Payment succeeds |
|
|
75
77
|
| Decline | `4000 0000 0000 0002` | Payment declined |
|
|
76
78
|
| Decline (insufficient funds) | `4000 0000 0000 9995` | Insufficient funds error |
|
|
77
|
-
|
|
79
|
+
|
|
80
|
+
Only use cards listed on the [Testing Process](https://docs.dodopayments.com/miscellaneous/testing-process) page; other Stripe-style test numbers (for example "lost card" variants) are not documented for Dodo.
|
|
78
81
|
|
|
79
82
|
### Subscription renewal failure
|
|
80
83
|
|
|
81
84
|
To test subscription renewal failures, use this card:
|
|
82
85
|
|
|
83
|
-
| Card Number | Behavior |
|
|
84
|
-
|
|
85
|
-
| `4000 0000 0000
|
|
86
|
+
| Card Number | Expiry | CVV | Behavior |
|
|
87
|
+
|---|---|---|---|
|
|
88
|
+
| `4000 0000 0000 0341` | 12/34 | 123 | Declined at the subscription's next charge (renewal, upgrade, or downgrade) |
|
|
89
|
+
|
|
90
|
+
Create the subscription with a success card first, then switch its payment method to this card in the Customer Portal.
|
|
86
91
|
|
|
87
92
|
### UPI (India)
|
|
88
93
|
|
|
89
|
-
Test UPI success and failure with these VPAs:
|
|
94
|
+
Test UPI success and failure with these VPAs. The billing country must be `IN` and the currency `INR` (non-Indian merchants also need Adaptive Currency enabled):
|
|
90
95
|
|
|
91
96
|
| VPA | Result |
|
|
92
97
|
|---|---|
|
|
93
|
-
| `success@
|
|
94
|
-
| `failure@
|
|
98
|
+
| `success@upi` | Payment succeeds |
|
|
99
|
+
| `failure@upi` | Payment fails |
|
|
95
100
|
|
|
96
101
|
### BNPL, wallets, and regional methods
|
|
97
102
|
|
|
@@ -162,7 +167,9 @@ dodo wh trigger payment.success http://localhost:3000/webhook
|
|
|
162
167
|
|
|
163
168
|
Sends a realistic mock payload for a chosen event. It runs offline and works even while logged out — which is the tell for the part that matters: **these payloads are unsigned.** `unwrap()` rejects them because there is no valid signature to verify.
|
|
164
169
|
|
|
165
|
-
Use `unsafeUnwrap()` for triggered events only, and never on an endpoint that also receives real traffic.
|
|
170
|
+
Use `unsafeUnwrap()` for triggered events only, and never on an endpoint that also receives real traffic. Trigger names are `<category>.<event>`, for example `payment.success`, `subscription.active`, or `dispute.opened`.
|
|
171
|
+
|
|
172
|
+
**Trigger names are not always the payload `type`.** `payment.success` delivers `type: "payment.succeeded"`, `refund.success` delivers `refund.succeeded`, and `licence.created` delivers `license_key.created`. Switch on the payload types in your handler; a `case 'payment.success'` never matches.
|
|
166
173
|
|
|
167
174
|
As with `listen`, both arguments are required in direct mode; `/wh trigger` inside the TUI opens a wizard instead.
|
|
168
175
|
|
|
@@ -184,19 +191,19 @@ Products are environment-specific. You cannot reuse a test product ID in product
|
|
|
184
191
|
|
|
185
192
|
**Programmatic approach:**
|
|
186
193
|
|
|
187
|
-
Fetch the product from test mode, extract its configuration, and create it in live mode:
|
|
194
|
+
Fetch the product from test mode, extract its configuration, and create it in live mode. API keys are per mode — a test key does not authenticate against live mode and vice versa — so this needs two distinct keys:
|
|
188
195
|
|
|
189
196
|
```typescript
|
|
190
197
|
// In test mode
|
|
191
198
|
const testClient = new DodoPayments({
|
|
192
|
-
bearerToken: process.env.
|
|
199
|
+
bearerToken: process.env.DODO_PAYMENTS_TEST_API_KEY,
|
|
193
200
|
environment: 'test_mode',
|
|
194
201
|
});
|
|
195
202
|
const testProduct = await testClient.products.retrieve('pdt_test_123');
|
|
196
203
|
|
|
197
204
|
// In live mode
|
|
198
205
|
const liveClient = new DodoPayments({
|
|
199
|
-
bearerToken: process.env.
|
|
206
|
+
bearerToken: process.env.DODO_PAYMENTS_LIVE_API_KEY,
|
|
200
207
|
environment: 'live_mode',
|
|
201
208
|
});
|
|
202
209
|
const liveProduct = await liveClient.products.create({
|
|
@@ -331,5 +338,5 @@ Test keys (prefixed `dodo_test_`) only work with `https://test.dodopayments.com`
|
|
|
331
338
|
|
|
332
339
|
- [Testing Process](https://docs.dodopayments.com/miscellaneous/testing-process)
|
|
333
340
|
- [Test vs. Live Mode](https://docs.dodopayments.com/miscellaneous/test-mode-vs-live-mode)
|
|
334
|
-
- [Webhook Integration](https://docs.dodopayments.com/developer-resources/webhooks
|
|
335
|
-
- [Dodo CLI](https://docs.dodopayments.com/developer-resources/cli)
|
|
341
|
+
- [Webhook Integration](https://docs.dodopayments.com/developer-resources/webhooks)
|
|
342
|
+
- [Dodo CLI](https://docs.dodopayments.com/developer-resources/sdks/cli)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: usage-based-billing
|
|
3
|
-
description:
|
|
3
|
+
description: Dodo Payments usage-based billing that charges per measured unit, covering meters, usage event ingestion by event_name, aggregation, free thresholds, price_per_unit, and metered subscriptions. Use when billing per API call, AI token, storage, or bandwidth, sending usage events, or adding metered pricing to a plan; use credit-based-billing for prepaid credit pools.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Dodo Payments Usage-Based Billing
|
|
@@ -181,7 +181,7 @@ async function trackBatchUsage(
|
|
|
181
181
|
customerId: string;
|
|
182
182
|
eventName: string;
|
|
183
183
|
occurredAt: string;
|
|
184
|
-
metadata: Record<string, string>;
|
|
184
|
+
metadata: Record<string, string | number | boolean>;
|
|
185
185
|
}>
|
|
186
186
|
) {
|
|
187
187
|
const formattedEvents = events.map((event) => ({
|
|
@@ -301,10 +301,11 @@ async function callAI(
|
|
|
301
301
|
customer_id: customerId,
|
|
302
302
|
event_name: 'ai.tokens',
|
|
303
303
|
timestamp: completedAt,
|
|
304
|
+
// Aggregated properties (Sum/Max/Last) must be numbers, not strings.
|
|
304
305
|
metadata: {
|
|
305
|
-
tokens: response.usage.total_tokens
|
|
306
|
-
prompt_tokens: response.usage.prompt_tokens
|
|
307
|
-
completion_tokens: response.usage.completion_tokens
|
|
306
|
+
tokens: response.usage.total_tokens,
|
|
307
|
+
prompt_tokens: response.usage.prompt_tokens,
|
|
308
|
+
completion_tokens: response.usage.completion_tokens,
|
|
308
309
|
model: 'gpt-4',
|
|
309
310
|
}
|
|
310
311
|
}]
|
|
@@ -331,9 +332,10 @@ async function updateStorageUsage(
|
|
|
331
332
|
customer_id: customerId,
|
|
332
333
|
event_name: 'storage.snapshot',
|
|
333
334
|
timestamp: capturedAt,
|
|
335
|
+
// Numeric values: a string property does not aggregate.
|
|
334
336
|
metadata: {
|
|
335
|
-
bytes: bytesUsed
|
|
336
|
-
gb: (bytesUsed / 1024 / 1024 / 1024).toFixed(2),
|
|
337
|
+
bytes: bytesUsed,
|
|
338
|
+
gb: Number((bytesUsed / 1024 / 1024 / 1024).toFixed(2)),
|
|
337
339
|
}
|
|
338
340
|
}]
|
|
339
341
|
});
|
|
@@ -355,15 +357,16 @@ await updateStorageUsage(
|
|
|
355
357
|
### Retrieve Usage History
|
|
356
358
|
|
|
357
359
|
```typescript
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
console.log(
|
|
360
|
+
// Auto-paginates across ALL billing periods; narrow with start_date/end_date/meter_id.
|
|
361
|
+
for await (const period of client.subscriptions.retrieveUsageHistory('sub_abc123', {
|
|
362
|
+
start_date: '2025-01-01T00:00:00Z',
|
|
363
|
+
page_size: 20,
|
|
364
|
+
})) {
|
|
365
|
+
console.log(period); // one record per billing period, with per-meter usage
|
|
366
|
+
}
|
|
364
367
|
```
|
|
365
368
|
|
|
366
|
-
This
|
|
369
|
+
This is paginated usage history organized by billing period, across the subscription's lifetime - not just the current period. Filter with `start_date`, `end_date`, and `meter_id`, and iterate pages (or `for await`) rather than reading only the first page.
|
|
367
370
|
|
|
368
371
|
---
|
|
369
372
|
|
|
@@ -378,7 +381,7 @@ To link a meter to credits:
|
|
|
378
381
|
3. On the meter, enable **Bill usage in Credits**.
|
|
379
382
|
4. Set `credit_entitlement_id` and `meter_units_per_credit` (e.g., 1,000 tokens = 1 credit).
|
|
380
383
|
|
|
381
|
-
|
|
384
|
+
The free threshold does **not** apply to credit-billed meters: every unit counts toward credit deduction (it only applies when the meter bills in money). Approximately every minute, a background worker aggregates new usage, converts it using the meter-to-credit ratio, and deducts from non-expired grants, earliest-expiring grants consumed first. When credits run out, configured overage behavior applies.
|
|
382
385
|
|
|
383
386
|
---
|
|
384
387
|
|