🎟️
個人開発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 に紐づくコード文字列(ユーザーが入力する) |
WELCOME50、FRIEND2026
|
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