mcpField
R
recur-checkout

recur-checkout

github.com/recur-tw开发037 次安装3 周前更新
代码仓库

关于 recur-checkout

recur-checkout 是使用 recur-tw 实现 Recur 支付结账流程的技能,涵盖托管式、弹窗式和嵌入式结账。内容包括 React 与服务端示例、商品列表及支付结果处理。

主要功能

  1. 01实现托管式、弹窗式和嵌入式结账
  2. 02使用 React hooks 处理结账与订阅
  3. 03按类型列出商品
  4. 04处理支付完成、失败和取消
  5. 05在服务端创建托管结账会话

适用场景

  1. 01添加订阅或一次性购买结账
  2. 02提供弹窗或嵌入式结账界面
  3. 03构建商品选择或定价页面
  4. 04从服务端创建托管结账会话

MCP 相关

输入介绍了 Recur 结账实现指南,未提及 MCP。

安装

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

$npx skills add https://github.com/recur-tw/skills --skill recur-checkout

安装后在 Claude Code 中输入/recur-checkout确认已加载。

详细介绍

Recur 结账集成

你正在帮助实现 Recur 结账流程。以下内容均已根据 recur-tw 类型定义(RecurContextValue、UseSubscribeResult、CheckoutOptions、 RedirectToCheckoutOptions、SubscriptionResult)核对。产品 ID 是来自 list_products / 控制台的 CUID(例如 cmfxq8n2a0001l8yz3k5p9t7d);示例中的 prod_xxx 是占位符。你也可以传入 productSlug。

结账模式

模式方式适用场景
托管式(推荐)useRecur().redirectToCheckout() → checkout.recur.tw任何应用,可在 localhost 上使用
模态框useSubscribe() / useRecur().checkout(),并在 provider 中设置 checkoutMode: 'modal'无需离开页面即可快速购买
嵌入式使用相同的 hooks,在 provider 中设置 checkoutMode: 'embedded' + containerElementId自定义结账页面

checkoutMode 在 <RecurProvider config> 上设置,而不是在调用时设置。provider 的默认值是 'embedded',如果没有 containerElementId 就会报错——需要弹出窗口时,请设置 checkoutMode: 'modal'。任何调用都没有 mode: 'hosted' | 'modal' 选项。

托管式结账(推荐)

只有 checkout() 会设置 isCheckingOut;redirectToCheckout() 会创建会话并 跳转,因此需要自行跟踪待处理状态。

'use client'

import { useState } from 'react'
import { useRecur } from 'recur-tw'

function CheckoutButton({ productId }: { productId: string }) {
  const { redirectToCheckout } = useRecur()
  const [redirecting, setRedirecting] = useState(false)

  const handleClick = async () => {
    setRedirecting(true)
    try {
      await redirectToCheckout({
        productId,                            // or productSlug: 'pro-plan'
        successUrl: `${window.location.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
        cancelUrl: `${window.location.origin}/pricing`,
        customerEmail: '[email protected]',    // optional, pre-fills the checkout page
        customerName: 'John Doe',             // optional
        externalCustomerId: 'user_123',       // optional, links to your user system
      })
    } catch (err) {
      setRedirecting(false)
      console.error('Failed to start checkout:', err)
    }
  }

  return (
    <button onClick={handleClick} disabled={redirecting}>
      {redirecting ? 'Redirecting...' : 'Subscribe'}
    </button>
  )
}

浏览器会跳转,不会触发任何回调。请在成功页面上(通过会话 ID)或通过 webhook 确认付款。

模态框 / 嵌入式结账

使用 useSubscribe(模态框推荐)

回调是 hook 的选项;subscribe() 只接收结账选项。

'use client'

import { useSubscribe } from 'recur-tw'
import { useRouter } from 'next/navigation'

function SubscribeButton({ productId }: { productId: string }) {
  const router = useRouter()
  const { subscribe, isLoading, error, reset } = useSubscribe({
    onPaymentComplete: (subscription) => {
      // subscription: SubscriptionResult — id, status, productId, amount, currentPeriodEnd
      router.push('/dashboard')
    },
    onPaymentFailed: (error) => {
      console.error('Failed:', error.code, error.message)
      return { action: 'retry' } // or 'close' or 'custom'
    },
    onPaymentCancel: () => console.log('User cancelled'),
  })

  return (
    <>
      <button
        onClick={() => subscribe({ productId, customerEmail: '[email protected]' })}
        disabled={isLoading}
      >
        {isLoading ? 'Processing...' : 'Subscribe'}
      </button>
      {error && <p className="error" onClick={reset}>{error.message}</p>}
    </>
  )
}

useSubscribe() 返回 { subscribe, mutate, isLoading, error, reset }——没有 subscription 字段;请使用 onPaymentComplete 获取结果。

使用 useRecur().checkout(内联回调)
'use client'

import { useRecur } from 'recur-tw'

function CheckoutButton({ productId }: { productId: string }) {
  const { checkout, isCheckingOut } = useRecur()

  const handleClick = () =>
    checkout({
      productId,
      customerEmail: '[email protected]',    // optional; collected in the form if omitted
      customerName: 'John Doe',
      externalCustomerId: 'user_123',
      onPaymentComplete: (subscription) => console.log('Success!', subscription.id, subscription.status),
      onPaymentFailed: (error) => ({ action: 'retry' }),
      onPaymentCancel: () => console.log('User cancelled'),
    })

  return (
    <button onClick={handleClick} disabled={isCheckingOut}>
      {isCheckingOut ? 'Processing...' : 'Subscribe'}
    </button>
  )
}
模态框 / 嵌入式模式的 provider 设置
// Modal (popup)
<RecurProvider
  config={{
    publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY!,
    checkoutMode: 'modal',
  }}
>
  {children}
</RecurProvider>

// Embedded (inline form) — the container must exist in the DOM
<RecurProvider
  config={{
    publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY!,
    checkoutMode: 'embedded',
    containerElementId: 'recur-checkout-container',
  }}
>
  {children}
</RecurProvider>

function CheckoutPage() {
  return (
    <div>
      <h1>Complete Your Purchase</h1>
      {/* Recur renders the payment form here */}
      <div id="recur-checkout-container" />
    </div>
  )
}

模态框 / 嵌入式结账需要已注册的域名;在 localhost 上请使用托管式结账。

选项类型

// redirectToCheckout()
interface RedirectToCheckoutOptions {
  productId?: string          // or productSlug — one is required
  productSlug?: string
  mode?: 'PAYMENT' | 'SUBSCRIPTION' | 'SETUP'  // usually inferred from the product
  successUrl: string
  cancelUrl: string
  customerEmail?: string
  customerName?: string
  externalCustomerId?: string
}

// checkout() / subscribe()
interface CheckoutOptions {
  productId?: string
  productSlug?: string
  customerEmail?: string
  customerName?: string
  externalCustomerId?: string
  successUrl?: string         // used if 3-D Secure needs a redirect
  cancelUrl?: string
  // checkout() only — for subscribe() pass these to useSubscribe():
  onPaymentComplete?: (subscription: SubscriptionResult) => void
  onPaymentFailed?: (error: CheckoutError) => PaymentFailedAction | void
  onPaymentCancel?: () => void
  onSuccess?: (result: CheckoutResult) => void   // session created (before payment)
  onError?: (error: CheckoutError) => void
}

interface SubscriptionResult {          // onPaymentComplete argument
  id: string
  status: string                        // 'ACTIVE', 'TRIALING', ...
  productId: string
  amount: number                        // whole TWD (499 = NT$499). NEVER divide by 100
  billingPeriod: string
  currentPeriodStart: string
  currentPeriodEnd: string
  trialEndsAt?: string
  nextBillingDate?: string
}

这些选项中没有 trialDays、quantity 或 metadata——试用和定价由产品决定(服务端 SDK 的 checkoutSessions.create() 接受 metadata)。

产品类型

所有四种类型都使用相同的调用;具体行为由产品决定:

redirectToCheckout({ productSlug: 'pro-monthly', successUrl, cancelUrl })  // SUBSCRIPTION (recurring)
redirectToCheckout({ productSlug: 'ebook', successUrl, cancelUrl })        // ONE_TIME
redirectToCheckout({ productSlug: 'credits-100', successUrl, cancelUrl })  // CREDITS (prepaid wallet)
redirectToCheckout({ productSlug: 'support-us', successUrl, cancelUrl })   // DONATION (variable amount)

列出产品

import { useProducts } from 'recur-tw'

function PricingPage() {
  const { data: products, isLoading, error } = useProducts({ type: 'SUBSCRIPTION' })

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>{error.message}</div>

  return (
    <div className="pricing-grid">
      {products?.map((product) => (
        <PricingCard key={product.id} product={product} />
      ))}
    </div>
  )
}

Product 字段:id、name、slug、description、type、billingPeriod ('MONTHLY' | 'YEARLY' | ... | null)、price(整数 TWD)、currency、trialDays、 metadata、productFamily、displayOrder。SDK 类型中没有 priceFormatted——请使用 `NT$${product.price.toLocaleString()}` 格式化。

处理支付失败

付款被拒时,CheckoutError.code 为 'PAYMENT_FAILED';网关自身的代码位于 error.details.failure_code(同时还有 failure_message 和 can_retry)。 CheckoutErrorDetails 是联合类型——冲突错误对应数组,支付失败对应对象,因此读取字段前需要先缩窄类型。

onPaymentFailed: (error) => {
  const details = Array.isArray(error.details) ? undefined : error.details

  switch (details?.failure_code) {
    case 'CARD_DECLINED':
      return { action: 'retry' }
    case 'INSUFFICIENT_FUNDS':
      return { action: 'custom', customTitle: '餘額不足', customMessage: '請使用其他付款方式' }
    default:
      return details?.can_retry ? { action: 'retry' } : { action: 'close' }
  }
}

服务端结账(托管式,无需 React)

import { Recur } from 'recur-tw/server'

const recur = new Recur(process.env.RECUR_SECRET_KEY!)
const session = await recur.checkoutSessions.create({
  productId: 'prod_xxx',                 // a CUID from list_products
  successUrl: 'https://yourapp.com/success',
  cancelUrl: 'https://yourapp.com/cancel',
  customerEmail: '[email protected]',
  metadata: { plan: 'pro' },             // optional, server-side only
})
// redirect the customer to session.url

也可以使用普通 REST:

const response = await fetch('https://api.recur.tw/v1/checkout/sessions', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.RECUR_SECRET_KEY}`,   // X-Recur-Secret-Key also works
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    productId: 'prod_xxx',
    customerEmail: '[email protected]',
    successUrl: 'https://yourapp.com/success',
    cancelUrl: 'https://yourapp.com/cancel',
  }),
})

// The session object is the response body itself (no outer `data` wrapper, snake_case keys)
const { url } = await response.json()
// Redirect the customer to `url`. (/v1/checkouts is the embedded-form endpoint, different shape.)

最佳实践

  1. 默认使用托管式结账——适用于各种场景,包括 localhost
  2. 处理模态框 / 嵌入式模式的所有回调——onPaymentComplete、onPaymentFailed、onPaymentCancel
  3. 显示加载状态——isCheckingOut(useRecur,仅限模态框 / 嵌入式)/ isLoading(useSubscribe);使用 redirectToCheckout() 时请自行跟踪状态
  4. 已有客户信息时预填信息;此项为可选
  5. 使用 externalCustomerId 将 Recur 客户与用户系统关联
  6. 先在沙盒中测试——使用 pk_test_ / sk_test_ 密钥

相关技能

  • /recur-quickstart - SDK 初始设置
  • /recur-webhooks - 接收支付通知
  • /recur-entitlements - 检查订阅权限

评论

支持 Markdown