mcpField
R
recur-portal

recur-portal

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

关于 recur-portal

用于实现 Recur Customer Portal 流程的技能,让订阅者自行管理订阅和账单,无需联系支持人员。内容涵盖服务端会话创建、Next.js 集成模式及直接调用 REST API。

主要功能

  1. 01在服务端创建门户会话
  2. 02按 ID 或邮箱识别客户
  3. 03集成 Next.js 路由和操作
  4. 04直接调用 REST API
  5. 05遵循身份验证和会话安全指南

适用场景

  1. 01添加自助账户门户入口
  2. 02让订阅者更新付款方式
  3. 03启用取消订阅或更改方案
  4. 04通过门户查看账单历史

安装

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

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

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

详细介绍

Recur 客户门户集成

你正在协助实现 Recur 的客户门户,让订阅者无需联系客服即可自行管理订阅。

什么是客户门户?

客户门户是一个托管页面,客户可以在其中:

  • 查看有效订阅和账单历史记录
  • 更新付款方式
  • 取消或重新激活订阅
  • 切换套餐(升级/降级)

适用场景

场景解决方案
“添加账户管理页面”创建门户会话并重定向
“让用户更新银行卡”由门户处理付款方式更新
“用户需要取消订阅”门户提供自助取消功能
“显示账单历史记录”门户显示发票和付款记录

快速入门:创建门户会话

门户会话需在服务端创建(需要 Secret Key)。

使用服务端 SDK
import { Recur } from 'recur-tw/server'

const recur = new Recur(process.env.RECUR_SECRET_KEY!)

// Create portal session - identify customer by email, ID, or externalId
const session = await recur.portal.sessions.create({
  email: '[email protected]',  // or customer: 'cus_xxx' or externalId: 'user_123'
  returnUrl: 'https://yourapp.com/account',
})

// Redirect customer to portal
redirect(session.url)
客户识别

可通过以下任一方法识别客户(按优先级排序):

// By Recur customer ID (highest priority)
await recur.portal.sessions.create({
  customer: 'cus_xxx',
  returnUrl: 'https://yourapp.com/account',
})

// By your system's user ID
await recur.portal.sessions.create({
  externalId: 'user_123',
  returnUrl: 'https://yourapp.com/account',
})

// By email (lowest priority)
await recur.portal.sessions.create({
  email: '[email protected]',
  returnUrl: 'https://yourapp.com/account',
})

Next.js 实现

API 路由(App Router)
// app/api/portal/route.ts
import { Recur } from 'recur-tw/server'
import { auth } from '@/lib/auth'  // Your auth solution
import { NextResponse } from 'next/server'

const recur = new Recur(process.env.RECUR_SECRET_KEY!)

export async function POST() {
  const session = await auth()

  if (!session?.user?.email) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  try {
    const portalSession = await recur.portal.sessions.create({
      email: session.user.email,
      returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
    })

    return NextResponse.json({ url: portalSession.url })
  } catch (error) {
    console.error('Portal session error:', error)
    return NextResponse.json(
      { error: 'Failed to create portal session' },
      { status: 500 }
    )
  }
}
服务端操作
// app/actions/portal.ts
'use server'

import { Recur } from 'recur-tw/server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'

const recur = new Recur(process.env.RECUR_SECRET_KEY!)

export async function openPortal() {
  const session = await auth()

  if (!session?.user?.email) {
    throw new Error('Unauthorized')
  }

  const portalSession = await recur.portal.sessions.create({
    email: session.user.email,
    returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
  })

  redirect(portalSession.url)
}
门户按钮组件
// components/portal-button.tsx
'use client'

import { useState } from 'react'

export function PortalButton() {
  const [isLoading, setIsLoading] = useState(false)

  const handleClick = async () => {
    setIsLoading(true)
    try {
      const response = await fetch('/api/portal', { method: 'POST' })
      const { url, error } = await response.json()

      if (error) throw new Error(error)

      window.location.href = url
    } catch (error) {
      console.error('Failed to open portal:', error)
      alert('無法開啟帳戶管理頁面,請稍後再試')
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <button onClick={handleClick} disabled={isLoading}>
      {isLoading ? '載入中...' : '管理訂閱'}
    </button>
  )
}
使用服务端操作和按钮
// components/portal-button-action.tsx
'use client'

import { openPortal } from '@/app/actions/portal'

export function PortalButton() {
  return (
    <form action={openPortal}>
      <button type="submit">管理訂閱</button>
    </form>
  )
}

门户会话响应

interface PortalSession {
  id: string              // Session ID (e.g., 'portal_sess_xxx')
  object: 'portal.session'
  url: string             // URL to redirect customer to
  customer: string        // Customer ID
  returnUrl: string       // URL to return after portal exit
  status: 'active' | 'expired'
  expiresAt: string       // ISO 8601 (sessions last 1 hour)
  accessedAt: string | null
  createdAt: string
}

直接使用 REST API

如果不使用 SDK:

const response = await fetch('https://api.recur.tw/v1/portal/sessions', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: '[email protected]',
    return_url: 'https://yourapp.com/account',
  }),
})

const { url } = await response.json()
// Redirect to url

常见模式

带有门户链接的账户页面
// app/account/page.tsx
import { auth } from '@/lib/auth'
import { PortalButton } from '@/components/portal-button'

export default async function AccountPage() {
  const session = await auth()

  return (
    <div>
      <h1>帳戶設定</h1>
      <p>Email: {session?.user?.email}</p>

      <section>
        <h2>訂閱管理</h2>
        <p>管理您的訂閱、更新付款方式、查看帳單記錄</p>
        <PortalButton />
      </section>
    </div>
  )
}
有条件地开放门户访问
// Only show portal button if user has subscription
function SubscriptionSection({ hasSubscription }: { hasSubscription: boolean }) {
  if (!hasSubscription) {
    return (
      <div>
        <p>您目前沒有訂閱</p>
        <a href="/pricing">查看方案</a>
      </div>
    )
  }

  return (
    <div>
      <p>您目前的訂閱:Pro 方案</p>
      <PortalButton />
    </div>
  )
}

门户配置

在 Recur Dashboard → Settings → Customer Portal 中配置门户行为:

  • 默认返回 URL:离开门户后要重定向到的位置
  • 允许的操作:启用/禁用取消订阅、更新付款方式和切换套餐
  • 品牌元素:自定义徽标和颜色

安全说明

  1. 仅限服务端:门户会话需要 Secret Key(sk_xxx)
  2. 有效期较短:会话会在 1 小时后过期
  3. 过期前可重复使用:该 URL 在这一小时内持续有效,因此应按需创建,且绝不要记录日志或通过电子邮件发送
  4. 验证用户身份:创建门户会话前,务必验证用户身份

错误处理

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

let session
try {
  session = await recur.portal.sessions.create({
    email: userEmail,
    returnUrl: returnUrl,
  })
} catch (error) {
  if (error instanceof RecurAPIError && error.code === 'not_found') {
    // No Recur customer matches this email / externalId / customer id,
    // usually because they have not bought anything yet
    redirect('/pricing')
  }
  // code 'bad_request': neither returnUrl nor a default return URL
  // (Dashboard → Settings → Customer Portal) is set
  throw error
}
redirect(session.url)

将 redirect() 放在 try 外部:在 Next.js 中,它通过抛出异常来工作,外围的 catch 会拦截它。

相关技能

  • /recur-quickstart - 初始 SDK 设置
  • /recur-checkout - 实现购买流程
  • /recur-entitlements - 检查订阅访问权限

评论

支持 Markdown