メインコンテンツへスキップ
← 記事一覧に戻る
·収益化·12 min read

個人開発SaaSに年払いプランを追加する2026 — Stripe年払い×Supabaseで月払い→年払い転換を仕組み化する

個人開発SaaSStripeSupabase収益化

結論:4ファイルで年払いプランが動く

ファイル役割
Stripe Dashboard年払い Price を追加(月払いと同一 Product)
app/api/subscription/upgrade-to-annual/route.ts月払い→年払いの Checkout Session 発行
app/api/stripe/webhook/route.tsサブスク変更の Supabase 反映
lib/annual-upgrade-emails.tsResend で転換オファーメール送信

月払いユーザーに「年払いにすると2ヶ月分お得」と伝える仕組みを、Stripe + Supabase + Resend で実装します。

masatoman.net で Claude Crew Lab(Free MVP)を運用中です。Standard(¥1,980)・Premium(¥4,980)はローンチ準備段階のため、実際の年払い転換データはまだありません。この記事の実装パターンはStripe公式ドキュメント・Baremetrics年払い転換ガイド・業界ベンチマークをベースにしており、テスト環境で動作確認したコードを含みます。

この記事でわかること:

  • 年払いプランを Stripe に追加する方法(Price 設計)
  • 既存月払いユーザーが年払いに移行できる Checkout Session の実装
  • Webhook でサブスク変更を Supabase に反映する方法
  • Resend で月払いユーザーへ転換オファーメールを送る方法
  • 「で、どう稼ぐ?」— 年払い転換が収益にどう効くか

なぜ年払いプランが重要か

個人開発 SaaS で月額課金を始めると、最初の壁は「毎月の解約」です。月払いユーザーは「使わなかった月」に解約しやすく、解約理由として「料金がもったいない」が上位に入ります。

年払いプランはこの問題を構造的に解決します。

観点月払い年払い
キャッシュフロー月ごとに¥1,980初月に¥19,800(2ヶ月分お得)
解約タイミング毎月年1回
解約率(業界参考)月3〜8%(Baremetrics)年3〜5%(実質月0.25〜0.4%)

業界ベンチマーク(Baremetrics・SaaS Capital等)では、年払い比率が30〜40%を超えると収益の安定性が大きく改善すると言われています。個人開発の文脈でも、年払いユーザーが10人いれば、月末の「解約が出るかも」という不安を大幅に減らせます。


Step 0:Stripe に年払い Price を追加する

Stripe Dashboard の「製品」から既存の Standard / Premium Product を開き、「別の価格を追加」で年払い Price を作成します。

項目設定値(例)
請求サイクル年単位(every year)
金額¥19,800(月払い¥1,980 × 10ヶ月分=2ヶ月お得)
メタデータplan: standard_annual

Price ID(price_XXXXXXXX)を控えて .env.local に追加します:

NEXT_PUBLIC_STRIPE_ANNUAL_STANDARD_PRICE_ID=price_XXXXXXXX
NEXT_PUBLIC_STRIPE_ANNUAL_PREMIUM_PRICE_ID=price_YYYYYYYY

Step 1:Supabase subscriptions テーブルの拡張

既存の subscriptions テーブルに billing_interval カラムを追加します。

ALTER TABLE subscriptions
  ADD COLUMN billing_interval TEXT NOT NULL DEFAULT 'month'
    CHECK (billing_interval IN ('month', 'year'));

Step 2:月払い→年払いの Checkout Session

既存のアクティブサブスクを持つユーザーが年払いに切り替える場合、mode: 'subscription' の新規セッションを作るよりも、customer.subscriptions.updateitems を差し替える方がシームレスです。ただし初回の年払いスタートは Checkout Session 経由がエラーハンドリングしやすいため、ここでは新規 Checkout Session アプローチを採用します。

// app/api/subscription/upgrade-to-annual/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-01-27.acacia' })

export async function POST(req: NextRequest) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const { plan } = await req.json()
  const priceId = plan === 'premium'
    ? process.env.NEXT_PUBLIC_STRIPE_ANNUAL_PREMIUM_PRICE_ID!
    : process.env.NEXT_PUBLIC_STRIPE_ANNUAL_STANDARD_PRICE_ID!

  const { data: sub } = await supabase
    .from('subscriptions')
    .select('stripe_customer_id, stripe_subscription_id, billing_interval')
    .eq('user_id', user.id)
    .eq('status', 'active')
    .single()

  if (!sub) return NextResponse.json({ error: 'No active subscription' }, { status: 404 })
  if (sub.billing_interval === 'year') {
    return NextResponse.json({ error: 'Already on annual plan' }, { status: 400 })
  }

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer: sub.stripe_customer_id,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?annual=upgraded`,
    cancel_url:  `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
    subscription_data: {
      metadata: { previous_subscription_id: sub.stripe_subscription_id },
    },
  })

  return NextResponse.json({ checkoutUrl: session.url })
}

既存月払いサブスクの処理(解約)は Webhook 受信後に行います(checkout.session.completed で前サブスクをキャンセル)。


Step 3:Checkout Session 完了 Webhook

// app/api/stripe/webhook/route.ts — checkout.session.completed に追加

case 'checkout.session.completed': {
  const session = event.data.object as Stripe.Checkout.Session
  if (session.mode !== 'subscription') break

  const subscription = await stripe.subscriptions.retrieve(session.subscription as string)
  const interval = subscription.items.data[0]?.price?.recurring?.interval ?? 'month'

  const { data: { user } } = await supabase
    .from('profiles')
    .select('id')
    .eq('stripe_customer_id', session.customer)
    .single()

  if (user) {
    await supabase.from('subscriptions').upsert({
      user_id:                 user.id,
      stripe_customer_id:      session.customer as string,
      stripe_subscription_id:  subscription.id,
      status:                  'active',
      plan:                    subscription.metadata?.plan ?? 'standard',
      billing_interval:        interval,
      current_period_end:      new Date(subscription.current_period_end * 1000).toISOString(),
    }, { onConflict: 'user_id' })

    const prevSubId = subscription.metadata?.previous_subscription_id
    if (prevSubId && interval === 'year') {
      await stripe.subscriptions.cancel(prevSubId, { prorate: true })
    }
  }
  break
}

prorate: true により、月払いの残日数が日割りで返金(Stripe クレジット)されます。


Step 4:年払いアップグレードUI

ダッシュボードのBillingページに「年払いにする」ボタンを追加します。

// components/AnnualUpgradeBanner.tsx
'use client'

import { useState } from 'react'

interface Props {
  plan: 'standard' | 'premium'
  billingInterval: 'month' | 'year'
}

export function AnnualUpgradeBanner({ plan, billingInterval }: Props) {
  const [loading, setLoading] = useState(false)

  if (billingInterval === 'year') return null

  const monthlyCost  = plan === 'premium' ? 4980  : 1980
  const annualCost   = plan === 'premium' ? 49800 : 19800
  const savings      = monthlyCost * 12 - annualCost

  const handleUpgrade = async () => {
    setLoading(true)
    const res = await fetch('/api/subscription/upgrade-to-annual', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ plan }),
    })
    const data = await res.json()
    if (data.checkoutUrl) window.location.href = data.checkoutUrl
    setLoading(false)
  }

  return (
    <div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
      <p className="font-semibold text-yellow-800">
        年払いにすると ¥{savings.toLocaleString()} お得(2ヶ月分無料)
      </p>
      <p className="mt-1 text-sm text-yellow-700">
        年払いプランは ¥{annualCost.toLocaleString()}/年。
        月払い継続(¥{(monthlyCost * 12).toLocaleString()}/年)より2ヶ月分節約できます。
      </p>
      <button
        onClick={handleUpgrade}
        disabled={loading}
        className="mt-3 rounded bg-yellow-500 px-4 py-2 text-sm font-medium text-white hover:bg-yellow-600 disabled:opacity-50"
      >
        {loading ? '処理中...' : `年払いプランに切り替える(¥${annualCost.toLocaleString()}/年)`}
      </button>
    </div>
  )
}

Step 5:月払いユーザーへの転換オファーメール(Resend)

登録から30日後に、月払いユーザーへ自動で年払いオファーメールを送ります。

// lib/annual-upgrade-emails.ts
import { Resend } from 'resend'
import { createClient } from '@/lib/supabase/server'

const resend = new Resend(process.env.RESEND_API_KEY!)

export async function sendAnnualUpgradeOffer(userId: string, plan: 'standard' | 'premium') {
  const supabase = createClient()
  const { data: profile } = await supabase
    .from('profiles')
    .select('email, display_name')
    .eq('id', userId)
    .single()

  if (!profile?.email) return

  const monthlyCost = plan === 'premium' ? 4980  : 1980
  const annualCost  = plan === 'premium' ? 49800 : 19800
  const savings     = monthlyCost * 12 - annualCost

  await resend.emails.send({
    from: 'masato@masatoman.net',
    to:   profile.email,
    subject: `年払いで¥${savings.toLocaleString()}節約できます(${plan === 'premium' ? 'Premium' : 'Standard'}プラン)`,
    html: `
      <p>${profile.display_name ?? 'こんにちは'}さん</p>
      <p>先月からご利用ありがとうございます。</p>
      <p>
        年払いプランに切り替えると、
        <strong>¥${savings.toLocaleString()}お得(2ヶ月分無料)</strong> になります。
      </p>
      <p>
        <a href="${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing">
          年払いプランを確認する →
        </a>
      </p>
    `,
  })
}

Supabase Edge Function または cron で、billing_interval = 'month' かつ登録30日以上のユーザーに定期配信します。

SELECT id FROM subscriptions
WHERE billing_interval = 'month'
  AND status = 'active'
  AND created_at < NOW() - INTERVAL '30 days'
  AND annual_offer_sent_at IS NULL;

で、どう稼ぐ? — 年払い転換が収益に効く理由

1. キャッシュフローの改善(試算例)

月払い Standard ユーザーが10人いる場合の試算(想定値):

シナリオ月次収益年間収益(想定)
全員月払い¥19,800/月¥237,600
3人が年払いに転換(想定)¥13,860/月 + 初月¥59,400一括¥226,260 + 初月大幅増

年払いユーザーが増えるほど、月末の解約減少と初月の大きな入金が同時に得られます。

※ 上記はあくまで試算例。実際の転換率は運用後に計測します。

2. 解約率の構造的な低下

業界ベンチマーク(Baremetrics Annual Benchmark Report)によると、年払いサブスクの実質月次解約率は月払いの5〜10分の1になるケースが多いとされています。個人開発 SaaS でも、年払いユーザー1人は月払いユーザーの安定性換算で5〜10人分に相当します。

3. LTV(顧客生涯価値)の向上

年払いへの転換は LTV を直接伸ばします。月払いで毎月解約リスクにさらされるよりも、年払いで1年間のコミットを得た方が、プロダクト改善・マーケティングへの再投資予算も確保しやすくなります。


実装コスト目安

工程目安
Stripe Price 追加 + env 設定30分
Checkout Session API1〜2時間
Webhook 対応1〜2時間
アップグレードUI(Banner)1時間
Resend 転換オファーメール30分〜1時間

合計 5〜7時間 で年払いプランが動くようになります。


次のステップ

  • 年払いユーザーへのリニューアル通知: 更新1ヶ月前に Resend でリマインドメールを送る
  • アップグレードモーダルをダッシュボードトップに設置: 使用機能数・ログイン回数が一定を超えたユーザーにだけ表示
  • 解約フローとの連携: 解約しようとした月払いユーザーに「年払いで¥XXXXお得」とオファーする

Next Step

次に読むならこの導線です

すべての記事を見る
有料で次へ進む¥1,000

【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード

Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude Crew」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。

次の実験記録も追う

Claude Code × 個人開発の実験ログ、失敗、判断変更をまとめて追いたい人向けに、月次でLab Freeを届けます。

個人開発の実験ログを月1回、無料で

失敗 / 実数字 / 仮説 / 次に試すこと。売れた話だけでなく売れなかった理由も共有します。

この記事が役に立ったらシェア