🎟️

個人開発SaaSのプロモーションコード設計 — Stripe Coupon × Supabase で限定割引を仕組み化する

に公開

結論:5ファイルでプロモーションコードが動く

ファイル 役割
Stripe Dashboard / API Coupon + PromotionCode を作成
app/api/promo/create/route.ts 管理者がコードを発行するエンドポイント
app/api/promo/validate/route.ts ユーザーがコードを検証・適用するエンドポイント
app/api/stripe/webhook/route.ts 使用済みイベントを Supabase に記録
components/PromoCodeInput.tsx Checkout 前に入力するUIコンポーネント

詳細な設計思想・「で、どう稼ぐ?」セクションは下記の masatoman.net 記事で解説しています。

👉 個人開発SaaSのプロモーションコード設計2026(masatoman.net)


Stripe の Coupon と PromotionCode の違い

概念 説明
Coupon 割引ルール本体(割引率・金額・期間) 「初月50%OFF」「3ヶ月¥500引き」
PromotionCode Coupon に紐づくコード文字列(ユーザーが入力する) WELCOME50FRIEND2026

1つの Coupon に複数の PromotionCode を紐づけられます。紹介コードを用途別に発行しつつ、同じ割引率を適用するケースに便利です。


Step 0:Stripe Dashboard で Coupon を作成する

const coupon = await stripe.coupons.create({
  percent_off: 50,
  duration: 'once',
  name: '初月50%OFF',
  max_redemptions: 200,
})

Step 1:管理者がプロモーションコードを発行するAPI

// app/api/promo/create/route.ts
export async function POST(req: NextRequest) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  const { data: profile } = await supabase
    .from('profiles').select('role').eq('id', user?.id).single()

  if (profile?.role !== 'admin') {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
  }

  const { couponId, code, maxRedemptions } = await req.json()

  const promoCode = await stripe.promotionCodes.create({
    coupon: couponId,
    code: code.toUpperCase(),
    max_redemptions: maxRedemptions ?? 1,
  })

  await supabase.from('promo_codes').insert({
    stripe_promo_code_id: promoCode.id,
    code: promoCode.code,
    coupon_id: couponId,
    max_redemptions: maxRedemptions ?? 1,
    times_redeemed: 0,
    active: true,
  })

  return NextResponse.json({ promoCode: promoCode.code })
}

Step 2:Supabase テーブル設計

CREATE TABLE promo_codes (
  id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  stripe_promo_code_id  TEXT NOT NULL UNIQUE,
  code                  TEXT NOT NULL UNIQUE,
  coupon_id             TEXT NOT NULL,
  max_redemptions       INT,
  times_redeemed        INT NOT NULL DEFAULT 0,
  active                BOOLEAN NOT NULL DEFAULT true,
  created_at            TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE promo_code_usages (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  promo_code_id   UUID NOT NULL REFERENCES promo_codes(id),
  user_id         UUID NOT NULL REFERENCES auth.users(id),
  subscription_id TEXT,
  discount_amount INT,
  used_at         TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Step 3:コード検証API

// app/api/promo/validate/route.ts
export async function POST(req: NextRequest) {
  const { code } = await req.json()

  const promoCodes = await stripe.promotionCodes.list({
    code: code.toUpperCase(),
    active: true,
    limit: 1,
  })

  if (promoCodes.data.length === 0) {
    return NextResponse.json({ valid: false, message: 'コードが見つかりません' })
  }

  const promo  = promoCodes.data[0]
  const coupon = promo.coupon

  const discountLabel = coupon.percent_off
    ? `${coupon.percent_off}%OFF`
    : `¥${(coupon.amount_off ?? 0).toLocaleString()}引き`

  const durationLabel =
    coupon.duration === 'once'      ? '初月のみ' :
    coupon.duration === 'repeating' ? `${coupon.duration_in_months}ヶ月間` :
    'ずっと適用'

  return NextResponse.json({
    valid: true,
    promoCodeId: promo.id,
    discountLabel,
    durationLabel,
    message: `${discountLabel}(${durationLabel})が適用されます`,
  })
}

Step 4:Checkout Session への適用

if (promoCodeId) {
  sessionParams.discounts = [{ promotion_code: promoCodeId }]
} else {
  sessionParams.allow_promotion_codes = true
}

discounts を明示すると Checkout 画面のコード入力欄が非表示になり、サーバー検証済みコードを確実に適用できます。


Step 5:Webhook で使用を Supabase に記録

case 'customer.subscription.created': {
  const subscription = event.data.object as Stripe.Subscription
  const discount = subscription.discount

  if (discount?.promotion_code) {
    const promoCodeId = typeof discount.promotion_code === 'string'
      ? discount.promotion_code
      : discount.promotion_code.id

    const { data: promoRow } = await supabase
      .from('promo_codes')
      .select('id')
      .eq('stripe_promo_code_id', promoCodeId)
      .single()

    if (promoRow) {
      await supabase.from('promo_code_usages').insert({
        promo_code_id:   promoRow.id,
        user_id:         userId,
        subscription_id: subscription.id,
        discount_amount: /* 計算値 */,
      })

      await supabase.rpc('increment_promo_times_redeemed', {
        p_stripe_promo_code_id: promoCodeId,
      })
    }
  }
  break
}

Step 6:PromoCodeInput コンポーネント

// components/PromoCodeInput.tsx
'use client'

export function PromoCodeInput({ onApply }: { onApply: (id: string, label: string) => void }) {
  const [code, setCode]       = useState('')
  const [message, setMessage] = useState('')
  const [loading, setLoading] = useState(false)
  const [applied, setApplied] = useState(false)

  const handleValidate = async () => {
    setLoading(true)
    const res  = await fetch('/api/promo/validate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ code }),
    })
    const data = await res.json()
    if (data.valid) { setApplied(true); onApply(data.promoCodeId, data.discountLabel) }
    setMessage(data.message ?? '無効なコードです')
    setLoading(false)
  }

  return (
    <div className="mt-4">
      <div className="flex gap-2">
        <input
          value={code} onChange={e => setCode(e.target.value.toUpperCase())}
          placeholder="WELCOME50" disabled={applied}
          className="flex-1 rounded border px-3 py-2 text-sm uppercase"
        />
        <button onClick={handleValidate} disabled={loading || applied}
          className="rounded bg-indigo-600 px-4 py-2 text-sm text-white disabled:opacity-50">
          {applied ? '適用済み' : loading ? '確認中...' : '適用'}
        </button>
      </div>
      {message && <p className={`mt-1 text-sm ${applied ? 'text-green-600' : 'text-red-500'}`}>{message}</p>}
    </div>
  )
}

まとめ

  • Coupon(割引ルール)と PromotionCode(コード文字列)の2レイヤー設計を理解する
  • /api/promo/create で管理者がコードを動的発行
  • /api/promo/validate でフロントから事前検証
  • Webhook で使用履歴を Supabase に記録して効果を計測

👉 「プロモーションコードで実際にどう稼ぐか」の設計思想は masatoman.net の記事 で解説しています。

Discussion