我已設定過 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 Dashboard 中配置它以允許計畫變更、取消(帶取消原因調查──數據很寶貴)和付款方法更新。這項工作單獨節省了我們大約 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 在連結過期時發送用戶的位置。這種情況發生的頻率比你想像的要多──如果某人在手機上開始上線,分心了,稍後回來。始終通過生成新連結來優雅地處理此問題。

佣金結構

當客戶預訂服務時,我們建立一個 PaymentIntent,其中包含 application_fee_amount

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 用於你的平台和你的連接帳戶。你需要一個單獨的 webhook 端點用於 Connect 事件:

// 常規 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),
    });
  }
}

在 Dashboard → Settings → Subscriptions and emails → Manage failed payments 中配置 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 事件) 中等
貨幣處理 區域定價矩陣 提供者的貨幣 捐贈者的貨幣 單一貨幣
試用支持 是,取決於地區 N/A N/A N/A
按比例分配 是,升級時 N/A N/A N/A
退款複雜度 按比例分配計算 平台費用反轉 簡單全額退款 簡單全額退款
客戶入口 必要 不需要 很好擁有 不需要
Stripe 費用(2025) 2.9% + 30¢ 2.9% + 30¢ + 0.5% Connect 2.9% + 30¢ 2.9% + 30¢

常見問題

我應該為分層定價建立多少個 Stripe 產品? 每個層級一個產品。因此,如果你有免費、基本、專業和高級,那就是四個產品。然後每個產品有多個價格──每個貨幣和計費間隔組合一個。具有 10 種貨幣的月度和年度帳單的專業層意味著該單一產品上有 20 個價格對象。聽起來很多,但 Stripe 處理得很好,它可以使你的目錄保持井井有條。

我能否將 Stripe Checkout 用於具有區域定價的訂閱? 是的,但你需要在建立 Checkout 階段之前確定客戶的地區,以便傳遞正確的價格 ID。我們使用 IP 地理定位(通過 Cloudflare 標頭)預先選擇貨幣,然後讓客戶確認或更改。不要依賴 Checkout 的自動貨幣──你想控制他們看到的價格。

Stripe Connect Express 帳戶和自定義帳戶之間有什麼區別? Express 帳戶讓 Stripe 處理你提供者的上線、身份驗證和儀表板。自定義帳戶讓你完全控制,但需要你自己構建所有這些。對於大多數市集,Express 是正確的選擇。我們從未遇到過損失控制能證明自定義工程成本合理的情況。Express 帳戶也會自動處理稅務報告(美國的 1099),這是一個巨大的合規性勝利。

我如何在不失去客戶的情況下處理失敗的訂閱付款? 分層三件事:Stripe 的智能重試(在 Dashboard 中啟用)、由 invoice.payment_failed webhook 觸發的自訂催款電子郵件,以及取消前的寬限期。我們在 3 次重試中提供 14 天。第一封電子郵件很友善(「嘿,你的卡可能已過期」),第二封更緊迫,第三封是最後警告。包括客戶入口的直接連結,他們可以在那裡更新他們的付款方法。僅此一項就恢復了大約 30-40% 的失敗付款。

我是否需要為 Stripe Connect 提供單獨的 webhook 端點? 是的。平台事件和 Connect 帳戶事件使用不同的 webhook 秘密和不同的事件結構。Connect 事件包含一個 account 欄位,用於識別事件涉及的連接帳戶。在 Stripe Dashboard 中註冊兩個端點:一個用於平台事件,一個用於 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 專案 中構建了這些模式,可以幫助你跳過昂貴的錯誤。檢查我們的 定價頁面了解參與模式──我們同時進行基於專案的構建和持續顧問。