🌟

Stripe のオーソリ機能を使って減額した金額をキャプチャした後の手数料を計算してみた(運営さんに手間かけた)

2023/07/10に公開

先に謝っておきます。
運営さん、本当に丁寧にご対応いただき有難うございます。

丁寧な運営さんがいなかったらマジで終わらんかったし
実装もしきれなかった気がする(マジで)

何がそんなにも大変だったのか...??

『 オーソリ使って減額した分の手数料を取得する事 』 です。

まぁ、一見すると「は?」って感じなので
具体例を使ってイメージを描きますね。

まず、僕が今開発しているアプリが
飲食店のセルフオーダーシステムなんですけど

「決済終わった後にクーポンが出てきたぁぁぁ」
とか
「あれ、この商品、頼んで無いよね。決済した分から返金しろぉぉぉ」
とか

ザラにあるので、決済を確定するのに24時間置いてるんです。

まぁ、クーポンの後出しとかは普通にイメージ湧きますよね。

仮に、10,000を決済確定してしまって、
決済手数料を支払った後に返金しろとか、減額しろとか言われたら
運営側がマイナスになりかねない訳です。

なので、それを防ぐ方法として

オーソリを利用して決済の決済枠(与信枠)を確保
もっと噛み砕くと、クレジットカードのショッピング枠を確保
だけしておいて、

もし万が一、
返金や減額処理をしたとしても運営側が損しないようにする

これがオンライン決済の常識です。

まぁ、今回はこの辺について詳しく解説しないので
興味がある方はこちらを参考にしてみてください。

https://tech.kitchhike.com/entry/stripe-authorization

減額した分の手数料が取得できない問題

で、今回取り上げたいのが

10,000円を確保して、実際に決済を確定させるのは
8,000円となった場合の決済手数料が上手く取得できない
(厳密に言うと、API一発で取得できないという事で、ちゃんと取得できます)

という問題です。

先にどうやって決済枠を確保するかは下記のコードを参照してください。

const paymentIntent = await stripe.paymentIntents.create({
  amount: amount,
  currency: 'jpy',
  payment_method: paymentMethodId,
  customer: customerId,
  confirm: true,
  capture_method: 'manual',
  payment_method_types: ['card'],
});

特に最後の3行とかは間違えやすいので注意してくださいねmm

では次、
決済の枠を確保した後にキャプチャします。
その際に減額をする場合は、元の金額から減額分を引いた金額を入力します。

決済をキャプチャ
const paymentIntent = await stripe.paymentIntents.capture(
  paymentIntent.id,
  { amount_to_capture: 10,000 - 2000 }
);

ここでは、10,000円の決済枠を確保した上で
2,000円をマイナスする事にします。

なので実質的に確定金額は8,000円になります。

で、ここでタイトルの問題が発生します。

『 8,000円に対しての決済手数料が取得できない 』

という問題です。

運営さん曰く、どうやらキャプチャ後の手数料を
一発で取得するAPIは用意してないらしいです。

(僕も結構探しましたが、見当たりませんでした...)

一応、決済手数料を取得する方法はあるにはある

ということで、下記のコードがそれです。

Node.js
const paymentIntent = await stripe.paymentIntents.retrieve(
  'pi_1Gpl8kLHughnNhxyIb1RvRTu',
  {
    expand: ['latest_charge.balance_transaction'],
  }
);

参考
https://stripe.com/docs/expand/use-cases

ですが、これだと10,000円に対しての決済手数料しか取得できません。

※ expand は拡張機能を意味します。

なぜ、10,000円に対する手数料しか取得できないのか?

もう、ここは設計の問題です。

これは最初に気が付くべきだったなぁと今になって反省しているのですが
どうやらこの減額した分をキャプチャするという行為は

Stripe で言う
ただの返金
だったのです。

僕が使ってるテスト用のダッシュボードをお見せするとこんな感じになっています。

¥ 10,000 JPY の横に
『一部返金』と書かれていますよね。

これが減額の実態なのです。

おそらく、決済確定してしまって、
その2,000円の処理は返金テーブルで行っているんでしょう。

まぁ、DB設計の問題なので全体像を見ないと何とも言えませんが
多分、正しいでしょうね。

超大手のStripeなので、おそらく安全で正しいはずです。

PaymentIntent に決済データと返金データを持たせるのは良く無いですから
返金データは返金テーブルに入れるって考えるとベストな気がします。

勘の良い人は気付くかも...

ここまで聞けば何となく分かる人もいそうな気がしますが

キャプチャの実態が
PaymentIntent と Refund の組み合わせ

という事は、

  1. PaymentIntent から決済の合計手数料を取得
  2. Refund から減額した分の決済手数料を取得
  3. 1と2の差額が本来の決済手数料

という事になります。

具体例を用いると、

  1. 10,000に対する決済手数料360円を取得
  2. 返金額2,000に対する決済手数料72円を取得
  3. 360円 - 72円 = 288円

つまり288円が8,000円に対する決済手数料なのです!

後はコードに直すだけ...実は若干めんどう

Stripe api はビビるくらい簡単に設計されていますから
理論さえ組み立てられたら楽勝のはずなんですが
今回は若干、苦戦するんですよね。

何故なら、たまに載ってない使い方をする必要があるから。
(↑ 後でちゃんと説明しますね)

理論上は、
PaymentIntentの決済手数料を取得して
減額分の手数料を取得するだけ

なんですが、、、

PaymentIntentの手数料は取得できるものの
減額分の決済手数料を取得するのに難があるんです。

上の方でPaymentIntentの手数料取得はやったので
減額分の決済手数料を取得する方法をここでは話します。

まず、ゴールとしては
減額分である2,000の決済手数料72が取得できたらokですよね。

それを取得するための関数はこちら

リクエスト
const balanceTransaction = await stripe.balanceTransactions.retrieve(
  refundBalanceTransactionId,
);

これで返ってくるレスポンスがこちら

レスポンス
>  {
>    id: 'txn_3NSIHJGoVFpvGYbs1Sgk0qj7',
>    object: 'balance_transaction',
>    amount: -2000,
>    available_on: 1689552000,
>    created: 1688988558,
>    currency: 'jpy',
>    description: 'REFUND FOR CHARGE',
>    exchange_rate: null,
>    fee: -72,
>    fee_details: [
>      {
>        amount: -72,
>        application: null,
>        currency: 'jpy',
>        description: 'Stripe processing fee refund',
>        type: 'stripe_fee'
>      }
>    ],
>    net: -1928,
>    reporting_category: 'partial_capture_reversal',
>    source: 're_3NSIHJGoVFpvGYbs14AA1pPa',
>    status: 'pending',
>    type: 'refund'
>  }

↑ これは僕が実際にテストで使用したデータとそのレスポンスです。

fee: -72

になってるのを見ると、ちゃんとデータは取れてそうです。

この処理は減額データ(Refund)のbalance_transactionを取得した形になります。

つまり、refund の balance_transaction_id なるものを見つける必要があるのです。

では、それはどうやって取得するのか...??

Charge データにありました。。。
しかも、拡張機能expandを使用して。。。

ここが若干、難ありの場所なんですよね。

通常の charge データを取得するのはめちゃくちゃ簡単です

const charge = await stripe.charges.retrieve(
  chargeId,
);

分からない人はドキュメントを見てね↓
https://stripe.com/docs/api/charges/retrieve

ですが、、、!!

これで取得できるのは、

下記はCharge取得時のレスポンス
>  {
>    id: 'ch_3NSIc3GoVFpvGYbs1fGFvMx2',
>    object: 'charge',
>    amount: 10000,
>    amount_captured: 8000,
>    amount_refunded: 2000,
>    application: null,
>    application_fee: null,
>    application_fee_amount: null,
>    balance_transaction: 'txn_3NSIc3GoVFpvGYbs1oYTh5Vk',
>    billing_details: {
>      address: {
>        city: null,
>        country: null,
>        line1: null,
>        line2: null,
>        postal_code: null,
>        state: null
>      },
>      email: null,
>      name: 'name',
>      phone: null
>    },
>    calculated_statement_descriptor: 'BIT.LY',
>    captured: true,
>    created: 1688989843,
>    currency: 'jpy',
>    customer: 'cus_NxUYU80RhKDOfT',
>    description: null,
>    destination: null,
>    dispute: null,
>    disputed: false,
>    failure_balance_transaction: null,
>    failure_code: null,
>    failure_message: null,
>    fraud_details: {},
>    invoice: null,
>    livemode: false,
>    metadata: {},
>    on_behalf_of: null,
>    order: null,
>    outcome: {
>      network_status: 'approved_by_network',
>      reason: null,
>      risk_level: 'normal',
>      risk_score: 4,
>      seller_message: 'Payment complete.',
>      type: 'authorized'
>    },
>    paid: true,
>    payment_intent: 'pi_3NSIc3GoVFpvGYbs1cCYsC0e',
>    payment_method: 'pm_1NBZZlGoVFpvGYbs2hbmPa5p',
>    payment_method_details: {
>      card: {
>        brand: 'visa',
>        checks: [Object],
>        country: 'US',
>        exp_month: 1,
>        exp_year: 2028,
>        fingerprint: 'LsV3wgCTu9qVuFny',
>        funding: 'credit',
>        installments: null,
>        last4: '4242',
>        mandate: null,
>        network: 'visa',
>        network_token: [Object],
>        three_d_secure: null,
>        wallet: null
>      },
>      type: 'card'
>    },
>    receipt_email: null,
>    receipt_number: null,
>    receipt_url: 'https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xSHF2SzVHb1ZGcHZHWWJzKJXhr6UGMgacqP4VqHk6LBbdrTOJBHqiHw6QLmFMftK9tFVoX90l-KZmb3OKYtsLrAhfU9qW7wUGV02L',
>    refunded: false,
>    review: null,
>    shipping: null,
>    source: null,
>    source_transfer: null,
>    statement_descriptor: null,
>    statement_descriptor_suffix: null,
>    status: 'succeeded',
>    transfer_data: null,
>    transfer_group: null
>  }

こんな感じで、まさかの refunds プロパティが無いのです。

ドキュメントには載ってるのに、何故か取得したデータには載ってない...

(...)

ここで expand を使って拡張機能を使用する事になります。

つまり、refunds も含めたデータを取得するために下記のコードに変更する必要があるのです。

refunds を含めた charge データを取得するための関数
const charge = await stripe.charges.retrieve(
  chargeId,
  {
    expand: ['refunds'],
  },
);

ここが一癖ある場所ですね。

ただ、これを入れることで下記データが取得できます。
( refunds は下の方にあります )

refunds 含めた charge レスポンス
>  {
>    id: 'ch_3NSIj9GoVFpvGYbs08xdQIl8',
>    object: 'charge',
>    amount: 10000,
>    amount_captured: 8000,
>    amount_refunded: 2000,
>    application: null,
>    application_fee: null,
>    application_fee_amount: null,
>    balance_transaction: 'txn_3NSIj9GoVFpvGYbs0YbtEilh',
>    billing_details: {
>      address: {
>        city: null,
>        country: null,
>        line1: null,
>        line2: null,
>        postal_code: null,
>        state: null
>      },
>      email: null,
>      name: 'name',
>      phone: null
>    },
>    calculated_statement_descriptor: 'BIT.LY',
>    captured: true,
>    created: 1688990283,
>    currency: 'jpy',
>    customer: 'cus_NxUYU80RhKDOfT',
>    description: null,
>    destination: null,
>    dispute: null,
>    disputed: false,
>    failure_balance_transaction: null,
>    failure_code: null,
>    failure_message: null,
>    fraud_details: {},
>    invoice: null,
>    livemode: false,
>    metadata: {},
>    on_behalf_of: null,
>    order: null,
>    outcome: {
>      network_status: 'approved_by_network',
>      reason: null,
>      risk_level: 'normal',
>      risk_score: 44,
>      seller_message: 'Payment complete.',
>      type: 'authorized'
>    },
>    paid: true,
>    payment_intent: 'pi_3NSIj9GoVFpvGYbs0QWgH3WW',
>    payment_method: 'pm_1NBZZlGoVFpvGYbs2hbmPa5p',
>    payment_method_details: {
>      card: {
>        brand: 'visa',
>        checks: [Object],
>        country: 'US',
>        exp_month: 1,
>        exp_year: 2028,
>        fingerprint: 'LsV3wgCTu9qVuFny',
>        funding: 'credit',
>        installments: null,
>        last4: '4242',
>        mandate: null,
>        network: 'visa',
>        network_token: [Object],
>        three_d_secure: null,
>        wallet: null
>      },
>      type: 'card'
>    },
>    receipt_email: null,
>    receipt_number: null,
>    receipt_url: 'https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xSHF2SzVHb1ZGcHZHWWJzKM3kr6UGMgZWxMt2IKI6LBYJ-r-GB0RrkhkGweuv7VXhB8SSB6wgKV5O6IWNvUzgUZhlNwHsbBJoRTJo',
>    refunded: false,
>    refunds: {
>      object: 'list',
>      data: [ [Object] ],
>      has_more: false,
>      total_count: 1,
>      url: '/v1/charges/ch_3NSIj9GoVFpvGYbs08xdQIl8/refunds'
>    },
>    review: null,
>    shipping: null,
>    source: null,
>    source_transfer: null,
>    statement_descriptor: null,
>    statement_descriptor_suffix: null,
>    status: 'succeeded',
>    transfer_data: null,
>    transfer_group: null
>  }

いや、dataの中身が見えへんやん
って人がいると思うので
その中身をご紹介するとこんな感じです

refunds の data の中身
>  [
>    {
>      id: 're_3NSIoSGoVFpvGYbs1ZnoMRnP',
>      object: 'refund',
>      amount: 2000,
>      balance_transaction: 'txn_3NSIoSGoVFpvGYbs1XM5sedM',
>      charge: 'ch_3NSIoSGoVFpvGYbs1NTTTBAI',
>      created: 1688990614,
>      currency: 'jpy',
>      metadata: {},
>      payment_intent: 'pi_3NSIoSGoVFpvGYbs1pdms6kI',
>      reason: null,
>      receipt_number: null,
>      source_transfer_reversal: null,
>      status: 'succeeded',
>      transfer_reversal: null
>    }
>  ]

はい、返金のリストになってますね。

ちなみに、キャプチャ処理は1度しか出来ないけどリストになってるということは、
決済手数料がかかった返金処理のデータがここに入るんでしょうね。

とまぁ、こんな感じで refund の balance_transaction が取得できましたよと。

後はもう簡単ですよね。

PaymentIntent の chargeId を取得するだけなので

const paymentIntent = await stripe.paymentIntents.retrieve(
  paymentIntentId,
);

この paymentIntent に chargeId が入ってます。
※ キャプチャ済みの時のみ

これにて終了

どうでしたか?

理解したら超楽勝ですけど、
なかなか気付かない場所がありましたよね。

そもそもの理論も気付かないし、
expand使った拡張機能もイマイチピンと来ないし、

簡単で評判のStripeさんですが
今回ばかりは複雑にならざるおえなかったようですね。

とまぁ、ここまでが皆様のためで
後は僕の戒めのためのメモ代わりにしますね。

なぜ、一部返金に気が付かなかったのか

ダッシュボードの一番下のjsonデータをちゃんと見てなかった

いや。厳密に言うと見るのを避けてた

新しいことを勉強することに。

でも、その結果
expandの拡張機能という素晴らしい処理を逃してしまっていた

心に余裕があるうちに、気が付いた時に、調べてみるべき。

気を付けるべきこと

減額と返金の両方をしている場合は
リストになって複数入ってるから
どれが減額でどれが返金かをAPI使って取得する

なぜベタ打ちで3.6%を計算しないのか?

一番は、決済手数料が場合によって変わるらしいから
どうやら月間売り上げが760万超えると下がるっぽい
それを考えると、絶対にベタ打ちはしたくない

後は、四捨五入の方針が変わったり、
そもそも最近の手数料問題のように劇的に変わる可能性があるから。

paypayは3%切ってるし、
squareも3.25%とか

Discussion