关于 discounts-and-promotions
Discounts and Promotions 是面向使用 Dodo Payments 的开发者的技能,涵盖折扣码管理、结账应用、资格校验及订阅计划变更。它说明百分比与固定金额折扣、限制规则、叠加顺序和计划变更时折扣的保留行为。
主要功能
- 01创建、查询、更新和删除折扣码
- 02配置折扣类型、资格与使用限制
- 03结账时应用及叠加多个折扣码
- 04按日期或订阅周期限制折扣
- 05管理订阅计划变更时的折扣保留
适用场景
- 01构建折扣码管理流程
- 02结账前核验折扣资格并显示价格
- 03为产品或客户设置优惠限制
- 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 | 结果 |
|---|---|---|---|
| 升级且不变更折扣 | undefined | true | 现有折扣会保留 |
| 升级且不变更折扣 | undefined | false | 现有折扣会被移除 |
| 升级且不变更折扣 | [](空数组) | 任意值 | 现有折扣会被移除 |
| 升级并替换折扣 | ['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',
});