mcpField
P
polar-migration

polar-migration

github.com/polarsource开发5130 次安装2 周前更新
代码仓库

关于 polar-migration

polar-migration 是面向迁移至 Polar 的指南,供维护订阅业务的开发者规划客户、产品和订阅迁移。内容涵盖分批迁移、并行运行、支付方式限制及迁移后的核验。

主要功能

  1. 01说明客户资料导入方式
  2. 02规划产品与权益映射
  3. 03比较三种迁移策略
  4. 04提供 Stripe、Paddle 等迁移示例
  5. 05涵盖并行运行与 Webhook
  6. 06提供迁移前后检查清单

适用场景

  1. 01将 Stripe Billing 客户迁移至 Polar
  2. 02逐步迁移活跃订阅
  3. 03迁移期间同时运行两个支付系统
  4. 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