4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Phoenix1.8でモバイルでも使えるシンプルなStripe決済を実装した

Posted at

はじめに

本記事はPhoenixにStripeで決済機能を実装する方法を解説します

Stripeとは

Stripeは、オンライン決済を簡単かつ安全に導入できる決済プラットフォームで
ドキュメントが非常に充実しており
決済も単発の決済、サブスクリプションに加え
売買する場所を提供するプラットフォームの決済も簡単に実装できます

今回はモバイルアプリ上のPhoenixで使うことを想定しているので

  • Checkout Session(Webアプリからstripeのページに移動して完了後リダイレクトする)
    ではなく
  • PaymentIntent(決済そのものの状態管理データを作成、アプリ内で完結)
    で実装してきます

環境

OS macOS 15.6
CPU m4
Elixir 1.18.4
Erlang 27.3.4
Phoenix 1.8.1

実装が必要なもの

本記事は決済に必要となる以下の実装方法を解説します

  • Stripeとの通信(Elixir) -> Elixirライブラリのstripity_stripeを使用します
  • 専用フォーム(JS Hooks) -> JSライブラリStripe.jsを使用します
  • WebHookAPI -> ローカル環境での検証用にStripe CLIを使用します

プロジェクトの作成

こちらを参考に進めていきます

サンプルに簡易ECとかだとコードがB側とC側で多くなってしますので、ただ管理者に金を送るだけのプロジェクトdonateを作り決済だけに焦点を絞ります

mix phx.new donate
cd donate
mix ecto.create

CRUD作成

mix phx.gen.auth Accounts User users
mix deps.get
mix ecto.migrate

カラムがstringの場合はカラム名のみ、string以外は:[型名]で指定します
またPhoenix 1.8からはphx.gen.authをしたあとは勝手にリレーション周りをいい感じにしてくれるので
user_id:references:usersを付ける必要はありません

mix phx.gen.live Payments Payment payments email name amount:integer currency payment_intent_id payment_method_id refund_id status
mix ecto.migrate

statusのEnum化と必須でない項目をrequiredから外します

lib/donate/payments/payment.ex
defmodule Donate.Payments.Payment do
  use Ecto.Schema
  import Ecto.Changeset

  schema "payments" do
    ...
-   field :status, :string
+   field :status, Ecto.Enum,
+     values: [:pending, :processing, :succeeded, :failed, :cancel],
+     default: :pending

    field :user_id, :id

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(payment, attrs, user_scope) do
    payment
    |> cast(attrs, [
      :email,
      :name,
      :amount,
      :currency,
      :payment_intent_id,
      :payment_method_id,
      :refund_id,
      :status
    ])
    |> validate_required([
      :email,
      :name,
      :amount,
      :currency
-     :payment_intent_id,
-     :payment_method_id,
-     :refund_id,
-     :status      
    ])
    |> put_change(:user_id, user_scope.user.id)
  end
end

認証が必要なスコープに以下のように追加します

lib/donate_web/router.ex:L48
  ## Authentication routes

  scope "/", DonateWeb do
    pipe_through [:browser, :require_authenticated_user]

    live_session :require_authenticated_user,
      on_mount: [{DonateWeb.UserAuth, :require_authenticated}] do
      live "/users/settings", UserLive.Settings, :edit
      live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email

+     live "/payments", PaymentLive.Index, :index
+     live "/payments/new", PaymentLive.Form, :new
+     live "/payments/:id", PaymentLive.Show, :show
+     live "/payments/:id/edit", PaymentLive.Form, :edit
    end

    post "/users/update-password", UserSessionController, :update_password
  end

stripity_stripeのセットアップ

以下のライブラリを追加します

installationには2.0とあります(2025/10/1現在)が、最新は3.2となっています

mix.exs
  defp deps do
    [
      ...
-     {:bandit, "~> 1.5"}
+     {:bandit, "~> 1.5"},
+     {:stripity_stripe, "~> 3.2"}
    ]
  end

Stripe通信用のAPIキーとは別にアプリ内で使う公開可能キーをアプリ名で登録しておきます

config/dev.exs
config :stripity_stripe,
  api_key: "your api secret key"

config :donate,
  stripe_publishable_key: "your publishable key"

APIシークレットキーと公開可能キー(publishable_key)はStripe登録後のダッシュボード右上か
ページ下部にある開発者 > APIキーから見られると思います

スクリーンショット 2025-10-01 1.46.58.png

フォーム修正(PaymentIntent作成まで)

ユーザー入力でなく、システムで入力するデータのinputを削除します

lib/donate_web/live/payment_live/form.ex:L7
  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <.header>
        {@page_title}
        <:subtitle>Use this form to manage payment records in your database.</:subtitle>
      </.header>
      
      <.form for={@form} id="payment-form" phx-change="validate" phx-submit="save">
        <.input field={@form[:email]} type="text" label="Email" />
        <.input field={@form[:name]} type="text" label="Name" />
        <.input field={@form[:amount]} type="number" label="Amount" />
-       <.input field={@form[:currency]} type="text" label="Currency" />
-       <.input field={@form[:payment_intent_id]} type="text" label="Payment intent" />
-       <.input field={@form[:payment_method_id]} type="text" label="Payment method" />
-       <.input field={@form[:refund_id]} type="text" 
-       <.input field={@form[:status]} type="text" label="Status" />
        <footer>
          <.button phx-disable-with="Saving..." variant="primary">Save Payment</.button>
          <.button navigate={return_path(@current_scope, @return_to, @payment)}>Cancel</.button>
        </footer>
      </.form>
    </Layouts.app>
    """
  end

stripeのフォームの表示制御とstripeのデータを保持する intentをnilでアサインします

lib/donate_web/live/payment_live/form.ex:L33
  @impl true
  def mount(params, _session, socket) do
    {:ok,
     socket
+    |> assign(:intent, nil)
     |> assign(:return_to, return_to(params["return_to"]))
     |> apply_action(socket.assigns.live_action, params)}
  end

saveイベント時に、通貨(currency)にjpyを入れる処理を追加します

lib/donate_web/live/payment_live/form.ex:L74
  def handle_event("save", %{"payment" => payment_params}, socket) do
+   payment_params = Map.merge(payment_params, %{"currency" => "jpy"})
    save_payment(socket, socket.assigns.live_action, payment_params)
  end

saveイベント時にcreate_payment_intenthandle_infoを発火させて、先にpaymentを作成します
同じ画面にとどまるので、push_navigateput_flashを削除して、paymentをアサインして、Stripeへ通信中フラグをアサインします

lib/donate_web/live/payment_live/form.ex:L99
  defp save_payment(socket, :new, payment_params) do
    case Payments.create_payment(socket.assigns.current_scope, payment_params) do
      {:ok, payment} ->
+       send(self(), {:create_payment_intent, payment})

        {:noreply,
         socket
-        |> put_flash(:info, "Payment created successfully")
-        |> push_navigate(to: return_path(socket.assigns.current_scope, socket.assigns.return_to, payment))
+        |> assign(:intent, :processing)
+        |> assign(:payment, payment)
         }

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end

create_payment_intentを作成します
Stripe.Customer.createを実行して、購入者データを作成します
(今回は使い捨てのように使いますが、userに紐づけると同一ユーザーからの購入として管理がしやすそうです)
作成した購入者データのIDをつけて、決済状態管理データを作成します
作成が完了したら、Stripe決済フォーム作成に必要な公開可能キーとクライアントシークレットをアサインします

lib/donate_web/live/payment_live/form.ex:L112
  @impl true
  def handle_info({:create_payment_intent, payment}, socket) do
    with {:ok, stripe_customer} <-
           Stripe.Customer.create(%{email: payment.email, name: payment.name}),
         {:ok, payment_intent} <-
           Stripe.PaymentIntent.create(%{
             customer: stripe_customer.id,
             automatic_payment_methods: %{enabled: true},
             amount: payment.amount,
             currency: payment.currency
           }) do
      Payment.update_payment(conn.assigns.current_scope, payment, %{
        payment_intent_id: payment_intent.id
      })

      pk = Application.fetch_env!(:donate, :stripe_publishable_key)
      
     {
       :noreply,
       assign(socket, :intent, 
        %{client_secret: payment_intent.client_secret, pk_secret: pk})
     }
    else
      {:error, error} ->
        {:noreply, put_flash(socket, :error, "決済エラー: #{error.message}")}

      error ->
        {:noreply, put_flash(socket, :error, "決済エラー: #{error.message}")}
    end
  end

フォーム修正(Payment Element表示から決済完了まで)

intentによる表示制御を追加して、stripeのフォームを表示するためのformを作成します
stripeフォームが表示されたあとに画面更新を防ぐのにphx-update="ignore"を設定
Liveviewのデータをdata-xxを通してJS Hookへ渡します

lib/donate_web/live/payment_live/form.ex:L7
  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <.header>
        {@page_title}
        <:subtitle>Use this form to manage payment records in your database.</:subtitle>
      </.header>
      
+     <%= if is_nil(@intent) do %>
        <.form for={@form} id="payment-form" phx-change="validate" phx-submit="save">
          <.input field={@form[:email]} type="text" label="Email" />
          <.input field={@form[:name]} type="text" label="Name" />
          <.input field={@form[:amount]} type="number" label="Amount" />
          <footer>
            <.button phx-disable-with="Saving..." variant="primary">Save Payment</.button>
            <.button navigate={return_path(@current_scope, @return_to, @payment)}>Cancel</.button>
          </footer>
        </.form>
+     <% else %>
+       <%= if @intent != :processing do %>
+          <div
+           id="payment-form"
+           phx-hook="StripePayment"
+           phx-update="ignore"
+           data-client-secret={@intent.client_secret}
+           data-pk={@intent.pk_secret}
+         >
+           <form id="payment-form-inner">
+             <div id="payment-element"></div>
+             <.button phx-disable-with="paying..." variant="primary">Pay</.button>             
+           </form>
+         </div>
+       <% end %>
+     <% end %>
    </Layouts.app>
    """
  end

rootレイアウトにStripe.jsの読み込みコードを追加します

lib/donate_web/components/layouts/root.html.heex
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="csrf-token" content={get_csrf_token()} />
    <.live_title default="Donate" suffix=" · Phoenix Framework">
      {assigns[:page_title]}
    </.live_title>
    <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
+   <script src="https://js.stripe.com/v3/" phx-track-static></script>
    ...
  </head>
  ...
</html>

JS Hookを以下のように作ります
決済が成功したらthis.pushEvent("stripe_success", paymentIntent);を実行してLiveView側のhandle_eventを発火させます

assets/js/Stripe.js
const StripePayment = {
  mounted() {
    const clientSecret = this.el.dataset.clientSecret;
    const pk = this.el.dataset.pk;
    const stripe = Stripe(pk);

    const elements = stripe.elements({ clientSecret });

    const paymentElement = elements.create("payment");
    paymentElement.mount("#payment-element");

    document
      .getElementById("payment-form-inner")
      .addEventListener("submit", async (e) => {
        e.preventDefault();

        const { error, paymentIntent } = await stripe.confirmPayment({
          elements,
          confirmParams: {
            return_url: window.location.href,
          },
          redirect: "if_required",
        });

        if (error) {
          console.error("Payment failed:", error.message);
          this.pushEvent("stripe_error", error);
          return;
        }

        if (paymentIntent && paymentIntent.status === "succeeded") {
          this.pushEvent("stripe_success", paymentIntent);
        }
      });
  },
};

export default StripePayment;
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/donate"
import topbar from "../vendor/topbar"
+ import StripePayment from "./Stripe"

const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
  longPollFallbackMs: 2500,
  params: {_csrf_token: csrfToken},
- hooks: {...colocatedHooks},
+ hooks: {StripePayment: StripePayment,...colocatedHooks},
})

e71b2389d09fc566a5de1c1bb2afb8c5.gif

キャンセルの実装

返金処理を実装してきます
といってもIDを元に関数を1つ実行するだけですので実に簡単です

キャンセルボタンを設置して、クリックしたらcancelイベントを発火させます

lib/donate_web/live/payment_live/show.ex:L6
  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      ...
+     <.button phx-click="cancel" class="btn btn-error">キャンセル</.button>
    </Layouts.app>
    """
  end

Stripe.Refund.createで返金処理を作成して、そのIDをpaymentに保存します

lib/donate_web/live/payment_live/show.ex:L51
  @impl true
  def handle_event("cancel", _params, socket) do
    payment = socket.assigns.payment

    {:ok, stripe} =
      Stripe.Refund.create(%{payment_intent: payment.payment_intent_id})

    {:ok, payment} =
      Payments.update_payment(socket.assigns.current_scope, payment, %{
        status: :processing,
        refund_id: stripe.id
      })

    socket
    |> assign(:payment, payment)
    |> put_flash(:error, "キャンセルしました")
    |> then(&{:noreply, &1})
  end

スクリーンショット 2025-10-01 12.00.05.png

WebHookAPIの作成

一応上記のままでも決済は完了しているのですが、確実に完了しましたとstripe側から通知をしてもらうためにWehbookでAPIを叩いてもらいます

WebHookの実行にbodyの生データがいるのですが、途中で消えてしまうのでplugでconnにアサインする処理を入れます

bodyの生データを読み込んでraw_bodyにアサインするplugを作成します

lib/donate_web/cache_body_reader.ex
defmodule DonateWeb.CacheBodyReader do
  @moduledoc """
  BodyParser for stripe webhook
  """
  def read_body(conn, opts) do
    {:ok, body, conn} = Plug.Conn.read_body(conn, opts)
    {:ok, body, Plug.Conn.assign(conn, :raw_body, body)}
  end
end

endpointにbody_readerとしてさっき作成したplugを追加します

lib/donate_web/endpoint.ex:L45
  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library(),
    body_reader: {DonateWeb.CacheBodyReader, :read_body, []}

追加したらWebhookのハンドリングを行うAPIを実装します

plugでアサインしたbodyの生データとヘッダーにあるsignature、webhookシークレットを取得し、 Stripe.Webhook.construct_eventを実行します

そうするとWebHookのイベントを取得するので内容毎にパターンマッチングで処理を分けていきます

  • 決済に成功したらstatusをsucceededに変更
  • 決済に失敗したらstatusをfailedに変更
  • キャンセルが完了したらstatusをcancelに変更
  • 処理を行わないイベントタイプは何もせず200を返す
  • エラーの場合は400を返す
lib/donate_web/controllers/stripe_webhook_controller.ex
defmodule DonateWeb.StripeWebhookController do
  use DonateWeb, :controller

  alias Donate.Repo
  alias Donate.Payments.Payment

  require Logger

  def handle(conn, _params) do
    raw = conn.assigns[:raw_body] || ""
    [sig] = get_req_header(conn, "stripe-signature")
    secret = fetch_secret()

    case Stripe.Webhook.construct_event(raw, sig, secret) do
      {:ok, %Stripe.Event{type: "payment_intent.succeeded", data: %{object: obj}}} ->
        Repo.get_by(Payment, payment_intent_id: obj.id)
        |> case do
          %Payment{} = payment ->
            Ecto.Changeset.change(payment, status: :succeeded)
            |> Repo.update()

          nil ->
            Logger.warning("ticket not found by pi #{obj.id}")
        end

        send_resp(conn, 200, "ok")

      {:ok, %Stripe.Event{type: "payment_intent.failed", data: %{object: obj}}} ->
        Repo.get_by(Payment, payment_intent_id: obj.id)
        |> case do
          %Payment{} = payment ->
            Ecto.Changeset.change(payment, status: :failed)
            |> Repo.update()

          nil ->
            Logger.warning("ticket not found by pi #{obj.id}")
        end

        send_resp(conn, 200, "ok")
        
      {:ok, %Stripe.Event{type: "charge.refund.updated", data: %{object: obj}}} ->
        Repo.get_by(Payment, refund_id: obj.id)
        |> case do
          %Payment{} = payment ->
            Ecto.Changeset.change(payment, status: :cancel)
            |> Repo.update()

          nil ->
            Logger.warning("payment not found by refund #{obj.id}")
        end

        send_resp(conn, 200, "ok")

      {:ok, event} ->
        Logger.info("Unhandled event: #{event.type}")
        send_resp(conn, 200, "ok")

      {:error, reason} ->
        Logger.error("Invalid webhook: #{inspect(reason)}")
        send_resp(conn, 400, "invalid")
    end
  end

  defp fetch_secret(),
    do: Application.get_env(:donate, :stripe_webhook_secret)
end

controlelrができたらrouterに以下のように追加します

lib/eventle_web/router.ex:L20
  scope "/", DonateWeb do
    pipe_through :api

    post "/stripe/webhook", StripeWebhookController, :handle
  end

次にローカルで動かすためにStripe CLIをインストールします
以下を参考に進めていきます

brew install stripe/stripe-cli/stripe

インストールが完了したら作成したStripeのアカウントでloginを行ってください

stripe login

ログイン後以下のようにforward設定を行います

stripe listen --forward-to localhost:4000/stripe/webhook

実行するとシークレットが発行されるのでそれを環境変数として設定します

Ready! You are using Stripe API Version [2025-06-30.basil]. Your webhook signing secret is whsec_c502aede0.......06316a31e49080c64674d3fc33cea8e6 (^C to quit)
config/dev.exs:L94
config :donate,
- stripe_publishable_key: "your publishable key"
+ stripe_publishable_key: "your publishable key",
+ stripe_webhook_secret: "your webhook secret"

決済完了直後はstatusがprocessingですが、リロード後はsucceededに変わりちゃんとwebhookが実行されているのがわかります

スクリーンショット 2025-10-01 11.12.30.png

スクリーンショット 2025-10-01 11.12.38.png

キャンセルも同様にstatusがprocessingですが、リロード後はcancelに変わりちゃんとwebhookが実行されているのがわかります

スクリーンショット 2025-10-01 12.00.05.png

スクリーンショット 2025-10-01 12.00.13.png

これで決済機能の実装が完了しました

モバイルで行う場合

決済系のシークレットを抜かれたりするのは非常に危険なので、APIサーバーを作成しコード上に置かないようにしましょう。

決済フォームを表示するのに必要なクライアントシークレットと公開可能キーをPaymentIntent作成後クライアントにわたします。
それ以外はAPIサーバーにリクエストを送る形式で実装できるかと思います

  def create_stripe_intent(conn, %{"id" => id}) do
    payment = Payments.get_payment!(conn.assigns.current_scope, id)
    %{email: email, name: name, amount: amount, currency: currency} = payment

    with {:ok, stripe_customer} <-
           Stripe.Customer.create(%{email: email, name: name}
           ),
         {:ok, payment_intent} <-
           Stripe.PaymentIntent.create(
             %{
               customer: stripe_customer.id,
               automatic_payment_methods: %{enabled: true},
               amount: amount,
               currency: currency
             }
           ) do
      Payments.update_payment(conn.assigns.current_scope, payment, %{
        payment_intent_id: payment_intent.id
      })

      pk = Application.fetch_env!(:donate, :stripe_publishable_key)
      render(conn, :stripe, %{intent: payment_intent, pk_secret: pk})
    else
      {:error, stripe_error} ->
        {:error, stripe_error.code}

      error ->
        {:error, error}
    end
  end

最後に

Stripeを使うことで簡単にセキュアな決済機能を実装することができました
SubscriptionやConnectによるプラットフォーム運営もそこまで難しくなく実装できるかと思うので、そちらの実装記事も書いてみたいなとは思います

本記事は以上になりますありがとうございました

参考サイト

https://fullstackphoenix.com/tutorials/setup-stripe-with-phoenix-liveview
https://hexdocs.pm/stripity_stripe/readme.html
https://docs.stripe.com/js
https://docs.stripe.com/stripe-cli
https://zenn.dev/manase/scraps/9cf7225a2f80e1

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?