モバイルアプリ内課金の取り消し処理──決済事業者との仕様ズレが後工程で顕在化するパターン

課題の背景:決済事業者とアプリの「取り消し」の定義がズレやすい

モバイルアプリで内課金を扱う案件に関わると、必ず直面する問題があります。それは取り消し処理の仕様が、決済事業者とアプリ側で異なるということです。

ユーザーが課金を申し込んだ直後に「やっぱり要らない」とキャンセルボタンを押した場合、アプリ側では「取り消した」と判定します。しかし決済事業者の側では、すでに決済が完了していて「取り消し不可」という状態になっていることがあります。逆に、アプリは処理完了と判定しても、決済事業者はまだ決済中という状態も起こります。

この仕様ズレは、開発初期には気づきにくく、ステージング環境やQA段階で「なぜか取り消しが効かない」「取り消したのに課金されている」といった不具合報告として浮上します。そして修正が後工程に持ち越されると、アプリ側とサーバー側、決済事業者との三者で調整が必要になり、コストが跳ね上がります。

決済事業者の仕様を正確に把握する重要性

最初にやるべきことは、決済事業者の仕様書を隅々まで読むことです。ただし注意が必要です。仕様書に書かれていることと、実装が一致していないケースが稀にあるからです。

決済事業者の仕様では、通常以下のような状態遷移が定義されています。

  • 決済前:ユーザーが購入画面で「購入」を押す前
  • 決済中:決済事業者のサーバーで処理中
  • 決済完了:課金確定。この状態からの取り消しは「返金」扱い
  • キャンセル:決済中の状態から中止。返金ではなく、そもそも課金されない

ここで重要なのは、キャンセルと返金は異なる処理だということです。キャンセルは決済完了前の状態からの中止、返金は決済完了後の取り消しです。決済事業者によっては、返金に対して手数料を取る、返金可能期限を設ける、返金理由の記録を求めるなど、キャンセルにはない制約があります。

アプリの仕様設計では、この区別を明確にしておく必要があります。「取り消す」という一言で済ませると、後から「返金手数料をどう処理するのか」「返金理由はどこに記録するのか」という質問が出てきます。

非同期処理と状態管理の実装パターン

実装では、決済事業者への問い合わせが非同期になることを前提に設計します。ユーザーが「キャンセル」ボタンを押してから、実際にキャンセルが完了するまで、数秒から数十秒かかることがあるからです。

アプリ側では以下のような状態を持つ必要があります。

購入待機中 → キャンセル要求中 → キャンセル完了
                      ↓
                  キャンセル失敗

ユーザーが「キャンセル」を押した時点で、アプリは即座に「キャンセル要求中」という状態に遷移し、UIでは「キャンセル処理中…」と表示します。同時にバックエンドに対して、決済事業者へのキャンセルリクエストを送ります。

バックエンド側では、決済事業者のAPIに問い合わせ、その結果をアプリに返します。ここで重要なのは、決済事業者からのレスポンスが「成功」「失敗」「タイムアウト」の3パターンあることを想定することです。特にタイムアウトの場合、決済事業者側では処理が進んでいるかもしれません。

# バックエンド(Ruby on Rails)での例
def cancel_purchase(purchase_id)
  purchase = Purchase.find(purchase_id)
  
  # 決済事業者へのキャンセルリクエスト
  result = PaymentGateway.cancel(purchase.transaction_id)
  
  case result.status
  when :success
    purchase.update(status: :cancelled, cancelled_at: Time.current)
    { success: true, message: "キャンセルが完了しました" }
  when :already_completed
    # 決済が既に完了している場合は返金に切り替える
    purchase.update(status: :refund_pending)
    { success: false, message: "返金手続きに切り替えます" }
  when :timeout
    # タイムアウトした場合は状態を保留にして、管理画面で確認できるようにする
    purchase.update(status: :cancel_pending)
    { success: false, message: "処理中です。しばらく待ってからご確認ください" }
  end
end

ポイントは、決済事業者からの応答に応じて、アプリ側の状態を適切に更新することです。「キャンセル失敗」と判定した場合も、その理由によって次のアクションが異なります。

決済事業者の状態と、アプリの状態を同期させるメカニズム

もう一つの重要な設計は、定期的に決済事業者の状態を問い合わせる仕組みです。アプリからのリクエストが成功したと見えても、決済事業者側で何か問題が起きていることがあります。

バックエンドでは、購入レコードに対して「状態確認が必要」というフラグを立て、バッチ処理で定期的に決済事業者に問い合わせます。

# 日次バッチで実行
def sync_payment_status
  # キャンセル待機中、または返金待機中の購入を抽出
  Purchase.where(status: [:cancel_pending, :refund_pending])
    .where("updated_at < ?", 1.hour.ago)
    .find_each do |purchase|
    
    current_status = PaymentGateway.fetch_status(purchase.transaction_id)
    
    case current_status
    when :cancelled
      purchase.update(status: :cancelled)
    when :refunded
      purchase.update(status: :refunded)
    when :active
      # キャンセルに失敗していた。ユーザーに通知が必要
      purchase.update(status: :cancel_failed)
      notify_user(purchase.user_id, "キャンセルできませんでした")
    end
  end
end

この仕組みにより、アプリとサーバー、決済事業者の間で状態のズレが生じても、最終的には一致させることができます。

運用で気をつけるべき点

実装後の運用では、以下の点に注意してください。

決済事業者の仕様変更に気づく:決済事業者は、セキュリティ強化やシステム更新に伴い、仕様を変更することがあります。特にキャンセルや返金の条件が変わることがあります。定期的に仕様書を確認し、変更があれば実装に反映させる必要があります。

ユーザーサポートとの連携:「キャンセルしたのに課金された」という問い合わせが来た場合、ユーザーが本当にキャンセル完了まで待たずにアプリを閉じた可能性があります。サポート担当者に対して、「キャンセル要求中の状態で閉じた場合、決済事業者では処理が続いている可能性がある」ことを周知しておくと、対応がスムーズになります。

ログの記録:キャンセルや返金の処理では、決済事業者からのレスポンス、その時刻、処理結果を詳細に記録しておきます。後から「なぜこの購入がこの状態なのか」という問い合わせが来た時、ログから原因を特定できます。

設計段階での確認チェックリスト

決済事業者との仕様を確認する際は、以下を明確にしておくと後のトラブルを減らせます。

  • キャンセル可能な状態は何か、そしてその判定は誰がするのか(アプリか、決済事業者か)
  • キャンセルと返金の区別、および手数料や期限の有無
  • 非同期処理でタイムアウトした場合、決済事業者側はどういう状態になるのか
  • 決済事業者側で「キャンセル失敗」となった場合、ユーザーに伝えるメッセージ
  • 状態確認のためのAPI、ポーリング間隔、ログ保持期間

これらを開発開始前に決済事業者と合意しておくことで、実装段階でのやり直しや、本番運用後の不具合を大きく減らせます。