mcpField
P
polar-migration

polar-migration

github.com/polarsource開発5130 回インストール2 週間前に更新
リポジトリ

polar-migration について

polar-migrationは、サブスクリプション事業をPolarへ移行する開発者向けガイドです。顧客・商品・契約の移行方法に加え、段階的な切り替え、並行運用、決済方法の制約、移行後の確認を扱います。

主な機能

  1. 01顧客データのインポート方法を説明
  2. 02商品と特典のマッピングを計画
  3. 033種類の移行戦略を比較
  4. 04Stripe、Paddleなどからの移行例
  5. 05並行運用とWebhookを解説
  6. 06移行前後のチェックリストを提供

活用シーン

  1. 01Stripe Billingの顧客をPolarへ移行
  2. 02有効なサブスクリプションを段階的に移行
  3. 03移行中に2つの決済システムを並行運用
  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 の設定手順全体(イベントの種類、フレームワークごとの違い、raw body の要件、冪等性)については、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 の対応付けを確認する
  • 必要に応じて手動でキャンセルする

二重請求

  • 旧プラットフォームで返金する
  • 顧客に謝罪する
  • タイミングのロジックを改善する

顧客が旧プラットフォームを使い続けたい

  • 可能な限り、顧客の選択を尊重する
  • 移行を促す特典を提案する
  • 最終期限を設定する

コメント