2024-12-11

Stripe で決済後の金額変更機能を実現する

この記事は Stripe / JP_Stripes Advent Calendar 2024 の 11 日目の記事です。

弊社では OTA(代理予約サイト)と呼ばれる、いわゆる楽天トラベルや一休のような、旅行パッケージ販売サイトの開発を行っています。今回はそこで必要になった「決済後の金額変更機能」をどうやって Stripe で実現したかをご紹介します。

「金額変更機能」とは何か

ホテル等の宿泊施設では、予約完了後に、宿泊者と施設側の双方の合意の上で料金が変更されることがよくあります。例えばオプション(食事等)の追加や、施設側の不備により減額する場合などです。

このようなとき、多くの場合決済は OTA を通して完了してしまっています。よって減額であれば一部返金、増額であれば追加で支払ってもらう必要があります。

このようなときのために、一部の大手 OTA では「予約完了後(決済終了後)に、施設(ホテル等)側で金額を変更する」機能があり、その機能を活用してつじつまを合わせることができるようでした。

Image

楽天トラベルのマニュアルより「料金変更」について引用

今回同様の機能を実装してほしいとの要望をお客様からいただき、実装を検討することになりました。

「予約」と「Stripe の決済」を 1:N の関係にする

金額変更機能以前のシステムでは、Stripe Checkout のみを用いて決済を行っていました。決済も一つの予約に対して一度きりでした。つまり、1つの予約に対して紐づく Stripe Checkout Session と Payment Intent は必ず一つということでした。

Image

しかし、この構成のままでは金額変更機能は実装できなそうでした。なぜなら一度作られた Checkout Session や Payment Intent の金額を変更することはできないからです。

Image

図のように増額するには、Payment Intent を全額返金して新しい Payment Intent を作り直すか、もしくは差額分の Payment Intent を追加で作らなければなりません。前者を「パターン1」、後者を「パターン2」としたのが下の図です。

Image

パターン1のほうが実装的には楽ですが、今回は返金後増額した金額を引き落とせないリスクを減らすという意味もあり、パターン2を採用しました。(尚、減額の場合はパターン1一択となります)

Checkout Session 完了時にカード情報を保存する

金額変更のためには顧客のカード情報(等 Payment Method)が必要です。金額変更のたびに毎回メール等で顧客に Checkout ページに誘導し明示的に支払い情報の入力をお願いする方法もありますが、スムーズな金額変更のために「最初の Checkout ページで入力されたカード情報を保存しておき、金額変更にはそれを利用する」という形を採用しました。

Stripe では、入力されたカード情報を Payment Method として Customer に紐づけて保存しておくことができます。これは通常は Setup Intents を用いて保存することが多いですが、“payment” モードの Checkout Session(一度限りの支払いの Checkout ページ)でも支払い方法を収集することができます1

方法は、Checkout Session の作成時に payment_intent_data.setup_future_usage'off_session' と指定します。

await stripe.checkout.sessions.create({
    mode: 'payment',
    customer: customerId,
    ...
    payment_intent_data: {
        setup_future_usage: 'off_session',
    },
});

こうすると同時に指定した Customer に紐づいて Payment Method が作成されます。

複数の Payment Intents をまとめて管理する

従来の「予約」と Payment Intent が 1:1 だったときには、「予約」に Payment Intent Object を持たせていました。

classDiagram
class 予約 {
    paymentIntent: StripePaymentIntent
    amount: number
    cancel()
    hoge()
}

しかしこのまま複数の Payment Intents を扱うようになったことで、格段にコードの複雑性が増すことが考えられました。

classDiagram
class 予約 {
    paymentIntents: StripePaymentIntent[]
    amount: number
    cancel()
    hoge()
}

そこで複数の Payment Intent をまとめて一つの Payment Intent のように取り扱うことのできるクラスを用意し、それを「予約」クラスに持たせることにしました。

classDiagram
direction LR
class 予約 {
    amount: number
    cancel()
    hoge()
}

class StripePaymentSet {
    paymentIntents: StripePaymentIntent[]
    get amount()
    get amountCapturable()
    get amountNet()
    get amountRefunded()
    changeAmountCapturable(amount: number)
    changeAmountNet(amount: number)
    capture(amount: number)
}

予約 --|> StripePaymentSet

StripePaymentSet クラスのゴールは下記のようなテストを通すことです(イメージがつきますでしょうか):

test('StripePaymentSet', async () => {
  // 500円の購入を作成
  const checkoutSession = await stripe.checkout.sessions.create({
    customer: customer.id,
    line_items: [{
      quantity: 1,
      price_data: {
        currency: 'JPY',
        unit_amount: 500,
        product_data: {
          name: 'テストデータ',
        },
      },
    }],
    success_url: 'http://localhost',
    mode: 'payment',
    payment_method_types: ['card'],
    payment_intent_data: {
      capture_method: 'manual',
    },
  });
  const paymentSet = await StripePaymentSet.fromIds(customer.id, checkoutSession.id, []);
  assert.equal(paymentSet.amount, 500);
  assert.equal(paymentSet.amountCapturable, 0);
  assert.equal(paymentSet.amountNet, 0);
  assert.equal(paymentSet.amountRefunded, 0);

  // Checkout Session を完了
  const paymentMethod = await stripe.paymentMethods.create({
    type: 'card',
    card: { token: 'tok_visa' },
    billing_details: { email: 'stripe@example.com' },
  });
  await stripe.paymentMethods.attach(paymentMethod.id, { customer: customer.id });
  await completeStripeCheckoutSession(checkoutSession.id, paymentMethod.id);
  await paymentSet.refetch();
  assert.equal(paymentSet.amount, 500);
  assert.equal(paymentSet.amountCapturable, 500);
  assert.equal(paymentSet.amountNet, 0);
  assert.equal(paymentSet.amountRefunded, 0);

  // キャプチャ可能金額を 400 に変更
  await paymentSet.changeAmountCapturable(400);
  await paymentSet.refetch();
  assert.equal(paymentSet.amount, 400);
  assert.equal(paymentSet.amountCapturable, 400);
  assert.equal(paymentSet.amountNet, 0);
  assert.equal(paymentSet.amountRefunded, 500);

  // キャプチャ可能金額を 600 に変更
  await paymentSet.changeAmountCapturable(600);
  await paymentSet.refetch();
  assert.equal(paymentSet.amount, 600);
  assert.equal(paymentSet.amountCapturable, 600);
  assert.equal(paymentSet.amountNet, 0);
  assert.equal(paymentSet.amountRefunded, 500);

  // キャプチャ
  await paymentSet.capture();
  await paymentSet.refetch();
  assert.equal(paymentSet.amount, 600);
  assert.equal(paymentSet.amountCapturable, 0);
  assert.equal(paymentSet.amountNet, 600);
  assert.equal(paymentSet.amountRefunded, 500);

  // 金額を500に変更
  await paymentSet.changeAmountNet(500);
  await paymentSet.refetch();
  assert.equal(paymentSet.amount, 500);
  assert.equal(paymentSet.amountCapturable, 0);
  assert.equal(paymentSet.amountNet, 500);
  assert.equal(paymentSet.amountRefunded, 600);

  // 金額を700に変更
  await paymentSet.changeAmountNet(700);
  await paymentSet.refetch();
  assert.equal(paymentSet.amount, 700);
  assert.equal(paymentSet.amountCapturable, 0);
  assert.equal(paymentSet.amountNet, 700);
  assert.equal(paymentSet.amountRefunded, 600);
});

最初に作った Stripe Checkout Session の金額を増やしたり減らしたりしています(そう見えるような使い方ができるクラスになっています)。

上記のテストコードが実際に通る StripePaymentSet クラスの実装が下記 GitHub にあります:

一部のソースコードだけかいつまんで説明すると、例えば PaymentSet の amount メソッドは、「現状の予定された金額(キャプチャもオーソリもされていない金額も含む)」を返却します。下記のようなコードになっています:

  // 現状の予定された金額(キャプチャもオーソリもされていない金額も含む)
  get amount() {
    // まだ Payment Intent が発行されていない場合
    if (!this.stripeCheckoutSession.payment_intent) {
      return this.stripeCheckoutSession.amount_total ?? 0;
    }

    // 既に Payment Intent が発行されている場合
    return this.stripePaymentIntentsIncludesCheckoutSessions.reduce(
      (sum, paymentIntent) => sum + paymentIntent.amount,
      0,
    ) - this.amountRefunded;
  }

また、例えば changeAmountCapturable メソッドは「オーソリ済み未キャプチャの金額を変更する」メソッドです。これは概ね下記のような動作をします。

  1. 現状のオーソリ済み金額を取得し、指定された金額(`amount`)をそこから引く。その値を `diffRemains` とする
  2. `diffRemains` が 0 なら return
  3. `diffRemains` が 0 より大きければ:
    1. (オーソリ済み金額を減らさなければならないので)最も金額が小さい Payment Intent のオーソリを解放する
    2. `diffRemains` から解放した金額を引く
    3. 3に戻る
  4. `diffRemains` が 0 より小さければ(オーソリ済み金額を増やさなければならないので)、`diffRemains` 分の Payment Intent を新しく作成する
実際のソースコード
// オーソリ金額を変更する
async changeAmountCapturable(amount: number) {
  const paymentIntents = [...this.stripePaymentIntentsIncludesCheckoutSessions];
  const currentAmount = this.amountCapturable;
  const diff = currentAmount - amount;
  if (diff === 0) return;

  // paymentIntentsSorted sorted by priority to cancel/refund.
  const paymentIntentsSorted = paymentIntents
    .filter((paymentIntent): paymentIntent is typeof paymentIntent & { status: 'requires_capture' } =>
      paymentIntent.status === 'requires_capture',
    ).sort((a, b) => a.amount - b.amount);

  let diffRemains = diff;
  while (diffRemains > 0) {
    const paymentIntent = paymentIntentsSorted.pop();
    if (!paymentIntent) throw new Error(`Logic Error: can't find the payment intent to cancel; diff: ${diff}; paymentIntents: ${util.format(paymentIntents)}; paymentIntentsSorted: ${util.format(paymentIntentsSorted)}`);

    await stripe.paymentIntents.cancel(paymentIntent.id);
    console.log(`Payment intent is canceled; stripePaymentIntentId: ${paymentIntent.id}`);

    diffRemains -= paymentIntent.amount_capturable;
  }
  if (diffRemains < 0) {
    let paymentMethodId = paymentIntents.find(pi => pi.payment_method)?.payment_method;
    if (!paymentMethodId) throw new Error(`These payment intents has no payment methods: ${this.stripePaymentIntentsIncludesCheckoutSessions.map(pi => pi.id)}`);
    if (typeof paymentMethodId === 'object') paymentMethodId = paymentMethodId.id;

    const paymentIntent = await stripe.paymentIntents.create({
      automatic_payment_methods: {
        enabled: true,
        allow_redirects: 'never',
      },
      amount: -diffRemains,
      capture_method: 'manual',
      confirm: true,
      currency: 'JPY',
      customer: this.stripeCustomerId,
      expand: ['latest_charge'],
      payment_method: paymentMethodId,
    });
    console.log(`New payment intent is created; stripePaymentIntentId: ${paymentIntent.id}`);
    this.stripePaymentIntents.push(assertLatestChargeExpanded(paymentIntent));
    return paymentIntent.id;
  }
  return undefined;
}

上記はオーソリ金額についてでしたが、キャプチャ済みのコードについてもほぼ同じアルゴリズムのメソッドがあります(changeAmountNet)。

このようにコードを書いていくことで、複数の PaymentIntents について、扱う側は「金額変更可能な一つの PaymentIntent」のように扱うことができるようになっています。

前述のリポジトリでは、STRIPE_API_KEY 環境変数さえ設定すれば、npm test でテストが動きますので興味があれば動かしてみてください(Customer/Checkout Session/Payment Intent を作成します。必ずどうなってもいいテスト環境で動かしてください)。

結果/まとめ

上述のような「Payment Intent をまとめて管理するクラス」を作成し、一つの「予約」で複数の Payment Intent を管理するようになった結果、予約の「金額変更機能」を実現することができました。

あまりないユースケースだとは思いますが、どなたかの参考になれば幸いです。


  1. ただし “payment” モードの Checkout Session で支払い方法の保存をオンにすると、Apple Pay が使えなくなったり、Adaptive Pricing が使えなくなるなどデメリットもあるので慎重に検討しましょう。 ↩︎