たまには Stripe 以外の決済手段も触ってみようということで、PAY.JP のサブスクリプションを実装してみました。
この記事では、Stripe のサブスクリプション実装経験がある方が PAY.JP で定期課金を実装する際に知っておきたい違いについて4点まとめました。基本的な API 設計はほぼ同じで、Stripe で書いたコードの 90% は再利用できます。
Pay.jpでサブスクリプションを実装する
顧客を作成し、プランを定義し、サブスクリプションを開始するという流れは Stripe と変わりません。
顧客の作成
まずはサブスクを割り当てる顧客を作成します。cardは実際にはフロントエンドで生成されたトークンIDを渡しましょう。
const payjp = require('payjp')('sk_test_xxx');
const customer = await payjp.customers.create({
email: 'user@example.com',
card: 'tok_xxx', // payjp.js で生成したトークン ID
metadata: { user_id: '12345' }
});
metadata は 20 キー・各値 500 文字まで保存できます。Stripe(50 キー)より少ないため、多くの ID を保存している設計では JSON 文字列への統合を検討してください。
プランの作成
申し込みするサブスクリプションのプランもコードで作っておきましょう。
const plan = await payjp.plans.create({
id: 'basic-monthly',
amount: 1000,
interval: 'month', // 'month' または 'year'
currency: 'jpy'
});
Stripe の Price オブジェクトに相当するものは PAY.JP にはありません。PriceとProductをまとめて管理するとイメージしておきましょう。もし月額と年額を用意するには、別々の Plan として作成します。
const monthlyPlan = await payjp.plans.create({ id: 'basic-monthly', amount: 1000, interval: 'month', currency: 'jpy' });
const yearlyPlan = await payjp.plans.create({ id: 'basic-yearly', amount: 10000, interval: 'year', currency: 'jpy' });
サブスクリプションの作成
続いてサブスクリプションを作成します。
// Stripe: items 配列に Price ID、trial_period_days に日数
await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: 'price_xxx' }],
trial_period_days: 14
});
// PAY.JP: plan 文字列に Plan ID、trial_end に UNIX タイムスタンプ
await payjp.subscriptions.create({
customer: customer.id,
plan: 'basic-monthly',
trial_end: Math.floor(Date.now() / 1000) + (14 * 24 * 60 * 60)
});
trial_end の指定方法が異なる点に注意です。Stripe の trial_period_days は日数で渡しますが、PAY.JP の trial_end は UNIX タイムスタンプで指定します。日数指定したい場合はプランの trial_days パラメータを使う方法もあります。
サブスクリプションのステータス
作成後のレスポンスに含まれる status は以下のいずれかを取ります。
trial— トライアル期間中active— 課金中paused— 一時停止中(課金失敗時も含む)canceled— キャンセル済み
Stripe では課金失敗時のステータスとして past_due や unpaid が使われますが、PAY.JP に unpaid というステータスは存在しません。失敗時は paused になります。この点を把握していないと、ステータスチェックのロジックが意図せず壊れます。
current_period_start と current_period_end は UTC タイムスタンプです。日本時間で表示するには +9 時間の変換が必要です。
const jstDate = new Date(subscription.current_period_end * 1000);
console.log(jstDate.toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' }));
Pay.jpとStripeのサブスクリプション実装における4つの違い
触ってみて感じた4つの違いは次のとおりです。
- プラン変更の挙動: デフォルトで即時課金 + サイクルリセットが発生する
- 支払い失敗の検知:
subscription.pausedWebhook で検知できる trial_endのパラメータ形式: タイムスタンプ指定(プランのtrial_daysが日数指定)- その他の構造的な差異: Price オブジェクトの不在、UTC 基準の課金サイクル
プラン変更で即座に課金が走る
Stripe ユーザーが困惑しやすいポイントがこちらです。Stripe の subscriptions.update でプランを変更した場合、デフォルトでは日割り計算(プロレーション)が行われて、差額が次回インボイスに加算されます。PAY.JP は逆です。プランを変更すると、変更時点で新しいプランでの課金が即座に実行され、サイクルもリセットされます。
// この呼び出しは変更時点で即時課金 + サイクルリセットが発生する
await payjp.subscriptions.update('sub_xxx', {
plan: 'premium-monthly'
});
次のサイクルから適用したい場合は next_cycle_plan を使います。Stripeは即座に請求したい・サイクルをリセットしたい時にパラメータを追加するので、設計思想が逆になっています。
// next_cycle_plan: 現在のサイクルが終わったあとにプランを切り替える
await payjp.subscriptions.update('sub_xxx', {
next_cycle_plan: 'premium-monthly'
});
trial_end に現在の current_period_end を指定することでも、次サイクルまで課金を遅延させられます。
const current = await payjp.subscriptions.retrieve('sub_xxx');
await payjp.subscriptions.update('sub_xxx', {
plan: 'premium-monthly',
trial_end: current.current_period_end
});
プラン変更のタイミング設計は、アプリケーション側で意識的に行う必要があります。
支払い失敗の検知イベントが異なる
定期課金 の更新(2回目以降)で課金に失敗した場合、PAY.JP は以下の2つのイベントを発火します。
charge.failed
subscription.paused
Stripe の invoice.payment_failed に対応するのが subscription.paused です。Webhook でこのイベントをハンドリングすることで、Stripe と同様にイベント駆動で失敗を検知できます。ポーリングで status を定期確認する実装は不要です。
app.post('/webhook/payjp', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-payjp-webhook-token'];
if (signature !== process.env.PAYJP_WEBHOOK_TOKEN) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(req.body);
switch (event.type) {
case 'subscription.created':
await handleSubscriptionCreated(event.data);
break;
case 'subscription.renewed':
// Stripe の invoice.payment_succeeded に相当
await handleSubscriptionRenewed(event.data);
break;
case 'subscription.paused':
// Stripe の invoice.payment_failed に相当
// 課金失敗時に status が paused になる
await handlePaymentFailed(event.data);
break;
case 'subscription.resumed':
await handleSubscriptionResumed(event.data);
break;
case 'subscription.canceled':
await handleSubscriptionCanceled(event.data);
break;
}
res.status(200).send('OK');
});
Webhook の署名検証は X-Payjp-Webhook-Token ヘッダーで行います。Stripe の HMAC-SHA256 署名検証よりシンプルな仕組みです。トークンは PAY.JP ダッシュボード のアカウント設定で確認できます。
なお、初回課金の失敗時は subscription.paused ではなく charge.failed のみが発火し、サブスクリプション自体が作成されません。失敗時のイベント構成は初回と2回目以降で異なります。
| タイミング | 成功時のイベント | 失敗時のイベント |
|---|---|---|
| 初回課金 | charge.succeeded + subscription.created |
charge.failed(サブスクリプション未作成) |
| 2回目以降 | charge.succeeded + subscription.renewed |
charge.failed + subscription.paused |
trial_end はタイムスタンプ
Stripe では trial_period_days という引数名で日数を渡します。一方PAY.JP では trial_end という名前でタイムスタンプを渡します。引数の型だけでなく、名前そのものが違います。
// Stripe: trial_period_days に日数
stripe.subscriptions.create({
customer: customer.id,
items: [{ price: 'price_xxx' }],
trial_period_days: 14
});
// PAY.JP: trial_end に UNIX タイムスタンプ
payjp.subscriptions.create({
customer: customer.id,
plan: 'basic-monthly',
trial_end: Math.floor(Date.now() / 1000) + (14 * 24 * 60 * 60)
});
プランレベルでトライアル日数を事前に定義したい場合は、プラン作成時に trial_days を指定します。
const plan = await payjp.plans.create({
id: 'basic-monthly',
amount: 1000,
interval: 'month',
currency: 'jpy',
trial_days: 14 // プランレベルでのトライアル日数(こちらは日数)
});
// trial_days を持つプランでサブスクリプションを作成すると自動的にトライアル期間が設定される
const subscription = await payjp.subscriptions.create({
customer: customer.id,
plan: 'basic-monthly' // trial_end の指定不要
});
サブスクリプション作成時に trial_end を指定した場合は、プランの trial_days より優先されます。
APIパラメータなどの違い
その他いくつかパラメータや計算ロジックに異なる部分がありました。
サブスクリプション作成の引数構造
Stripe は items 配列に Price オブジェクトを指定しますが、PAY.JP は plan に Plan ID を文字列で指定します。複数のプランを同時に指定する構造は PAY.JP にはありません。
課金サイクルの計算基準
PAY.JP の課金サイクルは UTC(協定世界時)を基準として計算されます。日本時間(JST)より 9 時間早い計算です。Stripe はアカウントのタイムゾーン設定を基準にします。
billing_day を設定していない場合、月末(29〜31日)に作成したサブスクリプションは翌月以降にサイクルがずれていく点に注意してください。UTC 基準で翌月に同日が存在しない場合はもっとも近い日に移動します。
プラン変更やキャンセル・一時停止について
一時停止と再開
PAY.JP には pause / resume という操作があります。
// 一時停止(課金を止める。サブスクリプション ID は維持される)
await payjp.subscriptions.pause('sub_xxx');
// 再開(カードを更新してから呼ぶ)
await payjp.subscriptions.resume('sub_xxx');
pause 状態では課金が止まりますが、サブスクリプション自体は維持されます。カードの有効期限切れなどで課金失敗 → 自動的に paused になった場合も、カードを更新してから resume を呼ぶことで再開できます。
キャンセル
Stripe の cancel_at_period_end に相当するパラメータは PAY.JP にはありません。期間終了時にキャンセルしたい場合は、current_period_end をデータベースに記録し、その日時にスケジューラから cancel を呼ぶ形で実装します。
// 即座にキャンセル
await payjp.subscriptions.cancel('sub_xxx');
// 期間終了時にキャンセルしたい場合は、current_period_end を記録して自前でスケジュール管理する
const subscription = await payjp.subscriptions.retrieve('sub_xxx');
await db.query(…)
まとめ
Stripe で培った知識は、PAY.JP でもほぼそのまま活かせます。顧客を作成し、プランを定義し、サブスクリプションを開始する基本フローは同じです。
最も重要な差異は「プラン変更がデフォルトで即時課金 + サイクルリセットになること」と「失敗時のステータスが paused であること(unpaid ではない)」の2点です。この2点をおさえておけば、Stripe から PAY.JP への移植は思ったより短時間で完了します。
詳細は PAY.JP 公式ドキュメント および API リファレンス をあわせてご確認ください。