mcpField
D
discounts-and-promotions

discounts-and-promotions

github.com/dodopayments营销与增长13190 次安装3 周前更新
代码仓库

关于 discounts-and-promotions

Discounts and Promotions 是面向使用 Dodo Payments 的开发者的技能,涵盖折扣码管理、结账应用、资格校验及订阅计划变更。它说明百分比与固定金额折扣、限制规则、叠加顺序和计划变更时折扣的保留行为。

主要功能

  1. 01创建、查询、更新和删除折扣码
  2. 02配置折扣类型、资格与使用限制
  3. 03结账时应用及叠加多个折扣码
  4. 04按日期或订阅周期限制折扣
  5. 05管理订阅计划变更时的折扣保留

适用场景

  1. 01构建折扣码管理流程
  2. 02结账前核验折扣资格并显示价格
  3. 03为产品或客户设置优惠限制
  4. 04升级或降级订阅时保留、替换或清除折扣

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dodopayments/skills --skill discounts-and-promotions

安装后在 Claude Code 中输入/discounts-and-promotions确认已加载。

详细介绍

折扣和促销

本技能涵盖 Dodo Payments 中的折扣码、优惠券和促销定价。构建折扣管理功能、在结账时应用折扣码、向用户展示价格前验证折扣码,或在订阅套餐变更时处理折扣,都可以使用本技能。

何时使用本技能

  • 创建、更新、列出或删除折扣码
  • 在结账或套餐变更时应用折扣码
  • 向用户展示调整后的价格前验证折扣码
  • 配置资格规则、使用次数限制或订阅周期限制
  • 处理订阅升级或降级时保留折扣的细微语义
  • 排查折扣未按预期应用或叠加的原因

核心概念

折扣类型: Dodo 同时支持百分比折扣和固定金额折扣。百分比金额以整数表示,其中 1500 表示 15%(100 = 1%)。固定金额以货币的最小单位表示(美元以美分计)。

折扣码: 客户在结账时输入的易读字符串(例如,SUMMER2025)。创建时,API 会将折扣码转换为大写(summer2025 会存储为 SUMMER2025);折扣码至少须为 3 个字符。如果省略 code,则会生成一个随机的 16 字符大写折扣码。

资格: 折扣可以限制为特定产品、客户或日期范围。未设置限制的折扣适用于任何产品和任何客户。

叠加: 一次结账或一个订阅最多可应用 20 个折扣码,应用顺序由 discount_codes 数组指定。

订阅周期限制: 折扣可配置为仅适用于特定数量的计费周期(例如,仅适用于前 3 个月)。

套餐变更时的保留: 客户升级或降级订阅时,是否保留现有折扣取决于 preserve_on_plan_change 设置和 discount_codes 参数。

折扣 CRUD

创建折扣
import DodoPayments from 'dodopayments';

const client = new DodoPayments({
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
  environment: 'test_mode',
});

// Percentage discount: 15% off
const percentageDiscount = await client.discounts.create({
  type: 'percentage',
  amount: 1500, // 15%
  code: 'SUMMER2025',
  metadata: {
    campaign: 'summer_promo',
    source: 'email_blast'
  }
});

// Flat discount: $10 off (1000 cents in currency_options)
const flatDiscount = await client.discounts.create({
  type: 'flat',
  amount: 1000,
  currency_options: [{
    currency: 'USD',
    max_amount_possible: 1000,
    is_default: true,
  }],
  code: 'WELCOME10',
});
列出折扣
const discounts = await client.discounts.list({
  page_size: 50,
  page_number: 0,
});

// Paginated responses expose `items`.
for (const discount of discounts.items) {
  console.log(`${discount.code}: ${discount.type} ${discount.amount}`);
}

// Or let the SDK walk every page for you:
for await (const discount of client.discounts.list()) {
  console.log(discount.code);
}
按 ID 检索折扣
const discount = await client.discounts.retrieve('discount_id');
console.log(discount.code, discount.type, discount.amount);
按折扣码检索折扣
try {
  const discount = await client.discounts.retrieveByCode('SUMMER2025');
  console.log('Discount found:', discount.amount);
} catch (error) {
  console.log('Code not found or expired');
}
更新折扣
await client.discounts.update('discount_id', {
  metadata: { updated_at: new Date().toISOString() }
});
删除折扣
await client.discounts.delete('discount_id');

折扣类型和配置

支持两种类型:percentage 和 flat(在控制面板中显示为 金额)。flat 折扣需要在 currency_options 中提供至少一种可解析的默认货币;对于 percentage 折扣,currency_options 为可选项,并可用于限制每种货币的折扣上限。固定金额抵扣会分摊到整个购物车,而不是应用于每个商品行。

百分比折扣

以整数表示,其中 100 = 1%。15% 的折扣表示为 1500。

const discount = await client.discounts.create({
  type: 'percentage',
  amount: 1500, // 15%
  code: 'PERCENT15',
});
固定金额折扣

对于固定金额折扣,通过 currency_options 设置每种货币的抵扣金额。10 美元的抵扣金额表示为 max_amount_possible: 1000(美分)。

const discount = await client.discounts.create({
  type: 'flat',
  amount: 1000, // $10.00
  currency_options: [{
    currency: 'USD',
    max_amount_possible: 1000,
    is_default: true,
  }],
  code: 'FLAT10',
});

资格和限制

产品限制

将折扣限制为特定产品:

const discount = await client.discounts.create({
  type: 'percentage',
  amount: 1500,
  code: 'PREMIUM_ONLY',
  restricted_to: ['pdt_premium', 'pdt_enterprise'],
});
客户资格

创建折扣时设置 customer_eligibility: 'specific',然后通过折扣-客户端点关联客户,从而将折扣限制为特定客户。customer_ids 不是 discounts.create 参数。

const discount = await client.discounts.create({
  type: 'percentage',
  amount: 1500,
  code: 'VIP_ONLY',
  customer_eligibility: 'specific',
});

// The SDK has no named method for this endpoint yet. `client.post` reuses the
// client's auth and base URL (test.dodopayments.com for `test_mode`,
// live.dodopayments.com for `live_mode`), so nothing is hardcoded.
await client.post(`/discounts/${discount.discount_id}/customers`, {
  body: { customer_ids: ['cus_vip_001', 'cus_vip_002'] },
});
日期范围

设置生效日期和到期日期:

const discount = await client.discounts.create({
  type: 'percentage',
  amount: 1500,
  code: 'SUMMER2025',
  starts_at: '2025-06-01T00:00:00Z',
  expires_at: '2025-08-31T23:59:59Z',
});
使用次数限制

限制折扣码的总使用次数:

const discount = await client.discounts.create({
  type: 'percentage',
  amount: 1500,
  code: 'LIMITED_100',
  usage_limit: 100,
});
订阅周期限制

仅在前 N 个计费周期应用折扣:

const discount = await client.discounts.create({
  type: 'percentage',
  amount: 1500,
  code: 'FIRST_3_MONTHS',
  subscription_cycles: 3, // applies to first 3 billing cycles only
});

在结账时应用折扣码

单个折扣码

切勿在浏览器中调用 SDK:它会使用你的密钥 API 密钥。请在服务端路由中创建会话,并仅返回 URL。

// Server route
const session = await client.checkoutSessions.create({
  product_cart: [{ product_id: 'pdt_abc', quantity: 1 }],
  discount_codes: ['SUMMER2025'],
  customer: { email: '[email protected]' },
  return_url: 'https://yoursite.com/return'
});

return Response.json({ checkout_url: session.checkout_url });
// Browser
const res = await fetch('/api/checkout', { method: 'POST' });
const { checkout_url } = await res.json();
window.location.href = checkout_url;
多个折扣码(叠加)

折扣码按指定顺序应用。每次结账最多可使用 20 个折扣码:

const session = await client.checkoutSessions.create({
  product_cart: [{ product_id: 'pdt_abc', quantity: 1 }],
  discount_codes: ['WELCOME10', 'BLACKFRIDAY20', 'LOYALTY5'],
  customer: { email: '[email protected]' },
  return_url: 'https://yoursite.com/return'
});

结账前验证折扣码

在向用户展示调整后的价格前,验证折扣码并检查其资格:

async function validateDiscount(code, productId) {
  try {
    const discount = await client.discounts.retrieveByCode(code);
    
    // Check expiration
    if (discount.expires_at && new Date(discount.expires_at) < new Date()) {
      return { valid: false, reason: 'Code expired' };
    }
    
    // Check usage limit
    if (discount.usage_limit && discount.times_used >= discount.usage_limit) {
      return { valid: false, reason: 'Code exhausted' };
    }
    
    // Check product eligibility
    if (discount.restricted_to.length > 0) {
      if (!discount.restricted_to.includes(productId)) {
        return { valid: false, reason: 'Code not valid for this product' };
      }
    }
    
    return { valid: true, discount };
  } catch (error) {
    return { valid: false, reason: 'Code not found or unavailable' };
  }
}

// Usage
const result = await validateDiscount('SUMMER2025', 'pdt_abc');
if (result.valid) {
  console.log('Discount applies:', result.discount.amount);
} else {
  console.log('Cannot apply:', result.reason);
}

方案变更时的折扣

客户更改订阅方案时,是否保留、替换或清除折扣,取决于 discount_codes 参数和每项折扣的 preserve_on_plan_change 设置。通过 discounts.create 或 discounts.update 设置 preserve_on_plan_change;它不是 subscriptions.changePlan 参数。

行为矩阵
场景discount_codes折扣的 preserve_on_plan_change结果
升级且不变更折扣undefinedtrue现有折扣会保留
升级且不变更折扣undefinedfalse现有折扣会被移除
升级且不变更折扣[](空数组)任意值现有折扣会被移除
升级并替换折扣['NEW_CODE']任意值仅应用 NEW_CODE;旧折扣会被移除
示例:保留现有折扣
await client.discounts.update('dsc_existing', {
  preserve_on_plan_change: true,
});

await client.subscriptions.changePlan('sub_123', {
  product_id: 'pdt_pro',
  quantity: 1,
  proration_billing_mode: 'prorated_immediately',
  // Omit discount_codes. Existing discounts configured with
  // preserve_on_plan_change: true carry forward.
});
示例:升级时替换折扣
await client.subscriptions.changePlan('sub_123', {
  product_id: 'pdt_pro',
  quantity: 1,
  proration_billing_mode: 'prorated_immediately',
  discount_codes: ['UPGRADE20'], // replaces old discounts
});
示例:降级时移除所有折扣
await client.subscriptions.changePlan('sub_123', {
  product_id: 'pdt_basic',
  quantity: 1,
  proration_billing_mode: 'prorated_immediately',
  discount_codes: [], // empty array removes all discounts
});

常见错误

仅在客户端验证折扣

切勿信任客户端验证。结账时应用折扣前,务必在服务端进行验证。用户可以在浏览器中修改折扣码。

// WRONG: client-side only
if (code.length > 0) {
  applyDiscount(code);
}

// CORRECT: server-side validation
try {
  const discount = await client.discounts.retrieveByCode(code);
  if (isEligible(discount)) {
    // proceed with checkout
  }
} catch (error) {
  // Unknown or unavailable code; reject the checkout request.
}
误以为折扣码总是可以叠加

折扣会按顺序叠加,但仍需满足适用条件。如果某个折扣码仅适用于购物车中没有的商品,即使其他折扣码可以应用,该折扣码也不会生效。

// Both codes may not apply if they have conflicting restrictions
const session = await client.checkoutSessions.create({
  product_cart: [{ product_id: 'pdt_basic', quantity: 1 }],
  discount_codes: ['PREMIUM_ONLY', 'BASIC_ONLY'], // one or both may fail
  return_url: 'https://yoursite.com/return'
});
传入空数组导致升级时丢失折扣

显式传入 discount_codes: [] 会移除所有折扣,即使 preserve_on_plan_change 为 true 也是如此。如果要保留现有折扣,请完全省略此参数。

// WRONG: removes all discounts
await client.subscriptions.changePlan('sub_123', {
  product_id: 'pdt_pro',
  quantity: 1,
  proration_billing_mode: 'prorated_immediately',
  discount_codes: [], // this clears discounts
});

// CORRECT: preserves discounts whose preserve_on_plan_change property is true
await client.subscriptions.changePlan('sub_123', {
  product_id: 'pdt_pro',
  quantity: 1,
  proration_billing_mode: 'prorated_immediately',
  // omit discount_codes
});
未处理已过期或已用尽的折扣码

折扣码可能因到期或达到兑换次数上限而失效。向用户展示折扣前,务必检查这两项。

// WRONG: assumes code is always valid
const discount = await client.discounts.retrieveByCode(code);
applyDiscount(discount);

// CORRECT: catch unknown codes, then check expiration and usage
try {
  const discount = await client.discounts.retrieveByCode(code);
  if (discount.expires_at && new Date(discount.expires_at) < new Date()) {
    showError('Code expired');
  } else if (discount.usage_limit && discount.times_used >= discount.usage_limit) {
    showError('Code exhausted');
  } else {
    applyDiscount(discount);
  }
} catch (error) {
  showError('Code not found');
}
混淆订阅周期限制与到期日期

subscription_cycles 仅适用于订阅,用于控制折扣适用的计费周期数。expires_at 则是该折扣码所有用途的截止时间。

// Applies to first 3 billing cycles of any subscription
const discount = await client.discounts.create({
  type: 'percentage',
  amount: 1500,
  code: 'FIRST_3_MONTHS',
  subscription_cycles: 3,
});

// Expires on a specific date, regardless of billing cycles
const discount2 = await client.discounts.create({
  type: 'percentage',
  amount: 1500,
  code: 'SUMMER_ONLY',
  expires_at: '2025-08-31T23:59:59Z',
});

资源

评论

支持 Markdown