@dodopayments/opencode-plugin 0.3.3 → 0.5.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 +1 -1
- package/README.md +152 -42
- package/opencode-plugin/README.md +20 -6
- package/opencode-plugin/index.js +27 -1
- package/package.json +5 -5
- package/skills/better-auth-integration/SKILL.md +214 -0
- package/skills/billing-sdk/SKILL.md +357 -370
- package/skills/checkout-integration/SKILL.md +580 -329
- package/skills/credit-based-billing/SKILL.md +448 -775
- package/skills/customer-management/SKILL.md +281 -0
- package/skills/discounts-and-promotions/SKILL.md +464 -0
- package/skills/dodo-best-practices/SKILL.md +471 -0
- package/skills/framework-adapters/SKILL.md +644 -0
- package/skills/license-keys/SKILL.md +324 -199
- package/skills/localized-pricing/SKILL.md +230 -0
- package/skills/mobile-checkout/SKILL.md +521 -0
- package/skills/product-catalog-management/SKILL.md +311 -0
- package/skills/refunds-and-disputes/SKILL.md +333 -0
- package/skills/subscription-integration/SKILL.md +323 -404
- package/skills/testing-and-go-live/SKILL.md +335 -0
- package/skills/usage-based-billing/SKILL.md +339 -479
- package/skills/webhook-integration/SKILL.md +600 -402
- package/skills/best-practices/SKILL.md +0 -315
|
@@ -1,501 +1,488 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: billing-sdk
|
|
3
|
-
description: Guide for
|
|
3
|
+
description: Guide for building billing UI with BillingSDK - the open-source React component library for pricing tables, subscription management, usage meters, invoice history, and customer portal flows wired to Dodo Payments.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# BillingSDK
|
|
6
|
+
# BillingSDK
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
BillingSDK is an open-source React component library for billing interfaces, maintained by Dodo Payments
|
|
9
|
+
at `github.com/dodopayments/billingsdk`. Components are copied into your repository as source files
|
|
10
|
+
(shadcn/ui model) rather than imported from a runtime npm package.
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
Use this skill when building the front end. All Dodo Payments API calls belong in server-side route
|
|
13
|
+
handlers; the components are presentation only.
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
## When to use this skill
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
- Building a pricing page that starts a Dodo checkout.
|
|
18
|
+
- Building an account/billing page with subscription details and a "Manage billing" button.
|
|
19
|
+
- Rendering usage meters, credit balances, invoice history, or upcoming charges.
|
|
20
|
+
- Adding cancel / upgrade / downgrade UI to an existing React or Next.js app.
|
|
21
|
+
- Deciding whether to scaffold with `@billingsdk/cli init` or add individual components.
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
- **React Components**: Pre-built, customizable billing components
|
|
18
|
-
- **CLI Tooling**: Project initialization and component management
|
|
19
|
-
- **Framework Support**: Next.js, Express.js, Hono, Fastify, React
|
|
20
|
-
- **Payment Provider**: Full integration with Dodo Payments
|
|
23
|
+
## Core concepts
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
**Source-installed, not a dependency.** The repository root package `billingsdk` is private. There is no
|
|
26
|
+
verified general-purpose runtime npm component package. You install components through the CLI or the
|
|
27
|
+
shadcn registry, and the `.tsx` files land in your project under `components/billingsdk/`.
|
|
28
|
+
|
|
29
|
+
**The installed file is the prop contract.** Because components are vendored into your repo, the exported
|
|
30
|
+
props type in `components/billingsdk/<component>.tsx` is authoritative for your version. Read it before
|
|
31
|
+
wiring callbacks. Do not assume prop names from another project.
|
|
32
|
+
|
|
33
|
+
**The registry is the installability source of truth.** Use
|
|
34
|
+
[`registry.json`](https://github.com/dodopayments/billingsdk/blob/main/registry.json). The docs navigation
|
|
35
|
+
has drifted from it — for example `payment-success-dialog` appears in navigation but not in the current
|
|
36
|
+
registry. If `add` fails, the block is not in the registry.
|
|
23
37
|
|
|
24
|
-
|
|
38
|
+
**Components never call Dodo.** They receive data and fire callbacks. Every Dodo Payments call runs on your
|
|
39
|
+
server with `DODO_PAYMENTS_API_KEY`. That key is `dodo_test_...` or `dodo_live_...` and must never reach
|
|
40
|
+
the browser.
|
|
25
41
|
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
Scaffold a new integration (framework config, API routes, `useBilling` hooks, `lib/dodopayments.ts`,
|
|
45
|
+
dependencies, and env vars `DODO_PAYMENTS_API_KEY`, `DODO_PAYMENTS_ENVIRONMENT`,
|
|
46
|
+
`DODO_PAYMENTS_WEBHOOK_KEY`):
|
|
28
47
|
|
|
29
48
|
```bash
|
|
30
49
|
npx @billingsdk/cli init
|
|
31
50
|
```
|
|
32
51
|
|
|
33
|
-
|
|
34
|
-
- Configure your framework (Next.js App Router)
|
|
35
|
-
- Set up Dodo Payments integration
|
|
36
|
-
- Generate API routes for checkout, customers, webhooks
|
|
37
|
-
- Install dependencies
|
|
38
|
-
- Create configuration files
|
|
39
|
-
|
|
40
|
-
### Option 2: Add to Existing Project
|
|
41
|
-
Add individual components using the CLI:
|
|
52
|
+
Add a single component to an existing project:
|
|
42
53
|
|
|
43
54
|
```bash
|
|
44
55
|
npx @billingsdk/cli add pricing-table-one
|
|
45
|
-
npx @billingsdk/cli add subscription-management
|
|
46
|
-
npx @billingsdk/cli add usage-meter-circle
|
|
47
56
|
```
|
|
48
57
|
|
|
49
|
-
|
|
50
|
-
Install directly using shadcn registry:
|
|
58
|
+
Or install through the shadcn registry using the `@billingsdk/` namespace:
|
|
51
59
|
|
|
52
60
|
```bash
|
|
53
61
|
npx shadcn@latest add @billingsdk/pricing-table-one
|
|
54
62
|
```
|
|
55
63
|
|
|
56
|
-
|
|
64
|
+
The published CLI package is `@billingsdk/cli`, most recently seen at version 0.9.0.
|
|
57
65
|
|
|
58
|
-
##
|
|
66
|
+
## Component inventory
|
|
59
67
|
|
|
60
|
-
|
|
68
|
+
From the current official registry, grouped by purpose:
|
|
61
69
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
| Group | Blocks |
|
|
71
|
+
|---|---|
|
|
72
|
+
| Pricing | `pricing-table-one` through `pricing-table-eight` |
|
|
73
|
+
| Subscription | `subscription-management`, `usage-based-pricing`, `invoice-history`, `update-plan-card`, `update-plan-dialog`, `proration-preview`, `cancel-subscription-card`, `cancel-subscription-dialog` |
|
|
74
|
+
| Usage and billing | `usage-meter-linear`, `usage-meter-circle`, `usage-table`, `detailed-usage-table`, `billing-screen`, `billing-settings`, `billing-settings-2`, `upcoming-charges` |
|
|
75
|
+
| Payments | `payment-details`, `payment-details-two`, `payment-method-selector`, `payment-card`, `payment-failure` |
|
|
76
|
+
| Promotion and trials | `banner`, `limited-offer-dialog`, `trial-expiry-card` |
|
|
65
77
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
2. Select payment provider (Dodo Payments)
|
|
69
|
-
3. Configure project settings
|
|
78
|
+
`pricing-table-one` is the only block with an officially published prop example, reproduced below. For
|
|
79
|
+
every other block, run `add` and read the generated file's props type.
|
|
70
80
|
|
|
71
|
-
|
|
81
|
+
## Server client
|
|
72
82
|
|
|
73
|
-
|
|
74
|
-
npx @billingsdk/cli add <component-name>
|
|
75
|
-
```
|
|
83
|
+
One module, imported only by route handlers and server components.
|
|
76
84
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
- `subscription-management` - Manage active subscriptions
|
|
81
|
-
- `usage-meter-circle` - Circular usage visualization
|
|
82
|
-
- More components available...
|
|
85
|
+
```typescript
|
|
86
|
+
// lib/dodopayments.ts
|
|
87
|
+
import DodoPayments from 'dodopayments';
|
|
83
88
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
3. Updates project configuration
|
|
88
|
-
4. Installs additional dependencies
|
|
89
|
+
if (!process.env.DODO_PAYMENTS_API_KEY) {
|
|
90
|
+
throw new Error('DODO_PAYMENTS_API_KEY is not set');
|
|
91
|
+
}
|
|
89
92
|
|
|
90
|
-
|
|
93
|
+
export const dodo = new DodoPayments({
|
|
94
|
+
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
|
|
95
|
+
environment: process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode',
|
|
96
|
+
});
|
|
97
|
+
```
|
|
91
98
|
|
|
92
|
-
|
|
99
|
+
`environment` defaults to `'live_mode'` when omitted, so set it explicitly during development. Base URLs
|
|
100
|
+
are `https://test.dodopayments.com` and `https://live.dodopayments.com`.
|
|
93
101
|
|
|
94
|
-
|
|
102
|
+
## Pricing page
|
|
95
103
|
|
|
96
|
-
|
|
104
|
+
### 1. Plan catalogue
|
|
97
105
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
npx shadcn@latest add @billingsdk/pricing-table-one
|
|
103
|
-
```
|
|
106
|
+
Keep the product ids and the display copy in one module. The `price` here is a display number rendered by
|
|
107
|
+
the component — the amount actually charged comes from the Dodo product catalogue, and Dodo API amounts
|
|
108
|
+
are always in the smallest currency unit (cents). Treat this file as a mirror of the dashboard and
|
|
109
|
+
reconcile it when you change a price.
|
|
104
110
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
```typescript
|
|
112
|
+
// lib/plans.ts
|
|
113
|
+
export type PlanId = 'starter' | 'pro';
|
|
114
|
+
|
|
115
|
+
export interface Plan {
|
|
116
|
+
id: PlanId;
|
|
117
|
+
title: string;
|
|
118
|
+
price: number;
|
|
119
|
+
period: string;
|
|
120
|
+
features: string[];
|
|
121
|
+
popular: boolean;
|
|
122
|
+
}
|
|
108
123
|
|
|
109
|
-
const
|
|
124
|
+
export const PLANS: readonly Plan[] = [
|
|
110
125
|
{
|
|
111
|
-
id: '
|
|
112
|
-
|
|
113
|
-
price:
|
|
114
|
-
|
|
115
|
-
features: ['
|
|
126
|
+
id: 'starter',
|
|
127
|
+
title: 'Starter',
|
|
128
|
+
price: 9,
|
|
129
|
+
period: 'month',
|
|
130
|
+
features: ['100 requests', 'Basic support', '1 project'],
|
|
131
|
+
popular: false,
|
|
116
132
|
},
|
|
117
133
|
{
|
|
118
|
-
id: '
|
|
119
|
-
|
|
134
|
+
id: 'pro',
|
|
135
|
+
title: 'Pro',
|
|
120
136
|
price: 29,
|
|
121
|
-
|
|
122
|
-
features: ['Unlimited
|
|
137
|
+
period: 'month',
|
|
138
|
+
features: ['Unlimited requests', 'Priority support', '10 projects'],
|
|
123
139
|
popular: true,
|
|
124
140
|
},
|
|
125
|
-
{
|
|
126
|
-
id: 'prod_enterprise',
|
|
127
|
-
name: 'Enterprise',
|
|
128
|
-
price: 99,
|
|
129
|
-
interval: 'month',
|
|
130
|
-
features: ['Everything in Pro', 'Custom integrations', 'Dedicated support'],
|
|
131
|
-
},
|
|
132
141
|
];
|
|
142
|
+
```
|
|
133
143
|
|
|
134
|
-
|
|
135
|
-
const handleSelectPlan = async (planId: string) => {
|
|
136
|
-
// Create checkout session
|
|
137
|
-
const response = await fetch('/api/checkout', {
|
|
138
|
-
method: 'POST',
|
|
139
|
-
headers: { 'Content-Type': 'application/json' },
|
|
140
|
-
body: JSON.stringify({ productId: planId }),
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
const { checkoutUrl } = await response.json();
|
|
144
|
-
window.location.href = checkoutUrl;
|
|
145
|
-
};
|
|
144
|
+
### 2. Product id map — server only
|
|
146
145
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
146
|
+
Never let the browser choose an arbitrary `product_id`. Map the public plan slug to a `pdt_` id on the
|
|
147
|
+
server and reject anything else.
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
// lib/plan-products.server.ts
|
|
151
|
+
import 'server-only';
|
|
152
|
+
import type { PlanId } from './plans';
|
|
153
|
+
|
|
154
|
+
const PRODUCT_IDS: Record<PlanId, string> = {
|
|
155
|
+
starter: 'pdt_starter_monthly',
|
|
156
|
+
pro: 'pdt_pro_monthly',
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export function productIdForPlan(value: unknown): string | null {
|
|
160
|
+
if (typeof value !== 'string') return null;
|
|
161
|
+
return PRODUCT_IDS[value as PlanId] ?? null;
|
|
153
162
|
}
|
|
154
163
|
```
|
|
155
164
|
|
|
156
|
-
###
|
|
165
|
+
### 3. Checkout route
|
|
157
166
|
|
|
158
|
-
|
|
167
|
+
```typescript
|
|
168
|
+
// app/api/checkout/route.ts
|
|
169
|
+
import { NextResponse } from 'next/server';
|
|
170
|
+
import { dodo } from '@/lib/dodopayments';
|
|
171
|
+
import { productIdForPlan } from '@/lib/plan-products.server';
|
|
172
|
+
import { getCurrentUser } from '@/lib/auth';
|
|
173
|
+
|
|
174
|
+
export async function POST(request: Request) {
|
|
175
|
+
const user = await getCurrentUser();
|
|
176
|
+
if (!user) {
|
|
177
|
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
|
178
|
+
}
|
|
159
179
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
180
|
+
const body: unknown = await request.json();
|
|
181
|
+
const planId = (body as { planId?: unknown }).planId;
|
|
182
|
+
const productId = productIdForPlan(planId);
|
|
183
|
+
|
|
184
|
+
if (!productId) {
|
|
185
|
+
return NextResponse.json({ error: 'Unknown plan' }, { status: 400 });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const session = await dodo.checkoutSessions.create({
|
|
190
|
+
product_cart: [{ product_id: productId, quantity: 1 }],
|
|
191
|
+
customer: { email: user.email, name: user.name },
|
|
192
|
+
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing/return`,
|
|
193
|
+
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
|
|
194
|
+
metadata: { app_user_id: user.id },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (!session.checkout_url) {
|
|
198
|
+
return NextResponse.json({ error: 'No checkout URL returned' }, { status: 502 });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return NextResponse.json({ checkoutUrl: session.checkout_url });
|
|
202
|
+
} catch (error) {
|
|
203
|
+
console.error('Checkout session creation failed', error);
|
|
204
|
+
return NextResponse.json({ error: 'Could not start checkout' }, { status: 500 });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
163
207
|
```
|
|
164
208
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
import { PricingTableTwo } from "@/components/billingsdk/pricing-table-two";
|
|
209
|
+
`checkout_url` is nullable — it is absent when the session is created with `payment_method_id` — so guard
|
|
210
|
+
it rather than redirecting to `undefined`. Checkout URLs are single-use and normally expire after 24 hours.
|
|
168
211
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
212
|
+
Use `checkoutSessions.create`. `payments.create` and `subscriptions.create` are deprecated for new
|
|
213
|
+
integrations. Checkout parameters are covered in depth in the `checkout-integration` skill.
|
|
214
|
+
|
|
215
|
+
### 4. Client component
|
|
216
|
+
|
|
217
|
+
```tsx
|
|
218
|
+
// components/pricing-plans.tsx
|
|
219
|
+
'use client';
|
|
220
|
+
|
|
221
|
+
import { useState } from 'react';
|
|
222
|
+
import { PricingTableOne } from '@/components/billingsdk/pricing-table-one';
|
|
223
|
+
import { PLANS } from '@/lib/plans';
|
|
224
|
+
|
|
225
|
+
export function PricingPlans() {
|
|
226
|
+
const [error, setError] = useState<string | null>(null);
|
|
227
|
+
|
|
228
|
+
async function handlePlanSelect(planId: string) {
|
|
229
|
+
setError(null);
|
|
230
|
+
try {
|
|
231
|
+
const response = await fetch('/api/checkout', {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
headers: { 'Content-Type': 'application/json' },
|
|
234
|
+
body: JSON.stringify({ planId }),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (!response.ok) {
|
|
238
|
+
setError('Could not start checkout. Please try again.');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const { checkoutUrl } = (await response.json()) as { checkoutUrl: string };
|
|
243
|
+
window.location.href = checkoutUrl;
|
|
244
|
+
} catch {
|
|
245
|
+
setError('Network error. Please try again.');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
196
248
|
|
|
197
|
-
export function PricingPage() {
|
|
198
249
|
return (
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
250
|
+
<>
|
|
251
|
+
<PricingTableOne
|
|
252
|
+
plans={PLANS}
|
|
253
|
+
title="Choose your plan"
|
|
254
|
+
description="Select the plan that works best for you"
|
|
255
|
+
onPlanSelect={handlePlanSelect}
|
|
256
|
+
theme="classic"
|
|
257
|
+
size="medium"
|
|
258
|
+
/>
|
|
259
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
260
|
+
</>
|
|
205
261
|
);
|
|
206
262
|
}
|
|
207
263
|
```
|
|
208
264
|
|
|
209
|
-
|
|
265
|
+
Render `<PricingPlans />` from `app/pricing/page.tsx`. Nothing secret crosses into the client component:
|
|
266
|
+
it sends a plan slug and receives a URL.
|
|
267
|
+
|
|
268
|
+
## Subscription management and customer portal
|
|
210
269
|
|
|
211
|
-
|
|
270
|
+
Install the block, then read its props from the generated file:
|
|
212
271
|
|
|
213
|
-
**Installation:**
|
|
214
272
|
```bash
|
|
215
273
|
npx @billingsdk/cli add subscription-management
|
|
216
274
|
```
|
|
217
275
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
import { SubscriptionManagement } from "@/components/billingsdk/subscription-management";
|
|
276
|
+
The portal route. The method is `customers.customerPortal.create(customerID, { ...params })` and the
|
|
277
|
+
session's URL field is `link`.
|
|
221
278
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
279
|
+
```typescript
|
|
280
|
+
// app/api/portal/route.ts
|
|
281
|
+
import { NextResponse } from 'next/server';
|
|
282
|
+
import { dodo } from '@/lib/dodopayments';
|
|
283
|
+
import { getCurrentUser } from '@/lib/auth';
|
|
284
|
+
|
|
285
|
+
export async function POST() {
|
|
286
|
+
const user = await getCurrentUser();
|
|
287
|
+
if (!user?.dodoCustomerId) {
|
|
288
|
+
return NextResponse.json({ error: 'No billing account' }, { status: 400 });
|
|
289
|
+
}
|
|
230
290
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (confirm('Are you sure you want to cancel?')) {
|
|
242
|
-
await fetch('/api/subscription/cancel', { method: 'POST' });
|
|
243
|
-
}
|
|
244
|
-
}}
|
|
245
|
-
/>
|
|
246
|
-
);
|
|
291
|
+
try {
|
|
292
|
+
const session = await dodo.customers.customerPortal.create(user.dodoCustomerId, {
|
|
293
|
+
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
return NextResponse.json({ url: session.link });
|
|
297
|
+
} catch (error) {
|
|
298
|
+
console.error('Customer portal session failed', error);
|
|
299
|
+
return NextResponse.json({ error: 'Could not open billing portal' }, { status: 500 });
|
|
300
|
+
}
|
|
247
301
|
}
|
|
248
302
|
```
|
|
249
303
|
|
|
250
|
-
|
|
304
|
+
Portal links expire after 24 hours, so mint one per click instead of caching it. In the portal a customer
|
|
305
|
+
can view and cancel subscriptions, change plans within enabled product collections, update payment
|
|
306
|
+
methods, download invoices, and retrieve license keys.
|
|
251
307
|
|
|
252
|
-
|
|
308
|
+
Client side:
|
|
253
309
|
|
|
254
|
-
**Installation:**
|
|
255
|
-
```bash
|
|
256
|
-
npx @billingsdk/cli add usage-meter-circle
|
|
257
|
-
```
|
|
258
|
-
|
|
259
|
-
**Usage:**
|
|
260
310
|
```tsx
|
|
261
|
-
|
|
311
|
+
// components/manage-billing-button.tsx
|
|
312
|
+
'use client';
|
|
313
|
+
|
|
314
|
+
import { useState } from 'react';
|
|
315
|
+
|
|
316
|
+
export function ManageBillingButton() {
|
|
317
|
+
const [pending, setPending] = useState(false);
|
|
318
|
+
|
|
319
|
+
async function openPortal() {
|
|
320
|
+
setPending(true);
|
|
321
|
+
try {
|
|
322
|
+
const response = await fetch('/api/portal', { method: 'POST' });
|
|
323
|
+
if (!response.ok) {
|
|
324
|
+
setPending(false);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const { url } = (await response.json()) as { url: string };
|
|
328
|
+
window.location.href = url;
|
|
329
|
+
} catch {
|
|
330
|
+
setPending(false);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
262
333
|
|
|
263
|
-
export function UsageDashboard() {
|
|
264
334
|
return (
|
|
265
|
-
<
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
current={8500}
|
|
269
|
-
limit={10000}
|
|
270
|
-
unit="calls"
|
|
271
|
-
/>
|
|
272
|
-
<UsageMeterCircle
|
|
273
|
-
label="Storage"
|
|
274
|
-
current={3.2}
|
|
275
|
-
limit={5}
|
|
276
|
-
unit="GB"
|
|
277
|
-
/>
|
|
278
|
-
<UsageMeterCircle
|
|
279
|
-
label="Bandwidth"
|
|
280
|
-
current={45}
|
|
281
|
-
limit={100}
|
|
282
|
-
unit="GB"
|
|
283
|
-
/>
|
|
284
|
-
</div>
|
|
335
|
+
<button type="button" onClick={openPortal} disabled={pending}>
|
|
336
|
+
{pending ? 'Opening…' : 'Manage billing'}
|
|
337
|
+
</button>
|
|
285
338
|
);
|
|
286
339
|
}
|
|
287
340
|
```
|
|
288
341
|
|
|
289
|
-
|
|
342
|
+
Wire `openPortal` to whichever callback prop your installed `subscription-management.tsx` exposes, or
|
|
343
|
+
render the standalone button next to it. Load the subscription itself in the server component that hosts
|
|
344
|
+
the page — `await dodo.subscriptions.retrieve(user.dodoSubscriptionId)` — and pass plain data down.
|
|
290
345
|
|
|
291
|
-
##
|
|
346
|
+
## Usage and credit display
|
|
292
347
|
|
|
293
|
-
|
|
348
|
+
Install a meter block:
|
|
294
349
|
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
├── app/
|
|
298
|
-
│ ├── api/
|
|
299
|
-
│ │ ├── checkout/
|
|
300
|
-
│ │ │ └── route.ts
|
|
301
|
-
│ │ ├── portal/
|
|
302
|
-
│ │ │ └── route.ts
|
|
303
|
-
│ │ └── webhooks/
|
|
304
|
-
│ │ └── dodo/
|
|
305
|
-
│ │ └── route.ts
|
|
306
|
-
│ └── pricing/
|
|
307
|
-
│ └── page.tsx
|
|
308
|
-
├── components/
|
|
309
|
-
│ └── billingsdk/
|
|
310
|
-
│ ├── pricing-table-one.tsx
|
|
311
|
-
│ └── subscription-management.tsx
|
|
312
|
-
├── lib/
|
|
313
|
-
│ ├── dodo.ts
|
|
314
|
-
│ └── billingsdk-config.ts
|
|
315
|
-
└── .env.local
|
|
350
|
+
```bash
|
|
351
|
+
npx @billingsdk/cli add usage-meter-linear
|
|
316
352
|
```
|
|
317
353
|
|
|
318
|
-
|
|
354
|
+
Fetch the numbers server-side. Credit balances come from `creditEntitlements.balances.retrieve`:
|
|
319
355
|
|
|
320
|
-
**Checkout Route (`app/api/checkout/route.ts`):**
|
|
321
356
|
```typescript
|
|
322
|
-
|
|
323
|
-
import {
|
|
357
|
+
// app/api/usage/credits/route.ts
|
|
358
|
+
import { NextResponse } from 'next/server';
|
|
359
|
+
import { dodo } from '@/lib/dodopayments';
|
|
360
|
+
import { getCurrentUser } from '@/lib/auth';
|
|
324
361
|
|
|
325
|
-
|
|
326
|
-
const { productId, email } = await req.json();
|
|
362
|
+
const CREDIT_ENTITLEMENT_ID = process.env.DODO_CREDIT_ENTITLEMENT_ID;
|
|
327
363
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
364
|
+
export async function GET() {
|
|
365
|
+
const user = await getCurrentUser();
|
|
366
|
+
if (!user?.dodoCustomerId || !CREDIT_ENTITLEMENT_ID) {
|
|
367
|
+
return NextResponse.json({ error: 'No billing account' }, { status: 400 });
|
|
368
|
+
}
|
|
333
369
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
370
|
+
try {
|
|
371
|
+
const balance = await dodo.creditEntitlements.balances.retrieve(user.dodoCustomerId, {
|
|
372
|
+
credit_entitlement_id: CREDIT_ENTITLEMENT_ID,
|
|
373
|
+
});
|
|
337
374
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
export async function POST(req: NextRequest) {
|
|
345
|
-
const session = await getSession();
|
|
346
|
-
|
|
347
|
-
const portal = await dodo.customers.createPortalSession({
|
|
348
|
-
customer_id: session.user.customerId,
|
|
349
|
-
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
return NextResponse.json({ url: portal.url });
|
|
375
|
+
return NextResponse.json({ balance: balance.balance, overage: balance.overage });
|
|
376
|
+
} catch (error) {
|
|
377
|
+
console.error('Credit balance lookup failed', error);
|
|
378
|
+
return NextResponse.json({ error: 'Could not read balance' }, { status: 500 });
|
|
379
|
+
}
|
|
353
380
|
}
|
|
354
381
|
```
|
|
355
382
|
|
|
356
|
-
|
|
383
|
+
Pass the result into the meter block using the prop names in your installed
|
|
384
|
+
`components/billingsdk/usage-meter-linear.tsx`. Related blocks for the same surface: `usage-meter-circle`,
|
|
385
|
+
`usage-table`, `detailed-usage-table`, `upcoming-charges`, `invoice-history`.
|
|
357
386
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
{
|
|
362
|
-
id: process.env.NEXT_PUBLIC_PLAN_FREE_ID!,
|
|
363
|
-
name: 'Free',
|
|
364
|
-
description: 'Perfect for trying out',
|
|
365
|
-
price: 0,
|
|
366
|
-
interval: 'month' as const,
|
|
367
|
-
features: [
|
|
368
|
-
'5 projects',
|
|
369
|
-
'1 GB storage',
|
|
370
|
-
'Community support',
|
|
371
|
-
],
|
|
372
|
-
},
|
|
373
|
-
{
|
|
374
|
-
id: process.env.NEXT_PUBLIC_PLAN_PRO_ID!,
|
|
375
|
-
name: 'Pro',
|
|
376
|
-
description: 'For professionals',
|
|
377
|
-
price: 29,
|
|
378
|
-
interval: 'month' as const,
|
|
379
|
-
popular: true,
|
|
380
|
-
features: [
|
|
381
|
-
'Unlimited projects',
|
|
382
|
-
'50 GB storage',
|
|
383
|
-
'Priority support',
|
|
384
|
-
'API access',
|
|
385
|
-
],
|
|
386
|
-
},
|
|
387
|
-
];
|
|
387
|
+
Credit balances are eventually consistent — meter-to-credit deduction runs on a background worker roughly
|
|
388
|
+
once a minute — so the displayed balance is informational. Do not gate a request on it. Metering, credit
|
|
389
|
+
entitlements, and ledger entries are covered in the `usage-based-billing` and `credit-based-billing` skills.
|
|
388
390
|
|
|
389
|
-
|
|
390
|
-
returnUrl: process.env.NEXT_PUBLIC_APP_URL + '/success',
|
|
391
|
-
portalReturnUrl: process.env.NEXT_PUBLIC_APP_URL + '/account',
|
|
392
|
-
};
|
|
393
|
-
```
|
|
391
|
+
## Theming and customization
|
|
394
392
|
|
|
395
|
-
|
|
393
|
+
Components are Tailwind CSS plus shadcn/ui conventions, so there are three layers:
|
|
396
394
|
|
|
397
|
-
|
|
395
|
+
1. **Theme tokens.** Override the shadcn CSS variables in `globals.css`; every block inherits them.
|
|
398
396
|
|
|
399
|
-
|
|
397
|
+
```css
|
|
398
|
+
@layer base {
|
|
399
|
+
:root {
|
|
400
|
+
--primary: 220 90% 56%;
|
|
401
|
+
--primary-foreground: 0 0% 100%;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
```
|
|
400
405
|
|
|
401
|
-
|
|
406
|
+
2. **Component props.** `pricing-table-one` accepts `theme` (`"classic"` in the official example) and
|
|
407
|
+
`size` (`"medium"`). Check the installed file for the full union types.
|
|
402
408
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
3. **Component source modification** (files are local)
|
|
409
|
+
3. **Source edits.** The files are yours. Editing them is the supported path for structural changes, at
|
|
410
|
+
the cost of manual reconciliation when you re-run `add`.
|
|
406
411
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
/* globals.css */
|
|
410
|
-
@layer base {
|
|
411
|
-
:root {
|
|
412
|
-
--primary: 220 90% 56%;
|
|
413
|
-
--primary-foreground: 0 0% 100%;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
```
|
|
412
|
+
The hosted Dodo checkout page is themed separately, through `customization.theme_config` on the checkout
|
|
413
|
+
session — not by these components.
|
|
417
414
|
|
|
418
|
-
|
|
415
|
+
## Framework support
|
|
419
416
|
|
|
420
|
-
|
|
417
|
+
The components are **not** framework-agnostic. They require React or Next.js plus Tailwind CSS and
|
|
418
|
+
shadcn/ui conventions.
|
|
421
419
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
className="max-w-4xl mx-auto"
|
|
427
|
-
containerClassName="gap-8"
|
|
428
|
-
cardClassName="border-2"
|
|
429
|
-
/>
|
|
430
|
-
```
|
|
420
|
+
The CLI additionally ships server integration templates for Next.js, Express, Hono, Fastify, and React.
|
|
421
|
+
That covers the route-handler side only. Caution: Dodo's integration page and BillingSDK's own
|
|
422
|
+
introduction have disagreed about which server templates are shipping versus "coming soon" — confirm the
|
|
423
|
+
adapter you want with the current CLI before committing to it.
|
|
431
424
|
|
|
432
|
-
|
|
425
|
+
For non-React front ends, call Dodo through a framework adapter (`@dodopayments/nextjs`,
|
|
426
|
+
`@dodopayments/express`, `@dodopayments/sveltekit`, and others) and build your own UI.
|
|
433
427
|
|
|
434
|
-
## Environment
|
|
428
|
+
## Environment variables
|
|
435
429
|
|
|
436
430
|
```bash
|
|
437
|
-
# .env.local
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
431
|
+
# .env.local — server-side only, never prefixed with NEXT_PUBLIC_
|
|
432
|
+
DODO_PAYMENTS_API_KEY=dodo_test_xxxxxxxxxxxxxxxx
|
|
433
|
+
DODO_PAYMENTS_ENVIRONMENT=test_mode
|
|
434
|
+
DODO_PAYMENTS_WEBHOOK_KEY=xxxxxxxxxxxxxxxx
|
|
435
|
+
DODO_CREDIT_ENTITLEMENT_ID=cde_xxxxxxxxxxxxxxxx
|
|
442
436
|
|
|
443
|
-
#
|
|
444
|
-
NEXT_PUBLIC_PLAN_FREE_ID=prod_xxxxx
|
|
445
|
-
NEXT_PUBLIC_PLAN_PRO_ID=prod_xxxxx
|
|
446
|
-
NEXT_PUBLIC_PLAN_ENTERPRISE_ID=prod_xxxxx
|
|
447
|
-
|
|
448
|
-
# App
|
|
437
|
+
# Safe to expose
|
|
449
438
|
NEXT_PUBLIC_APP_URL=https://yoursite.com
|
|
450
439
|
```
|
|
451
440
|
|
|
452
|
-
|
|
441
|
+
Switch to `dodo_live_...` and `DODO_PAYMENTS_ENVIRONMENT=live_mode` together. A live key against
|
|
442
|
+
`test_mode` fails, and a test key against `live_mode` fails.
|
|
453
443
|
|
|
454
|
-
##
|
|
444
|
+
## Common mistakes
|
|
455
445
|
|
|
456
|
-
|
|
457
|
-
|
|
446
|
+
**Exposing the API key to the browser.** `NEXT_PUBLIC_DODO_PAYMENTS_API_KEY` inlines the secret into the
|
|
447
|
+
JS bundle for anyone to read. There is no publishable key in Dodo Payments — every key is secret. Import
|
|
448
|
+
`lib/dodopayments.ts` only from route handlers and server components.
|
|
458
449
|
|
|
459
|
-
|
|
460
|
-
|
|
450
|
+
**Calling `customers.createPortalSession`.** That method does not exist. Use
|
|
451
|
+
`client.customers.customerPortal.create(customerID, { ...params })` and read `.link`, not `.url`.
|
|
461
452
|
|
|
462
|
-
|
|
463
|
-
|
|
453
|
+
**Trusting client-rendered plan state.** The selected plan in React state, or the fact that the user
|
|
454
|
+
returned to `return_url`, does not mean the subscription is active. Grant entitlements only from
|
|
455
|
+
webhook-confirmed subscription events persisted in your database, then render UI from that. Webhook
|
|
456
|
+
signature verification is covered in the `webhook-integration` skill.
|
|
464
457
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
try {
|
|
468
|
-
const response = await fetch('/api/checkout', {...});
|
|
469
|
-
const { checkoutUrl } = await response.json();
|
|
470
|
-
window.location.href = checkoutUrl;
|
|
471
|
-
} finally {
|
|
472
|
-
setLoading(false);
|
|
473
|
-
}
|
|
474
|
-
};
|
|
475
|
-
```
|
|
458
|
+
**Accepting a raw `product_id` from the request body.** A caller can then check out against any product in
|
|
459
|
+
your catalogue, including internal or discounted ones. Map an opaque plan slug to a `pdt_` id server-side.
|
|
476
460
|
|
|
477
|
-
|
|
478
|
-
|
|
461
|
+
**Hardcoding prices in the client.** `PLANS[].price` is display text. When you change a price in the Dodo
|
|
462
|
+
dashboard, the checkout charges the new amount while the pricing page keeps advertising the old one.
|
|
463
|
+
Reconcile `lib/plans.ts` with the dashboard as part of any pricing change.
|
|
479
464
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
import { getSubscription } from '@/lib/subscription';
|
|
465
|
+
**`npm install billingsdk`.** The root repository package is private. Install through
|
|
466
|
+
`@billingsdk/cli` or the shadcn registry instead.
|
|
483
467
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
return <SubscriptionManagement subscription={subscription} />;
|
|
488
|
-
}
|
|
489
|
-
```
|
|
468
|
+
**Guessing prop names.** `pricing-table-one` is the only block with a published prop example. Open the
|
|
469
|
+
generated file under `components/billingsdk/` and read the exported props type before wiring anything else.
|
|
490
470
|
|
|
491
|
-
|
|
492
|
-
|
|
471
|
+
**Using `DODO_PAYMENTS_WEBHOOK_SECRET`.** The SDK reads `DODO_PAYMENTS_WEBHOOK_KEY` for the `webhookKey`
|
|
472
|
+
option.
|
|
493
473
|
|
|
494
|
-
|
|
474
|
+
**Gating requests on the displayed credit balance.** Deduction is asynchronous, so the reported balance
|
|
475
|
+
lags. Dodo explicitly warns against using it as strict per-request authorization.
|
|
476
|
+
|
|
477
|
+
**Using `payments.create` or `subscriptions.create` for a purchase.** Both are deprecated. Use
|
|
478
|
+
`checkoutSessions.create`.
|
|
495
479
|
|
|
496
480
|
## Resources
|
|
497
481
|
|
|
498
|
-
- [BillingSDK
|
|
499
|
-
- [
|
|
500
|
-
- [Component
|
|
501
|
-
- [
|
|
482
|
+
- [BillingSDK on Dodo docs](https://docs.dodopayments.com/developer-resources/billingsdk)
|
|
483
|
+
- [BillingSDK quick start](https://billingsdk.com/docs/quick-start)
|
|
484
|
+
- [Component catalogue](https://billingsdk.com/docs/components)
|
|
485
|
+
- [`registry.json` — installability source of truth](https://github.com/dodopayments/billingsdk/blob/main/registry.json)
|
|
486
|
+
- [Checkout Sessions guide](https://docs.dodopayments.com/developer-resources/checkout-session)
|
|
487
|
+
- [Customer Portal](https://docs.dodopayments.com/features/customer-portal)
|
|
488
|
+
- [Credit-based billing](https://docs.dodopayments.com/features/credit-based-billing)
|