我运营的四种Stripe定价模式及其陷阱

在过去几年里,我已经设置过大约十几次Stripe集成,这是我学到的东西:入门教程很简单。难的部分是在同一个组织中运行四种完全不同的定价模型,处理30多个国家的货币而不出现舍入错误,并确保您的webhook处理程序不会在周六凌晨3点悄悄失败。

这不是另一篇"在5分钟内创建结账会话"的文章。我们将详细讲解我们构建和运营的四种生产定价模型——具有地区定价的分层订阅、通过Stripe Connect进行的市场佣金、与特定实体绑定的循环捐赠以及一次性服务付款。每一种都有其自身的陷阱,我将分享特定的代码模式和配置决策,这些决策使我们避免了痛苦的错误。

目录

订阅业务的最佳Stripe设置:我们运营的4种模型

为什么一个Stripe设置不适合所有人

Stripe的文档对于单一模式的业务非常出色。您选择订阅或一次性付款,按照指南操作,然后您就上线了。但是大多数真实的业务不会长期保持如此简单。

我们跨多个产品运营:一个具有分层订阅的SaaS平台、一个从提供商处获取佣金的市场、一个具有循环赞助的慈善项目,以及一个具有一次性付款的咨询预订系统。这些都在同一个Stripe账户下,但需要完全不同的产品、定价、webhook和客户管理配置。

我看到团队犯的最大错误是试图将所有计费强制纳入一个模型。订阅优先的架构在您需要一次性付款时会崩溃。仅结账会话方法在您需要具有按比例分摊的循环计费时会分崩离析。您需要将Stripe设置视为计费模式的投资组合。

如果您正在构建类似的东西——尤其是在前端使用Next.jsAstro的无头架构上——这些模式将为您节省数周的调试时间。

模型1:具有地区定价的分层订阅

这是我们运营的最复杂的模型,也是教会我们最痛苦的教训的模型。设置:四个层级(免费、基础、专业、高级),定价在30多个国家/地区有所不同。

Stripe中的产品结构

在Stripe中,每个层级都是一个单独的产品。每个产品有多个价格——每个货币/地区组合一个。这很重要:不要尝试使用单一价格并自己进行货币转换。Stripe的多货币定价是为此而专门构建的。

// 地区定价配置
const REGIONAL_PRICING = {
  pro: {
    USD: { monthly: 2900, yearly: 29000 },  // $29/月,$290/年
    EUR: { monthly: 2700, yearly: 27000 },  // €27/月,€270/年
    GBP: { monthly: 2300, yearly: 23000 },  // £23/月,£230/年
    JPY: { monthly: 4200, yearly: 42000 },  // ¥4,200/月 -- 不是 ¥42.00!
    KRW: { monthly: 38000, yearly: 380000 }, // ₩38,000/月
    INR: { monthly: 190000, yearly: 1900000 }, // ₹1,900/月
    BRL: { monthly: 14900, yearly: 149000 }, // R$149/月
  },
  // ... 对基础和高级重复
};

注意那些JPY和KRW的值?我稍后会详细讲解那个错误,但简短版本:这些是零小数位货币。当您为JPY传递4200时,Stripe会将其解释为¥4,200——而不是¥42.00。如果您像处理USD一样乘以100,您刚刚向某人收费了¥420,000(2,800美元)而不是¥4,200(28美元)。问我是怎么知道的。

地区试用逻辑

并不是每个地区都可以获得免费试用。我们在某些东南亚市场中以困难的方式学到了这一点,其中试用滥用的情况明显高于其他地区。我们的配置看起来像这样:

const TRIAL_CONFIG = {
  default_trial_days: 14,
  excluded_regions: ['VN', 'PH', 'ID', 'TH', 'MM', 'KH', 'LA'],
  reduced_trial_regions: {
    IN: 7,
    BR: 7,
  },
};

function getTrialDays(countryCode) {
  if (TRIAL_CONFIG.excluded_regions.includes(countryCode)) {
    return 0;
  }
  return TRIAL_CONFIG.reduced_trial_regions[countryCode] 
    ?? TRIAL_CONFIG.default_trial_days;
}

这被传递到订阅创建中:

const subscription = await stripe.subscriptions.create({
  customer: customerId,
  items: [{ price: regionalPriceId }],
  trial_period_days: getTrialDays(customer.address.country),
  payment_behavior: 'default_incomplete',
  payment_settings: {
    save_default_payment_method: 'on_subscription',
  },
  expand: ['latest_invoice.payment_intent'],
});

按比例分摊行为

当某人在周期中途从基础升级到专业时,您需要决定:他们立即支付差额,还是在下一个计费周期支付?我们使用create_prorations进行即时付款:

const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
  items: [{
    id: existingItemId,
    price: newPriceId,
  }],
  proration_behavior: 'create_prorations',
  payment_behavior: 'pending_if_incomplete',
});

对于降级,我们计划在计费周期结束时进行更改。没有人希望在发票上看到意外的信用计算。

客户门户

Stripe的客户门户被低估了。与其构建自己的订阅管理UI,不如配置门户并将用户重定向到那里:

const portalSession = await stripe.billingPortal.sessions.create({
  customer: customerId,
  return_url: `${process.env.APP_URL}/settings/billing`,
});

在Stripe仪表板中配置它以允许计划变更、取消(带有取消原因调查——数据很宝贵)和付款方式更新。仅这一项就为我们节省了大约40小时的前端开发时间。

模型2:通过Stripe Connect进行的市场佣金

我们的市场模式使用Stripe Connect促进客户和服务提供商之间的支付。平台对每笔交易收取佣金。这是大多数教程都忽略的Stripe Connect设置。

提供商入职

市场上的每个提供商都需要一个Stripe Express账户。入职流程创建账户并将其重定向到Stripe的托管入职:

const account = await stripe.accounts.create({
  type: 'express',
  country: provider.country,
  email: provider.email,
  capabilities: {
    card_payments: { requested: true },
    transfers: { requested: true },
  },
  business_type: 'individual',
  metadata: {
    provider_id: provider.id,
    platform: 'fme',
  },
});

const accountLink = await stripe.accountLinks.create({
  account: account.id,
  refresh_url: `${process.env.APP_URL}/provider/onboarding/refresh`,
  return_url: `${process.env.APP_URL}/provider/onboarding/complete`,
  type: 'account_onboarding',
});

关键细节:refresh_url是Stripe在链接过期时发送用户的地方。这发生的频率比您想象的要高——如果有人在手机上开始入职,分心了,稍后回来。始终通过生成新链接来优雅地处理这个问题。

佣金结构

当客户预订服务时,我们创建一个带有application_fee_amount的PaymentIntent:

const paymentIntent = await stripe.paymentIntents.create({
  amount: bookingAmountInCents,
  currency: 'usd',
  application_fee_amount: Math.round(bookingAmountInCents * 0.15), // 15% 平台费用
  transfer_data: {
    destination: providerStripeAccountId,
  },
  metadata: {
    booking_id: booking.id,
    provider_id: provider.id,
    customer_id: customer.id,
  },
});

15%的佣金归平台所有。剩余的85%(扣除Stripe的处理费)转入提供商的Express账户。

支付安排

默认情况下,Stripe按滚动基础向Express账户支付。我们将其覆盖为每周支付,这为我们提供了退款和争议的缓冲:

await stripe.accounts.update(providerStripeAccountId, {
  settings: {
    payouts: {
      schedule: {
        interval: 'weekly',
        weekly_anchor: 'friday',
      },
    },
  },
});

周五支付意味着提供商在周一之前看到他们账户中的钱。这是一个小事情,但对提供商的满意度和留任率非常重要。

Connect特定的Webhook

通过Stripe Connect,您会收到来自您的平台和连接账户的webhook。您需要一个单独的Connect事件webhook端点:

// 常规webhook端点
app.post('/webhooks/stripe', handlePlatformWebhooks);

// Connect webhook端点
app.post('/webhooks/stripe-connect', handleConnectWebhooks);

Connect webhook处理程序需要以不同的方式验证事件并检查account字段:

async function handleConnectWebhooks(req, res) {
  const event = stripe.webhooks.constructEvent(
    req.body,
    req.headers['stripe-signature'],
    process.env.STRIPE_CONNECT_WEBHOOK_SECRET // 不同的秘密!
  );
  
  const connectedAccountId = event.account;
  // 现在在连接账户的背景下处理事件
}

订阅业务的最佳Stripe设置:我们运营的4种模型 - 架构

模型3:与实体绑定的循环捐赠

这个针对我们正在构建的动物慈善倡议——考虑"赞助特定动物"每月循环捐赠。捐赠者选择一个动物,设置月度金额,并获得照片更新。

实体链接的订阅

这里的技巧是将Stripe订阅链接到您数据库中的特定实体(动物)。我们完全通过元数据完成此操作:

const subscription = await stripe.subscriptions.create({
  customer: donorCustomerId,
  items: [{
    price_data: {
      currency: 'usd',
      product: sponsorshipProductId,
      unit_amount: donorChosenAmount, // 捐赠者选择他们的金额
      recurring: {
        interval: 'month',
      },
    },
  }],
  metadata: {
    entity_id: animal.id,
    entity_type: 'animal',
    entity_name: animal.name,
    sponsor_email: donor.email,
  },
});

使用price_data而不是预先创建的价格允许捐赠者选择自己的月度金额。这比创建数百个价格对象更清晰。

月度更新电子邮件

当赞助订阅的invoice.paid触发时,我们触发月度更新流程:

async function handleSponsorshipInvoicePaid(invoice) {
  const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
  const entityId = subscription.metadata.entity_id;
  
  // 使用最新照片队列月度更新电子邮件
  await emailQueue.add('sponsorship-update', {
    donorEmail: subscription.metadata.sponsor_email,
    entityId,
    invoiceAmount: invoice.amount_paid,
    invoicePdf: invoice.invoice_pdf,
  });
}

电子邮件包含发票PDF(Stripe自动生成这些)、被赞助动物的最新照片和护理更新。这是一个小接触,可以显著减少循环捐赠的流失。

处理捐赠取消

当有人取消其赞助时,您需要与SaaS取消的处理方式不同。没有"降级"——要么取消要么什么都没有。但您希望以后更容易重新订阅:

async function handleSponsorshipCancellation(subscription) {
  const entityId = subscription.metadata.entity_id;
  
  // 将赞助标记为不活跃,而不是删除
  await db.sponsorships.update({
    where: { stripeSubscriptionId: subscription.id },
    data: { 
      status: 'inactive',
      cancelledAt: new Date(),
    },
  });
  
  // 发送"我们会想念您"电子邮件,并提供简单的重新订阅链接
  await sendCancellationEmail(subscription.metadata.sponsor_email, entityId);
}

模型4:一次性服务付款

最简单的模型,但仍有重要的细节。此模式适用于咨询预订,其中某人支付一次并获得服务——不重复计费。

带有预订数据的结账会话

const session = await stripe.checkout.sessions.create({
  mode: 'payment',
  line_items: [{
    price: consultationPriceId,
    quantity: 1,
  }],
  customer_email: customer.email,
  metadata: {
    booking_id: booking.id,
    service_type: 'consultation',
    appointment_date: booking.date.toISOString(),
    practitioner_id: booking.practitionerId,
  },
  success_url: `${process.env.APP_URL}/booking/confirmed?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${process.env.APP_URL}/booking/${booking.id}`,
  expires_after: 1800, // 30分钟
  payment_intent_data: {
    metadata: {
      booking_id: booking.id,
    },
  },
});

有两点需要注意。首先:expires_after可防止废弃的结账会话无限期存在。预订时隙不应无限期保留。其次:我们在payment_intent_data.metadata中复制booking_id,因为PaymentIntent元数据与结账会话元数据分开。当您收到payment_intent.succeeded webhook时,您会希望该预订ID就在那里。

付款+预订确认

checkout.session.completed上,我们一次性确认预订并发送所有内容:

async function handleCheckoutComplete(session) {
  const bookingId = session.metadata.booking_id;
  
  // 确认预订
  const booking = await db.bookings.update({
    where: { id: bookingId },
    data: { 
      status: 'confirmed',
      paymentSessionId: session.id,
      paidAt: new Date(),
    },
  });
  
  // 向客户发送确认
  await sendBookingConfirmation(session.customer_email, booking);
  
  // 通知从业者
  await notifyPractitioner(booking.practitionerId, booking);
}

真正起作用的Webhook架构

在所有四个模型中,webhook都是骨干。这是我们经过太多调试会话后确定的架构:

const WEBHOOK_HANDLERS = {
  'checkout.session.completed': handleCheckoutComplete,
  'invoice.paid': handleInvoicePaid,
  'invoice.payment_failed': handlePaymentFailed,
  'customer.subscription.created': handleSubscriptionCreated,
  'customer.subscription.updated': handleSubscriptionUpdated,
  'customer.subscription.deleted': handleSubscriptionDeleted,
  'account.updated': handleConnectAccountUpdated,
  'payment_intent.succeeded': handlePaymentSucceeded,
};

async function webhookHandler(req, res) {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.rawBody, // 您需要原始正文,而不是解析的JSON
      req.headers['stripe-signature'],
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error('Webhook签名验证失败:', err.message);
    return res.status(400).send();
  }

  // 幂等性:检查我们是否已处理此事件
  const processed = await db.webhookEvents.findUnique({
    where: { stripeEventId: event.id },
  });
  if (processed) {
    return res.status(200).json({ received: true, duplicate: true });
  }

  const handler = WEBHOOK_HANDLERS[event.type];
  if (handler) {
    try {
      await handler(event.data.object, event);
      await db.webhookEvents.create({
        data: { stripeEventId: event.id, type: event.type, processedAt: new Date() },
      });
    } catch (err) {
      console.error(`处理${event.type}时出错:`, err);
      return res.status(500).send(); // Stripe将重试
    }
  }

  res.status(200).json({ received: true });
}

幂等性检查至关重要。Stripe将重试失败的webhook,您绝对不想处理同一事件两次——尤其是对于创建预订或触发支付的操作。

失败支付重试逻辑和催款

Stripe具有内置的智能重试,但您应该在顶部添加自己的催款逻辑:

async function handlePaymentFailed(invoice) {
  const attemptCount = invoice.attempt_count;
  const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
  
  if (attemptCount === 1) {
    // 首次失败:温和推动
    await sendEmail(invoice.customer_email, 'payment-failed-soft', {
      updatePaymentUrl: await createPortalLink(invoice.customer),
    });
  } else if (attemptCount === 2) {
    // 第二次失败:更紧急
    await sendEmail(invoice.customer_email, 'payment-failed-urgent', {
      updatePaymentUrl: await createPortalLink(invoice.customer),
      daysUntilCancellation: 7,
    });
  } else if (attemptCount >= 3) {
    // 最后警告
    await sendEmail(invoice.customer_email, 'payment-failed-final', {
      updatePaymentUrl: await createPortalLink(invoice.customer),
    });
  }
}

在仪表板中配置Stripe的重试计划 → 设置 → 订阅和电子邮件 → 管理失败的付款。我们在取消前使用3次重试,跨越14天。

花钱教训的零小数位货币错误

这值得拥有自己的部分,因为这是一个最终困扰每个人的错误。Stripe对大多数货币使用美分(最小货币单位)。$29.00变成2900。但某些货币没有小数位。

以下是重要的零小数位货币:

货币 代码 示例:"$29等价物" 您传递给Stripe的内容
日本日元 JPY ¥4,200 4200(不是420000
韩国元 KRW ₩38,000 38000(不是3800000
越南盾 VND ₫700,000 700000
智利比索 CLP $25,000 25000
巴拉圭瓜拉尼 PYG ₲200,000 200000

这是我们在任何地方使用的实用程序函数:

const ZERO_DECIMAL_CURRENCIES = [
  'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW',
  'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF',
  'XOF', 'XPF',
];

function toStripeAmount(amount, currency) {
  const curr = currency.toUpperCase();
  if (ZERO_DECIMAL_CURRENCIES.includes(curr)) {
    return Math.round(amount); // 已在最小单位中
  }
  return Math.round(amount * 100);
}

function fromStripeAmount(stripeAmount, currency) {
  const curr = currency.toUpperCase();
  if (ZERO_DECIMAL_CURRENCIES.includes(curr)) {
    return stripeAmount;
  }
  return stripeAmount / 100;
}

在任何地方使用它。在您的结账创建中、在您的webhook处理程序中、在您的仪表板显示中。任何地方。您忘记的一次是您向某人收费100倍预期金额的时间。

四种模型的比较

方面 分层订阅 市场 循环捐赠 一次性付款
Stripe产品 多个产品,每个产品多个价格 每个服务类型一个产品 单一产品,动态定价 单一产品,固定价格
计费模式 subscription payment与Connect subscription payment
Webhook复杂性 高(生命周期事件) 高(Connect事件) 中等
货币处理 地区定价矩阵 提供商的货币 捐赠者的货币 单一货币
试用支持 是,取决于地区 不适用 不适用 不适用
按比例分摊 是,升级时 不适用 不适用 不适用
退款复杂性 按比例分摊计算 平台费用撤销 简单全额退款 简单全额退款
客户门户 必不可少 不需要 很好有 不需要
Stripe费用(2025) 2.9% + 30¢ 2.9% + 30¢ + 0.5% Connect 2.9% + 30¢ 2.9% + 30¢

常见问题

我应该为分层定价创建多少个Stripe产品? 每个层级一个产品。所以如果您有免费、基础、专业和高级,那就是四个产品。然后每个产品有多个价格——每个货币和计费间隔组合一个。具有月度和年度计费的Pro层级跨越10种货币意味着该单个产品上有20个价格对象。听起来像很多,但Stripe处理得很好,这使您的目录保持井然有序。

我可以将Stripe结账用于具有地区定价的订阅吗? 是的,但您需要在创建结账会话之前确定客户的地区,以便您可以传递正确的价格ID。我们使用IP地理位置(通过Cloudflare头)预先选择货币,然后让客户确认或更改它。不要依赖结账的自动货币——您希望控制他们看到的价格。

Stripe Connect Express和Custom账户之间有什么区别? Express账户让Stripe处理您的提供商的入职、身份验证和仪表板。Custom账户为您提供完全控制,但需要您自己构建所有这些。对于大多数市场,Express是正确的选择。我们从未遇到过丧失控制权使工程成本值得的情况。Express账户也自动处理税务报告(美国的1099s),这是一个巨大的合规优势。

我如何处理失败的订阅付款而不失去客户? 分层三个东西:Stripe的智能重试(在仪表板中启用)、由invoice.payment_failed webhook触发的自定义催款电子邮件,以及取消前的宽限期。我们在3次重试尝试中给予14天。第一封电子邮件是友好的("嘿,您的卡可能已过期"),第二封是紧急的,第三封是最后警告。包含直接链接到客户门户,他们可以在其中更新其付款方式。仅这一项就恢复了大约30-40%的失败付款。

我是否需要为Stripe Connect提供单独的webhook端点? 是的。平台事件和Connect账户事件使用不同的webhook密钥和不同的事件结构。Connect事件包括一个标识事件所关联的连接账户的account字段。在Stripe仪表板中注册两个端点:一个用于平台事件,一个用于Connect事件。这种分离也使调试变得更加容易。

什么是零小数位货币,我为什么应该关心? 零小数位货币,如JPY(日本日元)和KRW(韩国元)不使用小数单位。当Stripe说"最小货币单位的金额"时,对于USD,这是美分(2900 = $29.00),但对于JPY,它是日元(4200 = ¥4,200)。如果您像处理USD一样乘以100,您收费₩420,000 而不是¥4,200。始终使用在转换前检查货币的辅助函数。Stripe在其文档中维护官方零小数位货币列表。

我应该使用Stripe Billing的客户门户还是构建自己的? 使用客户门户进行订阅管理,除非您有非常具体的UI需求。它开箱即用处理计划变更、取消、付款方式更新和发票历史记录。您可以自定义品牌并配置允许哪些操作。构建自己的门户意味着自己处理按比例分摊计算、支付方式标记化和SCA/3D Secure流程。门户是免费的——它包含在您的Stripe订阅费中。

我如何在本地测试地区定价和货币处理? Stripe的测试模式支持所有货币。在您计划支持的每种货币中创建测试价格,然后使用Stripe CLI将webhook转发到您的本地服务器:stripe listen --forward-to localhost:3000/webhooks/stripe。对于零小数位货币测试,特别是创建一个JPY价格并在webhook处理程序日志中验证金额,然后再上线。我们还维护一个测试套件,在每个支持的货币上运行toStripeAmountfromStripeAmount——它已多次捕获问题。

如果您正在构建基于订阅的产品并需要帮助解决计费架构问题,或者如果您正在将Stripe与无头CMS集成,请联系我们。我们已经在多个无头CMS项目上构建了这些模式,可以帮助您避免昂贵的错误。查看我们的定价页面了解互动模型——我们提供基于项目的构建和持续咨询。