subscription-integration
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseDodo Payments Subscription Integration
Dodo Payments 订阅集成
Implement recurring billing with trials, plan changes, and usage-based pricing.
实现包含试用、套餐变更及基于使用量定价的定期计费功能。
Quick Start
快速开始
1. Create Subscription Product
1. 创建订阅产品
In the dashboard (Products → Create Product):
- Select "Subscription" type
- Set billing interval (monthly, yearly, etc.)
- Configure pricing
在控制台中(产品 → 创建产品):
- 选择“订阅”类型
- 设置计费周期(月度、年度等)
- 配置定价
2. Create Checkout Session
2. 创建结账会话
typescript
import DodoPayments from 'dodopayments';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
});
const session = await client.checkoutSessions.create({
product_cart: [
{ product_id: 'prod_monthly_plan', quantity: 1 }
],
subscription_data: {
trial_period_days: 14, // Optional trial
},
customer: {
email: 'subscriber@example.com',
name: 'Jane Doe',
},
return_url: 'https://yoursite.com/success',
});
// Redirect to session.checkout_urltypescript
import DodoPayments from 'dodopayments';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
});
const session = await client.checkoutSessions.create({
product_cart: [
{ product_id: 'prod_monthly_plan', quantity: 1 }
],
subscription_data: {
trial_period_days: 14, // 可选试用周期
},
customer: {
email: 'subscriber@example.com',
name: 'Jane Doe',
},
return_url: 'https://yoursite.com/success',
});
// 重定向至session.checkout_url3. Handle Webhook Events
3. 处理Webhook事件
typescript
// subscription.active - Grant access
// subscription.cancelled - Schedule access revocation
// subscription.renewed - Log renewal
// payment.succeeded - Track paymentstypescript
// subscription.active - 授予访问权限
// subscription.cancelled - 安排访问权限回收
// subscription.renewed - 记录续费信息
// payment.succeeded - 跟踪支付记录Subscription Lifecycle
订阅生命周期
┌─────────────┐ ┌─────────┐ ┌────────┐
│ Created │ ──▶ │ Trial │ ──▶ │ Active │
└─────────────┘ └─────────┘ └────────┘
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌──────────┐ ┌───────────┐ ┌───────────┐
│ On Hold │ │ Cancelled │ │ Renewed │
└──────────┘ └───────────┘ └───────────┘
│ │
▼ ▼
┌──────────┐ ┌───────────┐
│ Failed │ │ Expired │
└──────────┘ └───────────┘┌─────────────┐ ┌─────────┐ ┌────────┐
│ 创建完成 │ ──▶ │ 试用中 │ ──▶ │ 活跃中 │
└─────────────┘ └─────────┘ └────────┘
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌──────────┐ ┌───────────┐ ┌───────────┐
│ 暂停中 │ │ 已取消 │ │ 已续费 │
└──────────┘ └───────────┘ └───────────┘
│ │
▼ ▼
┌──────────┐ ┌───────────┐
│ 支付失败 │ │ 已过期 │
└──────────┘ └───────────┘Webhook Events
Webhook事件
| Event | When | Action |
|---|---|---|
| Subscription starts | Grant access |
| Any field changes | Sync state |
| Payment fails | Notify user, retry |
| Successful renewal | Log, send receipt |
| Upgrade/downgrade | Update entitlements |
| User cancels | Schedule end of access |
| Mandate creation fails | Notify, retry options |
| Term ends | Revoke access |
| 事件 | 触发时机 | 操作 |
|---|---|---|
| 订阅启动时 | 授予访问权限 |
| 任何字段变更时 | 同步状态 |
| 支付失败时 | 通知用户,重试支付 |
| 续费成功时 | 记录信息,发送收据 |
| 套餐升级/降级时 | 更新权益 |
| 用户取消订阅时 | 安排访问权限终止时间 |
| 授权创建失败时 | 通知用户,提供重试选项 |
| 订阅期限结束时 | 回收访问权限 |
Implementation Examples
实现示例
Full Subscription Handler
完整订阅处理器
typescript
// app/api/webhooks/subscription/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function POST(req: NextRequest) {
const event = await req.json();
const data = event.data;
switch (event.type) {
case 'subscription.active':
await handleSubscriptionActive(data);
break;
case 'subscription.cancelled':
await handleSubscriptionCancelled(data);
break;
case 'subscription.on_hold':
await handleSubscriptionOnHold(data);
break;
case 'subscription.renewed':
await handleSubscriptionRenewed(data);
break;
case 'subscription.plan_changed':
await handlePlanChanged(data);
break;
case 'subscription.expired':
await handleSubscriptionExpired(data);
break;
}
return NextResponse.json({ received: true });
}
async function handleSubscriptionActive(data: any) {
const {
subscription_id,
customer,
product_id,
next_billing_date,
recurring_pre_tax_amount,
payment_frequency_interval,
} = data;
// Create or update user subscription
await prisma.subscription.upsert({
where: { externalId: subscription_id },
create: {
externalId: subscription_id,
userId: customer.customer_id,
email: customer.email,
productId: product_id,
status: 'active',
currentPeriodEnd: new Date(next_billing_date),
amount: recurring_pre_tax_amount,
interval: payment_frequency_interval,
},
update: {
status: 'active',
currentPeriodEnd: new Date(next_billing_date),
},
});
// Grant access
await prisma.user.update({
where: { id: customer.customer_id },
data: {
subscriptionStatus: 'active',
plan: product_id,
},
});
// Send welcome email
await sendWelcomeEmail(customer.email, product_id);
}
async function handleSubscriptionCancelled(data: any) {
const { subscription_id, customer, cancelled_at, cancel_at_next_billing_date } = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: {
status: 'cancelled',
cancelledAt: new Date(cancelled_at),
// Keep access until end of billing period if cancel_at_next_billing_date
accessEndsAt: cancel_at_next_billing_date
? new Date(data.next_billing_date)
: new Date(),
},
});
// Send cancellation email
await sendCancellationEmail(customer.email, cancel_at_next_billing_date);
}
async function handleSubscriptionOnHold(data: any) {
const { subscription_id, customer } = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: { status: 'on_hold' },
});
// Notify user about payment issue
await sendPaymentFailedEmail(customer.email);
}
async function handleSubscriptionRenewed(data: any) {
const { subscription_id, next_billing_date } = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: {
status: 'active',
currentPeriodEnd: new Date(next_billing_date),
},
});
}
async function handlePlanChanged(data: any) {
const { subscription_id, product_id, recurring_pre_tax_amount } = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: {
productId: product_id,
amount: recurring_pre_tax_amount,
},
});
// Update user entitlements based on new plan
await updateUserEntitlements(subscription_id, product_id);
}
async function handleSubscriptionExpired(data: any) {
const { subscription_id, customer } = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: { status: 'expired' },
});
// Revoke access
await prisma.user.update({
where: { id: customer.customer_id },
data: {
subscriptionStatus: 'expired',
plan: null,
},
});
}typescript
// app/api/webhooks/subscription/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function POST(req: NextRequest) {
const event = await req.json();
const data = event.data;
switch (event.type) {
case 'subscription.active':
await handleSubscriptionActive(data);
break;
case 'subscription.cancelled':
await handleSubscriptionCancelled(data);
break;
case 'subscription.on_hold':
await handleSubscriptionOnHold(data);
break;
case 'subscription.renewed':
await handleSubscriptionRenewed(data);
break;
case 'subscription.plan_changed':
await handlePlanChanged(data);
break;
case 'subscription.expired':
await handleSubscriptionExpired(data);
break;
}
return NextResponse.json({ received: true });
}
async function handleSubscriptionActive(data: any) {
const {
subscription_id,
customer,
product_id,
next_billing_date,
recurring_pre_tax_amount,
payment_frequency_interval,
} = data;
// 创建或更新用户订阅
await prisma.subscription.upsert({
where: { externalId: subscription_id },
create: {
externalId: subscription_id,
userId: customer.customer_id,
email: customer.email,
productId: product_id,
status: 'active',
currentPeriodEnd: new Date(next_billing_date),
amount: recurring_pre_tax_amount,
interval: payment_frequency_interval,
},
update: {
status: 'active',
currentPeriodEnd: new Date(next_billing_date),
},
});
// 授予访问权限
await prisma.user.update({
where: { id: customer.customer_id },
data: {
subscriptionStatus: 'active',
plan: product_id,
},
});
// 发送欢迎邮件
await sendWelcomeEmail(customer.email, product_id);
}
async function handleSubscriptionCancelled(data: any) {
const { subscription_id, customer, cancelled_at, cancel_at_next_billing_date } = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: {
status: 'cancelled',
cancelledAt: new Date(cancelled_at),
// 如果设置了cancel_at_next_billing_date,则保留访问权限至计费周期结束
accessEndsAt: cancel_at_next_billing_date
? new Date(data.next_billing_date)
: new Date(),
},
});
// 发送取消订阅邮件
await sendCancellationEmail(customer.email, cancel_at_next_billing_date);
}
async function handleSubscriptionOnHold(data: any) {
const { subscription_id, customer } = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: { status: 'on_hold' },
});
// 通知用户支付出现问题
await sendPaymentFailedEmail(customer.email);
}
async function handleSubscriptionRenewed(data: any) {
const { subscription_id, next_billing_date } = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: {
status: 'active',
currentPeriodEnd: new Date(next_billing_date),
},
});
}
async function handlePlanChanged(data: any) {
const { subscription_id, product_id, recurring_pre_tax_amount } = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: {
productId: product_id,
amount: recurring_pre_tax_amount,
},
});
// 根据新套餐更新用户权益
await updateUserEntitlements(subscription_id, product_id);
}
async function handleSubscriptionExpired(data: any) {
const { subscription_id, customer } = data;
await prisma.subscription.update({
where: { externalId: subscription_id },
data: { status: 'expired' },
});
// 回收访问权限
await prisma.user.update({
where: { id: customer.customer_id },
data: {
subscriptionStatus: 'expired',
plan: null,
},
});
}Subscription with Trial
带试用的订阅
typescript
const session = await client.checkoutSessions.create({
product_cart: [
{ product_id: 'prod_pro_monthly', quantity: 1 }
],
subscription_data: {
trial_period_days: 14,
},
customer: {
email: 'user@example.com',
name: 'John Doe',
},
return_url: 'https://yoursite.com/welcome',
});typescript
const session = await client.checkoutSessions.create({
product_cart: [
{ product_id: 'prod_pro_monthly', quantity: 1 }
],
subscription_data: {
trial_period_days: 14,
},
customer: {
email: 'user@example.com',
name: 'John Doe',
},
return_url: 'https://yoursite.com/welcome',
});Customer Portal for Self-Service
自助服务客户门户
Allow customers to manage their subscription:
typescript
// Create portal session
const portal = await client.customers.createPortalSession({
customer_id: 'cust_xxxxx',
return_url: 'https://yoursite.com/account',
});
// Redirect to portal.urlPortal features:
- View subscription details
- Update payment method
- Cancel subscription
- View billing history
允许客户管理自己的订阅:
typescript
// 创建门户会话
const portal = await client.customers.createPortalSession({
customer_id: 'cust_xxxxx',
return_url: 'https://yoursite.com/account',
});
// 重定向至portal.url门户功能:
- 查看订阅详情
- 更新支付方式
- 取消订阅
- 查看账单历史
On-Demand (Usage-Based) Subscriptions
按需(基于使用量)订阅
For metered/usage-based billing:
针对计量/基于使用量的计费场景:
Create Subscription with Mandate
创建带授权的订阅
typescript
const session = await client.checkoutSessions.create({
product_cart: [
{ product_id: 'prod_usage_based', quantity: 1 }
],
customer: { email: 'user@example.com' },
return_url: 'https://yoursite.com/success',
});typescript
const session = await client.checkoutSessions.create({
product_cart: [
{ product_id: 'prod_usage_based', quantity: 1 }
],
customer: { email: 'user@example.com' },
return_url: 'https://yoursite.com/success',
});Charge for Usage
针对使用量收费
typescript
// When usage occurs, create a charge
const charge = await client.subscriptions.charge({
subscription_id: 'sub_xxxxx',
amount: 1500, // $15.00 in cents
description: 'API calls for January 2025',
});typescript
// 当产生使用量时,创建收费记录
const charge = await client.subscriptions.charge({
subscription_id: 'sub_xxxxx',
amount: 1500, // 15.00美元,单位为分
description: '2025年1月API调用费用',
});Track Usage Events
跟踪使用量事件
typescript
// payment.succeeded - Charge succeeded
// payment.failed - Charge failed, implement retry logictypescript
// payment.succeeded - 收费成功
// payment.failed - 收费失败,实现重试逻辑Plan Changes
套餐变更
Upgrade/Downgrade Flow
升级/降级流程
typescript
// Get available plans
const plans = await client.products.list({
type: 'subscription',
});
// Change plan
await client.subscriptions.update({
subscription_id: 'sub_xxxxx',
product_id: 'prod_new_plan',
proration_behavior: 'create_prorations', // or 'none'
});typescript
// 获取可用套餐
const plans = await client.products.list({
type: 'subscription',
});
// 变更套餐
await client.subscriptions.update({
subscription_id: 'sub_xxxxx',
product_id: 'prod_new_plan',
proration_behavior: 'create_prorations', // 或 'none'
});Handling subscription.plan_changed
subscription.plan_changed处理subscription.plan_changed
事件
subscription.plan_changedtypescript
async function handlePlanChanged(data: any) {
const { subscription_id, product_id, customer } = data;
// Map product to features/limits
const planFeatures = getPlanFeatures(product_id);
await prisma.user.update({
where: { externalId: customer.customer_id },
data: {
plan: product_id,
features: planFeatures,
apiLimit: planFeatures.apiLimit,
storageLimit: planFeatures.storageLimit,
},
});
}typescript
async function handlePlanChanged(data: any) {
const { subscription_id, product_id, customer } = data;
// 将产品映射为功能/限制
const planFeatures = getPlanFeatures(product_id);
await prisma.user.update({
where: { externalId: customer.customer_id },
data: {
plan: product_id,
features: planFeatures,
apiLimit: planFeatures.apiLimit,
storageLimit: planFeatures.storageLimit,
},
});
}Access Control Pattern
访问控制模式
Middleware Example (Next.js)
中间件示例(Next.js)
typescript
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
// Check subscription status
const session = await getSession(request);
if (!session?.user) {
return NextResponse.redirect(new URL('/login', request.url));
}
const subscription = await getSubscription(session.user.id);
// Check if accessing premium feature
if (request.nextUrl.pathname.startsWith('/dashboard/pro')) {
if (!subscription || subscription.status !== 'active') {
return NextResponse.redirect(new URL('/pricing', request.url));
}
// Check if plan includes this feature
if (!subscription.features.includes('pro')) {
return NextResponse.redirect(new URL('/upgrade', request.url));
}
}
return NextResponse.next();
}typescript
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
// 检查订阅状态
const session = await getSession(request);
if (!session?.user) {
return NextResponse.redirect(new URL('/login', request.url));
}
const subscription = await getSubscription(session.user.id);
// 检查是否访问高级功能
if (request.nextUrl.pathname.startsWith('/dashboard/pro')) {
if (!subscription || subscription.status !== 'active') {
return NextResponse.redirect(new URL('/pricing', request.url));
}
// 检查套餐是否包含该功能
if (!subscription.features.includes('pro')) {
return NextResponse.redirect(new URL('/upgrade', request.url));
}
}
return NextResponse.next();
}React Hook for Subscription State
React Hook 订阅状态管理
typescript
// hooks/useSubscription.ts
import useSWR from 'swr';
export function useSubscription() {
const { data, error, mutate } = useSWR('/api/subscription', fetcher);
return {
subscription: data,
isLoading: !error && !data,
isError: error,
isActive: data?.status === 'active',
isPro: data?.plan?.includes('pro'),
refresh: mutate,
};
}
// Usage in component
function PremiumFeature() {
const { isActive, isPro } = useSubscription();
if (!isActive) {
return <UpgradePrompt />;
}
if (!isPro) {
return <ProUpgradePrompt />;
}
return <ActualFeature />;
}typescript
// hooks/useSubscription.ts
import useSWR from 'swr';
export function useSubscription() {
const { data, error, mutate } = useSWR('/api/subscription', fetcher);
return {
subscription: data,
isLoading: !error && !data,
isError: error,
isActive: data?.status === 'active',
isPro: data?.plan?.includes('pro'),
refresh: mutate,
};
}
// 组件中使用
function PremiumFeature() {
const { isActive, isPro } = useSubscription();
if (!isActive) {
return <UpgradePrompt />;
}
if (!isPro) {
return <ProUpgradePrompt />;
}
return <ActualFeature />;
}Common Patterns
常见模式
Grace Period for Failed Payments
支付失败宽限期
typescript
async function handleSubscriptionOnHold(data: any) {
const gracePeriodDays = 7;
await prisma.subscription.update({
where: { externalId: data.subscription_id },
data: {
status: 'on_hold',
gracePeriodEnds: new Date(Date.now() + gracePeriodDays * 24 * 60 * 60 * 1000),
},
});
// Schedule job to revoke access after grace period
await scheduleAccessRevocation(data.subscription_id, gracePeriodDays);
}typescript
async function handleSubscriptionOnHold(data: any) {
const gracePeriodDays = 7;
await prisma.subscription.update({
where: { externalId: data.subscription_id },
data: {
status: 'on_hold',
gracePeriodEnds: new Date(Date.now() + gracePeriodDays * 24 * 60 * 60 * 1000),
},
});
// 安排宽限期后回收访问权限的任务
await scheduleAccessRevocation(data.subscription_id, gracePeriodDays);
}Prorated Upgrades
升级按比例计费
When upgrading mid-cycle:
typescript
// Dodo handles proration automatically
// Customer pays difference for remaining days
await client.subscriptions.update({
subscription_id: 'sub_xxxxx',
product_id: 'prod_higher_plan',
proration_behavior: 'create_prorations',
});在计费周期中途升级时:
typescript
// Dodo会自动处理按比例计费
// 客户支付剩余周期的差价
await client.subscriptions.update({
subscription_id: 'sub_xxxxx',
product_id: 'prod_higher_plan',
proration_behavior: 'create_prorations',
});Cancellation with End-of-Period Access
取消订阅但保留权限至周期结束
typescript
// subscription.cancelled event includes:
// - cancel_at_next_billing_date: boolean
// - next_billing_date: string (when access should end)
if (data.cancel_at_next_billing_date) {
// Keep access until next_billing_date
await scheduleAccessRevocation(
data.subscription_id,
new Date(data.next_billing_date)
);
}typescript
// subscription.cancelled事件包含:
// - cancel_at_next_billing_date: 布尔值
// - next_billing_date: 字符串(访问权限应终止的时间)
if (data.cancel_at_next_billing_date) {
// 保留访问权限至next_billing_date
await scheduleAccessRevocation(
data.subscription_id,
new Date(data.next_billing_date)
);
}Testing
测试
Test Scenarios
测试场景
- New subscription →
subscription.active - Renewal success → +
subscription.renewedpayment.succeeded - Renewal failure → +
subscription.on_holdpayment.failed - Plan upgrade →
subscription.plan_changed - Cancellation →
subscription.cancelled - Expiration →
subscription.expired
- 新订阅 →
subscription.active - 续费成功 → +
subscription.renewedpayment.succeeded - 续费失败 → +
subscription.on_holdpayment.failed - 套餐升级 →
subscription.plan_changed - 取消订阅 →
subscription.cancelled - 订阅过期 →
subscription.expired
Test in Dashboard
在控制台中测试
Use test mode and trigger events manually from the webhook settings.
使用测试模式,从Webhook设置中手动触发事件。