subscription-integration

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Dodo 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_url
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, // 可选试用周期
  },
  customer: {
    email: 'subscriber@example.com',
    name: 'Jane Doe',
  },
  return_url: 'https://yoursite.com/success',
});

// 重定向至session.checkout_url

3. Handle Webhook Events

3. 处理Webhook事件

typescript
// subscription.active - Grant access
// subscription.cancelled - Schedule access revocation
// subscription.renewed - Log renewal
// payment.succeeded - Track payments

typescript
// subscription.active - 授予访问权限
// subscription.cancelled - 安排访问权限回收
// subscription.renewed - 记录续费信息
// payment.succeeded - 跟踪支付记录

Subscription Lifecycle

订阅生命周期

┌─────────────┐     ┌─────────┐     ┌────────┐
│   Created   │ ──▶ │  Trial  │ ──▶ │ Active │
└─────────────┘     └─────────┘     └────────┘
                    ┌────────────────────┼────────────────────┐
                    ▼                    ▼                    ▼
              ┌──────────┐        ┌───────────┐        ┌───────────┐
              │ On Hold  │        │ Cancelled │        │  Renewed  │
              └──────────┘        └───────────┘        └───────────┘
                    │                    │
                    ▼                    ▼
              ┌──────────┐        ┌───────────┐
              │  Failed  │        │  Expired  │
              └──────────┘        └───────────┘

┌─────────────┐     ┌─────────┐     ┌────────┐
│   创建完成   │ ──▶ │  试用中  │ ──▶ │  活跃中 │
└─────────────┘     └─────────┘     └────────┘
                    ┌────────────────────┼────────────────────┐
                    ▼                    ▼                    ▼
              ┌──────────┐        ┌───────────┐        ┌───────────┐
              │  暂停中  │        │   已取消   │        │   已续费   │
              └──────────┘        └───────────┘        └───────────┘
                    │                    │
                    ▼                    ▼
              ┌──────────┐        ┌───────────┐
              │  支付失败 │        │   已过期   │
              └──────────┘        └───────────┘

Webhook Events

Webhook事件

EventWhenAction
subscription.active
Subscription startsGrant access
subscription.updated
Any field changesSync state
subscription.on_hold
Payment failsNotify user, retry
subscription.renewed
Successful renewalLog, send receipt
subscription.plan_changed
Upgrade/downgradeUpdate entitlements
subscription.cancelled
User cancelsSchedule end of access
subscription.failed
Mandate creation failsNotify, retry options
subscription.expired
Term endsRevoke access

事件触发时机操作
subscription.active
订阅启动时授予访问权限
subscription.updated
任何字段变更时同步状态
subscription.on_hold
支付失败时通知用户,重试支付
subscription.renewed
续费成功时记录信息,发送收据
subscription.plan_changed
套餐升级/降级时更新权益
subscription.cancelled
用户取消订阅时安排访问权限终止时间
subscription.failed
授权创建失败时通知用户,提供重试选项
subscription.expired
订阅期限结束时回收访问权限

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.url
Portal 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 logic

typescript
// 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
事件

typescript
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

测试场景

  1. New subscription →
    subscription.active
  2. Renewal success →
    subscription.renewed
    +
    payment.succeeded
  3. Renewal failure →
    subscription.on_hold
    +
    payment.failed
  4. Plan upgrade →
    subscription.plan_changed
  5. Cancellation →
    subscription.cancelled
  6. Expiration →
    subscription.expired
  1. 新订阅 →
    subscription.active
  2. 续费成功 →
    subscription.renewed
    +
    payment.succeeded
  3. 续费失败 →
    subscription.on_hold
    +
    payment.failed
  4. 套餐升级 →
    subscription.plan_changed
  5. 取消订阅 →
    subscription.cancelled
  6. 订阅过期 →
    subscription.expired

Test in Dashboard

在控制台中测试

Use test mode and trigger events manually from the webhook settings.

使用测试模式,从Webhook设置中手动触发事件。

Resources

资源