关于 polar-migration
polar-migration 是面向迁移至 Polar 的指南,供维护订阅业务的开发者规划客户、产品和订阅迁移。内容涵盖分批迁移、并行运行、支付方式限制及迁移后的核验。
主要功能
- 01说明客户资料导入方式
- 02规划产品与权益映射
- 03比较三种迁移策略
- 04提供 Stripe、Paddle 等迁移示例
- 05涵盖并行运行与 Webhook
- 06提供迁移前后检查清单
适用场景
- 01将 Stripe Billing 客户迁移至 Polar
- 02逐步迁移活跃订阅
- 03迁移期间同时运行两个支付系统
- 04检查迁移结果并清理旧平台
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ polarsource/ skills --skill polar-migration安装后在 Claude Code 中输入/polar-migration确认已加载。
详细介绍
Polar 迁移指南
从其他支付平台迁移到 Polar,同时尽量减少对客户和收入的影响。
迁移概览
可迁移的内容
| 数据类型 | 迁移方式 |
|---|---|
| 客户邮箱/姓名 | API 导入 |
| 产品目录 | 手动重新创建 |
| 活跃订阅 | 创建新订阅 |
| 支付方式 | 需要客户重新填写* |
| 历史订单 | 不迁移(保留在旧系统中) |
| 许可证密钥 | 生成新密钥 |
*由于 PCI 合规要求,支付方式令牌无法在不同支付处理商之间转移。客户需要重新填写支付详情。
迁移策略
1. 一次性切换
- 取消旧系统中的所有订阅
- 在 Polar 中创建新订阅
- 适合:客户数量较少、产品简单的情况
2. 渐进式迁移
- 并行运行两个系统
- 在客户续订时迁移
- 适合:客户数量较多、希望尽量降低风险的情况
3. 仅面向新客户
- 现有客户继续使用旧系统
- 新客户使用 Polar
- 适合:在全面转用前测试 Polar
迁移前检查清单
1. 设置 Polar
# Test in sandbox first
# https://sandbox.polar.sh
# Install SDK
npm install @polar-sh/sdk
2. 对应产品
在 Polar 中创建相应的产品:
import { Polar } from "@polar-sh/sdk";
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN,
server: "sandbox", // Test first!
});
// Create products matching your old catalog
const basicPlan = await polar.products.create({
name: "Basic Plan",
organizationId: "org_xxx",
description: "Basic features",
prices: [
{
type: "recurring",
recurringInterval: "month",
amountType: "fixed",
priceAmount: 900, // $9.00
priceCurrency: "usd",
},
{
type: "recurring",
recurringInterval: "year",
amountType: "fixed",
priceAmount: 9000, // $90.00
priceCurrency: "usd",
},
],
});
3. 对应权益/权限
// Create benefits in Polar
const licenseBenefit = await polar.benefits.create({
type: "license_keys",
description: "Software License",
organizationId: "org_xxx",
properties: {
prefix: "PRO",
activations: { limit: 3 },
},
});
// Attach the benefit to the product in the Polar dashboard
// (Organization → Products → [product] → Benefits).
4. 设置 Webhook
import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks";
export async function POST(request: Request): Promise<Response> {
const body = await request.text();
let event: ReturnType<typeof validateEvent>;
try {
event = validateEvent(
body,
{
"webhook-id": request.headers.get("webhook-id") ?? "",
"webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
"webhook-signature": request.headers.get("webhook-signature") ?? "",
},
process.env.POLAR_WEBHOOK_SECRET!,
);
} catch (error) {
if (error instanceof WebhookVerificationError) {
return Response.json({ received: false }, { status: 403 });
}
throw error;
}
switch (event.type) {
case "order.paid":
// Grant access in your system
await grantAccess(event.data.customer.externalId, event.data.productId);
break;
case "subscription.canceled":
// Schedule access removal
await scheduleAccessRemoval(event.data.customer.externalId, event.data.endsAt);
break;
}
return Response.json({ received: true });
}
有关完整的 Webhook 配置方法(事件类型、框架差异、原始请求体要求、幂等性),请参阅 polar-integration 技能。
从 Stripe Billing 迁移
导出客户数据
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// Export customers with active subscriptions
async function exportStripeCustomers() {
const customers: ExportedCustomer[] = [];
for await (const subscription of stripe.subscriptions.list({ status: "active" })) {
const customer = await stripe.customers.retrieve(subscription.customer as string);
if (customer.deleted) continue;
customers.push({
stripeCustomerId: customer.id,
email: customer.email!,
name: customer.name || undefined,
subscriptionId: subscription.id,
productId: subscription.items.data[0].price.product as string,
priceId: subscription.items.data[0].price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
});
}
return customers;
}
在 Polar 中创建客户
// Map Stripe products to Polar products
const productMap: Record<string, string> = {
"prod_stripe_basic": "prod_polar_basic",
"prod_stripe_pro": "prod_polar_pro",
};
async function createPolarCustomers(customers: ExportedCustomer[]) {
for (const customer of customers) {
// Create customer in Polar with external ID for linking
const polarCustomer = await polar.customers.create({
organizationId: "org_xxx",
email: customer.email,
name: customer.name,
externalId: customer.stripeCustomerId, // Link to Stripe ID
});
console.log(`Created Polar customer: ${polarCustomer.id} for ${customer.email}`);
}
}
迁移订阅(渐进式)
async function migrateSubscription(customer: ExportedCustomer) {
const polarProductId = productMap[customer.productId];
// Create checkout for customer to enter new payment method
const checkout = await polar.checkouts.create({
products: [polarProductId],
customerEmail: customer.email,
externalCustomerId: customer.stripeCustomerId,
successUrl: `https://yoursite.com/migration-complete?customer=${customer.stripeCustomerId}`,
// Allow discount for migration
discountId: "migration_discount_xxx",
});
// Send migration email to customer
await sendMigrationEmail(customer.email, {
checkoutUrl: checkout.url,
currentPeriodEnd: customer.currentPeriodEnd,
});
return checkout;
}
处理迁移完成
// Inside the validateEvent switch in your webhook handler:
case "order.paid": {
const order = event.data;
const stripeCustomerId = order.customer.externalId;
if (stripeCustomerId?.startsWith("cus_")) {
// Cancel Stripe subscription at period end
const stripeSubscriptions = await stripe.subscriptions.list({
customer: stripeCustomerId,
status: "active",
});
for (const sub of stripeSubscriptions.data) {
await stripe.subscriptions.update(sub.id, {
cancel_at_period_end: true,
});
}
// Mark customer as migrated in your database
await db.customer.update({
where: { stripeId: stripeCustomerId },
data: {
migratedToPolar: true,
polarCustomerId: order.customer.id,
},
});
}
break;
}
从 Paddle 迁移
导出 Paddle 数据
// Paddle API to export subscribers
async function exportPaddleSubscribers() {
const response = await fetch("https://vendors.paddle.com/api/2.0/subscription/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
vendor_id: process.env.PADDLE_VENDOR_ID,
vendor_auth_code: process.env.PADDLE_AUTH_CODE,
state: "active",
}),
});
const data = await response.json();
return data.response;
}
将 Paddle 数据对应到 Polar
const paddleToPolar = {
products: {
"paddle_12345": "polar_basic",
"paddle_12346": "polar_pro",
},
};
async function migratePaddleCustomer(paddleUser: PaddleSubscriber) {
// Create checkout with Polar
const checkout = await polar.checkouts.create({
products: [paddleToPolar.products[paddleUser.plan_id]],
customerEmail: paddleUser.user_email,
externalCustomerId: `paddle_${paddleUser.user_id}`,
successUrl: "https://yoursite.com/migrated",
});
return checkout;
}
从 Lemon Squeezy 迁移
// Lemon Squeezy API export
async function exportLemonSqueezyCustomers() {
const response = await fetch("https://api.lemonsqueezy.com/v1/subscriptions", {
headers: {
Authorization: `Bearer ${process.env.LEMON_SQUEEZY_API_KEY}`,
},
});
const { data } = await response.json();
return data.filter((sub: any) => sub.attributes.status === "active");
}
客户沟通
迁移邮件模板
const migrationEmailTemplate = {
subject: "Action Required: Update Your Payment Method",
body: `
Hi {{customer_name}},
We're upgrading our payment system to provide you with a better experience.
**What you need to do:**
Click the link below to update your payment method. This will only take a minute.
[Update Payment Method]({{checkout_url}})
**What's changing:**
- More payment options
- Better invoice management
- Improved customer portal
**What's NOT changing:**
- Your subscription price
- Your features and access
- Your billing date
**Timeline:**
Please complete this by {{deadline}} to ensure uninterrupted service.
Your current subscription will continue until {{current_period_end}}, then
seamlessly transition to our new system.
Questions? Reply to this email.
Thanks,
{{company_name}}
`,
};
async function sendMigrationEmail(email: string, data: MigrationData) {
// Use your email service (SendGrid, Postmark, etc.)
await emailService.send({
to: email,
subject: migrationEmailTemplate.subject,
body: renderTemplate(migrationEmailTemplate.body, {
customer_name: data.name || "there",
checkout_url: data.checkoutUrl,
deadline: formatDate(data.deadline),
current_period_end: formatDate(data.currentPeriodEnd),
company_name: "Your Company",
}),
});
}
提醒邮件序列
const reminderSchedule = [
{ daysBeforeDeadline: 14, template: "initial" },
{ daysBeforeDeadline: 7, template: "reminder" },
{ daysBeforeDeadline: 3, template: "urgent" },
{ daysBeforeDeadline: 1, template: "final" },
];
async function scheduleReminders(customer: Customer, deadline: Date) {
for (const reminder of reminderSchedule) {
const sendAt = subDays(deadline, reminder.daysBeforeDeadline);
await scheduleEmail({
to: customer.email,
template: reminder.template,
sendAt,
data: { checkoutUrl: customer.migrationCheckoutUrl },
});
}
}
并行运行
路由模式
// Determine which system handles a customer
async function getPaymentSystem(userId: string): Promise<"stripe" | "polar"> {
const user = await db.user.findUnique({ where: { id: userId } });
if (user?.polarCustomerId) {
return "polar";
}
return "stripe";
}
// Route subscription checks
async function hasActiveSubscription(userId: string): Promise<boolean> {
const system = await getPaymentSystem(userId);
if (system === "polar") {
const state = await polar.customers.getState({
customerId: user.polarCustomerId,
});
return state.activeSubscriptions.length > 0;
} else {
const subscriptions = await stripe.subscriptions.list({
customer: user.stripeCustomerId,
status: "active",
});
return subscriptions.data.length > 0;
}
}
统一 Webhook 处理程序
// Handle webhooks from both systems during migration
app.post("/webhooks/stripe", stripeWebhookHandler);
app.post("/webhooks/polar", polarWebhookHandler);
// Normalize events to your internal format
interface SubscriptionEvent {
type: "created" | "canceled" | "renewed";
customerId: string;
productId: string;
source: "stripe" | "polar";
}
function normalizeStripeEvent(event: Stripe.Event): SubscriptionEvent | null {
// Convert Stripe event to internal format
}
function normalizePolarEvent(event: PolarWebhookEvent): SubscriptionEvent | null {
// Convert Polar event to internal format
}
迁移后清理
验证迁移
async function verifyMigration() {
// Get all customers marked as migrated
const migratedCustomers = await db.customer.findMany({
where: { migratedToPolar: true },
});
const issues: string[] = [];
for (const customer of migratedCustomers) {
// Verify Polar subscription exists
const polarState = await polar.customers.getState({
customerId: customer.polarCustomerId,
});
if (polarState.activeSubscriptions.length === 0) {
issues.push(`${customer.email}: No active Polar subscription`);
}
// Verify Stripe subscription is canceled
const stripeSubscriptions = await stripe.subscriptions.list({
customer: customer.stripeCustomerId,
status: "active",
});
if (stripeSubscriptions.data.length > 0) {
issues.push(`${customer.email}: Stripe subscription still active`);
}
}
return issues;
}
取消旧系统
// After all customers migrated, cancel remaining Stripe subscriptions
async function cancelRemainingStripeSubscriptions() {
for await (const subscription of stripe.subscriptions.list({ status: "active" })) {
// Check if customer was migrated
const customer = await db.customer.findFirst({
where: { stripeId: subscription.customer as string },
});
if (customer?.migratedToPolar) {
await stripe.subscriptions.cancel(subscription.id);
console.log(`Canceled Stripe subscription ${subscription.id}`);
}
}
}
迁移检查清单
迁移前
- 已在 Polar(沙盒)中创建产品
- 已对应权益/权限
- 已配置 Webhook
- 已准备好邮件模板
- 已告知客户支持团队
- 已记录回滚计划
迁移期间
- 先用小批量进行测试
- 监控错误率
- 跟踪转化率
- 快速响应客户问题
- 按计划发送提醒邮件
迁移后
- 验证所有订阅均已迁移
- 取消旧平台上的订阅
- 更新文档
- 移除旧平台代码
- 归档旧平台数据
- 庆祝!🎉
故障排除
客户未收到迁移邮件
- 检查垃圾邮件文件夹
- 验证导出数据中的邮箱地址
- 更换邮件主题后重新发送
客户已完成结账,但 Stripe 未取消
- 检查 Webhook 日志
- 验证 external_id 映射
- 如有需要,手动取消
重复扣费
- 在旧平台上退款
- 向客户致歉
- 改进时序逻辑
客户希望继续使用旧平台
- 尽可能尊重他们的选择
- 提供迁移激励
- 设定最终期限
评论
支持 Markdown