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

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

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

目录

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

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

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

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

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

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

模型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处理程序日志中验证金额,然后再上线。我们还维护一个测试套件,在每个支持的货币上运行toStripeAmount和fromStripeAmount——它已多次捕获问题。

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