はじめに
本記事は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から外します
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
認証が必要なスコープに以下のように追加します
## 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となっています
defp deps do
[
...
- {:bandit, "~> 1.5"}
+ {:bandit, "~> 1.5"},
+ {:stripity_stripe, "~> 3.2"}
]
end
Stripe通信用のAPIキーとは別にアプリ内で使う公開可能キーをアプリ名で登録しておきます
config :stripity_stripe,
api_key: "your api secret key"
config :donate,
stripe_publishable_key: "your publishable key"
APIシークレットキーと公開可能キー(publishable_key)はStripe登録後のダッシュボード右上か
ページ下部にある開発者 > APIキーから見られると思います
フォーム修正(PaymentIntent作成まで)
ユーザー入力でなく、システムで入力するデータのinputを削除します
@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でアサインします
@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を入れる処理を追加します
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_intent
のhandle_info
を発火させて、先にpaymentを作成します
同じ画面にとどまるので、push_navigate
とput_flash
を削除して、paymentをアサインして、Stripeへ通信中フラグをアサインします
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決済フォーム作成に必要な公開可能キーとクライアントシークレットをアサインします
@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へ渡します
@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の読み込みコードを追加します
<!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を発火させます
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},
})
キャンセルの実装
返金処理を実装してきます
といってもIDを元に関数を1つ実行するだけですので実に簡単です
キャンセルボタンを設置して、クリックしたらcancelイベントを発火させます
@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に保存します
@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
WebHookAPIの作成
一応上記のままでも決済は完了しているのですが、確実に完了しましたとstripe側から通知をしてもらうためにWehbookでAPIを叩いてもらいます
WebHookの実行にbodyの生データがいるのですが、途中で消えてしまうのでplugでconnにアサインする処理を入れます
bodyの生データを読み込んでraw_bodyにアサインするplugを作成します
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を追加します
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を返す
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に以下のように追加します
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 :donate,
- stripe_publishable_key: "your publishable key"
+ stripe_publishable_key: "your publishable key",
+ stripe_webhook_secret: "your webhook secret"
決済完了直後はstatusがprocessingですが、リロード後はsucceededに変わりちゃんとwebhookが実行されているのがわかります
キャンセルも同様にstatusがprocessingですが、リロード後はcancelに変わりちゃんとwebhookが実行されているのがわかります
これで決済機能の実装が完了しました
モバイルで行う場合
決済系のシークレットを抜かれたりするのは非常に危険なので、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