Packages

phoenix_kit

1.7.49
1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules billing providers stripe.ex
Raw

lib/modules/billing/providers/stripe.ex

defmodule PhoenixKit.Modules.Billing.Providers.Stripe do
@moduledoc """
Stripe payment provider implementation.
This module implements the `PhoenixKit.Modules.Billing.Providers.Provider` behaviour
for Stripe payments. It supports:
- Hosted Checkout for one-time payments
- Setup sessions for saving payment methods
- Charging saved payment methods (for subscription renewals)
- Webhook signature verification
- Refunds
## Configuration
Configure Stripe in your provider settings:
# Via Admin UI: /admin/settings/billing/providers
# Or via Settings API:
PhoenixKit.Modules.Billing.update_provider_config(:stripe, %{
enabled: true,
mode: "test",
api_key: "sk_test_...",
webhook_secret: "whsec_..."
})
## Webhook Events
Configure your Stripe webhook to send these events:
- `checkout.session.completed` - Payment completed
- `checkout.session.expired` - Session expired
- `payment_intent.succeeded` - Payment succeeded (for saved cards)
- `payment_intent.payment_failed` - Payment failed
- `charge.refunded` - Refund processed
- `setup_intent.succeeded` - Card saved successfully
## Dependencies
Requires the `stripe` hex package:
{:stripe, "~> 1.1"}
"""
@behaviour PhoenixKit.Modules.Billing.Providers.Provider
alias PhoenixKit.Modules.Billing.Providers.Types.{
ChargeResult,
CheckoutSession,
PaymentMethodInfo,
RefundResult,
SetupSession,
WebhookEventData
}
alias PhoenixKit.Settings
require Logger
@stripe_api_version "2023-10-16"
# Provider identification
@impl true
def provider_name, do: :stripe
@impl true
def available? do
config = get_config()
config[:enabled] && config[:api_key] && config[:api_key] != ""
end
@doc """
Creates a Stripe Checkout Session for one-time payment.
## Options
- `:success_url` - URL to redirect after successful payment (required)
- `:cancel_url` - URL to redirect if user cancels (required)
- `:save_payment_method` - Whether to save card for future use (default: false)
- `:customer_email` - Pre-fill customer email
- `:metadata` - Additional metadata to attach
## Examples
iex> create_checkout_session(invoice, success_url: "https://...", cancel_url: "https://...")
{:ok, %{id: "cs_test_...", url: "https://checkout.stripe.com/..."}}
"""
@impl true
def create_checkout_session(invoice, opts) do
with {:ok, config} <- ensure_configured() do
line_items = build_line_items(invoice)
params = %{
mode: "payment",
line_items: line_items,
success_url: Keyword.fetch!(opts, :success_url),
cancel_url: Keyword.fetch!(opts, :cancel_url),
client_reference_id: to_string(invoice.uuid),
metadata: %{
invoice_id: to_string(invoice.uuid),
invoice_number: invoice.invoice_number
}
}
params =
params
|> maybe_add_customer_email(invoice, opts)
|> maybe_add_save_payment_method(opts)
|> maybe_add_custom_metadata(opts)
case stripe_request(:post, "/checkout/sessions", params, config) do
{:ok, %{"id" => id, "url" => url, "expires_at" => expires_at}} ->
{:ok,
%CheckoutSession{
id: id,
url: url,
provider: :stripe,
expires_at: DateTime.from_unix!(expires_at),
metadata: %{invoice_id: invoice.uuid}
}}
{:error, reason} ->
Logger.error("Stripe checkout session creation failed: #{inspect(reason)}")
{:error, reason}
end
end
end
@doc """
Creates a Stripe Setup Session to save a payment method.
## Options
- `:success_url` - URL to redirect after success (required)
- `:cancel_url` - URL to redirect if user cancels (required)
- `:customer_email` - Customer email
## Examples
iex> create_setup_session(user, success_url: "https://...", cancel_url: "https://...")
{:ok, %{id: "seti_...", url: "https://checkout.stripe.com/..."}}
"""
@impl true
def create_setup_session(user, opts) do
with {:ok, config} <- ensure_configured(),
{:ok, customer_id} <- ensure_customer(user, config) do
params = %{
mode: "setup",
customer: customer_id,
success_url: Keyword.fetch!(opts, :success_url),
cancel_url: Keyword.fetch!(opts, :cancel_url),
payment_method_types: ["card"],
metadata: %{
user_id: to_string(user.uuid)
}
}
case stripe_request(:post, "/checkout/sessions", params, config) do
{:ok, %{"id" => id, "url" => url}} ->
{:ok,
%SetupSession{
id: id,
url: url,
provider: :stripe,
metadata: %{user_id: user.uuid, customer_id: customer_id}
}}
{:error, reason} ->
Logger.error("Stripe setup session creation failed: #{inspect(reason)}")
{:error, reason}
end
end
end
@doc """
Charges a saved payment method.
Used for subscription renewals where the payment method was previously saved.
## Options
- `:currency` - Currency code (default: EUR)
- `:description` - Description for the charge
- `:invoice_id` - Associated invoice ID
- `:metadata` - Additional metadata
## Examples
iex> charge_payment_method(payment_method, Decimal.new("99.00"), currency: "EUR")
{:ok, %{id: "pi_...", provider_transaction_id: "ch_...", status: "succeeded"}}
"""
@impl true
def charge_payment_method(payment_method, amount, opts) do
with {:ok, config} <- ensure_configured() do
currency = Keyword.get(opts, :currency, "EUR") |> String.downcase()
amount_cents = Decimal.mult(amount, 100) |> Decimal.round() |> Decimal.to_integer()
params = %{
amount: amount_cents,
currency: currency,
customer: payment_method.provider_customer_id,
payment_method: payment_method.provider_payment_method_id,
off_session: true,
confirm: true,
description: Keyword.get(opts, :description, "PhoenixKit subscription payment"),
metadata:
%{
payment_method_id: to_string(payment_method.uuid)
}
|> maybe_merge_invoice_metadata(opts)
}
case stripe_request(:post, "/payment_intents", params, config) do
{:ok, %{"id" => id, "status" => "succeeded", "latest_charge" => charge_id}} ->
{:ok,
%ChargeResult{
id: id,
provider_transaction_id: charge_id,
amount: amount,
currency: String.upcase(currency),
status: "succeeded",
metadata: %{payment_intent_id: id}
}}
{:ok, %{"status" => "requires_action"}} ->
{:error, :requires_action}
{:ok, %{"status" => "requires_payment_method"}} ->
{:error, :card_declined}
{:error, %{"code" => "card_declined"}} ->
{:error, :card_declined}
{:error, %{"code" => "expired_card"}} ->
{:error, :payment_method_expired}
{:error, reason} ->
Logger.error("Stripe charge failed: #{inspect(reason)}")
{:error, reason}
end
end
end
@doc """
Verifies Stripe webhook signature.
Uses Stripe's signature verification to ensure the webhook came from Stripe.
## Examples
iex> verify_webhook_signature(raw_body, signature_header, webhook_secret)
:ok
iex> verify_webhook_signature(raw_body, "invalid", webhook_secret)
{:error, :invalid_signature}
"""
@impl true
def verify_webhook_signature(payload, signature, secret) do
# Stripe signature format: t=timestamp,v1=signature
with {:ok, parts} <- parse_signature(signature),
{:ok, timestamp} <- Map.fetch(parts, "t"),
{:ok, expected_sig} <- Map.fetch(parts, "v1"),
:ok <- verify_timestamp(timestamp),
:ok <- verify_signature(payload, timestamp, expected_sig, secret) do
:ok
else
_ -> {:error, :invalid_signature}
end
end
@doc """
Handles and normalizes Stripe webhook events.
## Supported Events
- `checkout.session.completed` - Checkout payment completed
- `checkout.session.expired` - Checkout session expired
- `payment_intent.succeeded` - Payment intent succeeded
- `payment_intent.payment_failed` - Payment failed
- `charge.refunded` - Charge refunded
- `setup_intent.succeeded` - Setup intent completed (card saved)
## Examples
iex> handle_webhook_event(%{"type" => "checkout.session.completed", ...})
{:ok, %{type: "checkout.completed", event_id: "evt_...", data: %{...}}}
"""
@impl true
def handle_webhook_event(%{"type" => type, "id" => event_id, "data" => %{"object" => object}}) do
case normalize_event(type, object) do
{:ok, normalized} ->
{:ok,
%WebhookEventData{
type: normalized.type,
event_id: event_id,
data: normalized.data,
provider: :stripe,
raw_payload: object
}}
{:error, :unknown_event} ->
Logger.debug("Unknown Stripe event type: #{type}")
{:error, :unknown_event}
end
end
def handle_webhook_event(_payload) do
{:error, :invalid_payload}
end
@doc """
Creates a refund for a Stripe charge.
## Options
- `:reason` - Reason for refund ("duplicate", "fraudulent", "requested_by_customer")
- `:metadata` - Additional metadata
## Examples
iex> create_refund("ch_xxx", Decimal.new("50.00"), reason: "requested_by_customer")
{:ok, %{id: "re_...", provider_refund_id: "re_...", amount: #Decimal<50.00>}}
"""
@impl true
def create_refund(provider_transaction_id, amount, opts) do
with {:ok, config} <- ensure_configured() do
params = %{
charge: provider_transaction_id
}
params =
if amount do
amount_cents = Decimal.mult(amount, 100) |> Decimal.round() |> Decimal.to_integer()
Map.put(params, :amount, amount_cents)
else
params
end
params =
case Keyword.get(opts, :reason) do
nil -> params
reason -> Map.put(params, :reason, reason)
end
case stripe_request(:post, "/refunds", params, config) do
{:ok, %{"id" => id, "amount" => amount_cents, "status" => status}} ->
{:ok,
%RefundResult{
id: id,
provider_refund_id: id,
amount: Decimal.div(Decimal.new(amount_cents), 100),
status: status,
metadata: %{}
}}
{:error, %{"code" => "charge_already_refunded"}} ->
{:error, :already_refunded}
{:error, reason} ->
Logger.error("Stripe refund failed: #{inspect(reason)}")
{:error, reason}
end
end
end
@doc """
Gets details of a saved payment method from Stripe.
## Examples
iex> get_payment_method_details("pm_xxx")
{:ok, %{id: "pm_xxx", type: "card", brand: "visa", last4: "4242", ...}}
"""
@impl true
def get_payment_method_details(provider_payment_method_id) do
with {:ok, config} <- ensure_configured() do
case stripe_request(:get, "/payment_methods/#{provider_payment_method_id}", nil, config) do
{:ok,
%{
"id" => id,
"type" => type,
"card" => %{
"brand" => brand,
"last4" => last4,
"exp_month" => exp_month,
"exp_year" => exp_year
}
}} ->
{:ok,
%PaymentMethodInfo{
id: id,
provider: :stripe,
provider_payment_method_id: id,
provider_customer_id: nil,
type: type,
brand: brand,
last4: last4,
exp_month: exp_month,
exp_year: exp_year,
metadata: %{}
}}
{:ok, %{"id" => id, "type" => type}} ->
{:ok,
%PaymentMethodInfo{
id: id,
provider: :stripe,
provider_payment_method_id: id,
provider_customer_id: nil,
type: type,
brand: nil,
last4: nil,
exp_month: nil,
exp_year: nil,
metadata: %{}
}}
{:error, %{"code" => "resource_missing"}} ->
{:error, :not_found}
{:error, reason} ->
Logger.error("Stripe get payment method failed: #{inspect(reason)}")
{:error, reason}
end
end
end
@doc """
Detaches a payment method from its customer.
## Examples
iex> detach_payment_method("pm_xxx")
:ok
"""
@impl true
def detach_payment_method(provider_payment_method_id) do
with {:ok, config} <- ensure_configured() do
case stripe_request(
:post,
"/payment_methods/#{provider_payment_method_id}/detach",
%{},
config
) do
{:ok, _} -> :ok
{:error, %{"code" => "resource_missing"}} -> {:error, :not_found}
{:error, reason} -> {:error, reason}
end
end
end
# ===========================================
# Private Helpers
# ===========================================
defp get_config do
%{
enabled: Settings.get_setting("billing_stripe_enabled", "false") == "true",
api_key: Settings.get_setting("billing_stripe_api_key", ""),
webhook_secret: Settings.get_setting("billing_stripe_webhook_secret", "")
}
end
defp ensure_configured do
config = get_config()
if config[:enabled] && config[:api_key] && config[:api_key] != "" do
{:ok, config}
else
{:error, :not_configured}
end
end
defp stripe_request(method, path, body, config) do
url = "https://api.stripe.com/v1#{path}"
headers = [
{"Authorization", "Bearer #{config[:api_key]}"},
{"Content-Type", "application/x-www-form-urlencoded"},
{"Stripe-Version", @stripe_api_version}
]
body_encoded = if body, do: encode_body(body), else: ""
request =
case method do
:get -> Req.new(method: :get, url: url, headers: headers)
:post -> Req.new(method: :post, url: url, headers: headers, body: body_encoded)
end
case Req.request(request) do
{:ok, %{status: status, body: response_body}} when status in 200..299 ->
{:ok, response_body}
{:ok, %{status: _status, body: %{"error" => error}}} ->
{:error, error}
{:ok, %{status: status, body: body}} ->
{:error, %{"status" => status, "body" => body}}
{:error, reason} ->
{:error, reason}
end
end
defp encode_body(map) when is_map(map) do
map
|> flatten_map()
|> URI.encode_query()
end
defp flatten_map(map, prefix \\ "") do
Enum.flat_map(map, fn {key, value} ->
new_key = if prefix == "", do: to_string(key), else: "#{prefix}[#{key}]"
flatten_value(new_key, value)
end)
end
defp flatten_value(key, %{} = nested), do: flatten_map(nested, key)
defp flatten_value(key, list) when is_list(list) do
list
|> Enum.with_index()
|> Enum.flat_map(fn {item, idx} -> flatten_list_item(key, item, idx) end)
end
defp flatten_value(key, value), do: [{key, to_string(value)}]
defp flatten_list_item(key, item, idx) when is_map(item) do
flatten_map(item, "#{key}[#{idx}]")
end
defp flatten_list_item(key, item, idx) do
[{"#{key}[#{idx}]", to_string(item)}]
end
defp build_line_items(invoice) do
(invoice.line_items || [])
|> Enum.map(fn item ->
%{
price_data: %{
currency: String.downcase(invoice.currency || "EUR"),
product_data: %{
name: item["name"] || "Item"
},
unit_amount: parse_amount_cents(item["unit_price"])
},
quantity: item["quantity"] || 1
}
end)
end
defp parse_amount_cents(nil), do: 0
defp parse_amount_cents(amount) when is_binary(amount) do
amount
|> Decimal.new()
|> Decimal.mult(100)
|> Decimal.round()
|> Decimal.to_integer()
end
defp parse_amount_cents(%Decimal{} = amount) do
amount
|> Decimal.mult(100)
|> Decimal.round()
|> Decimal.to_integer()
end
defp parse_amount_cents(amount) when is_number(amount) do
round(amount * 100)
end
defp maybe_add_customer_email(params, invoice, opts) do
email = Keyword.get(opts, :customer_email) || get_invoice_email(invoice)
if email do
Map.put(params, :customer_email, email)
else
params
end
end
defp get_invoice_email(invoice) do
case invoice do
%{billing_details: %{"email" => email}} when is_binary(email) -> email
%{user: %{email: email}} when is_binary(email) -> email
_ -> nil
end
end
defp maybe_add_save_payment_method(params, opts) do
if Keyword.get(opts, :save_payment_method, false) do
Map.merge(params, %{
payment_intent_data: %{
setup_future_usage: "off_session"
}
})
else
params
end
end
defp maybe_add_custom_metadata(params, opts) do
case Keyword.get(opts, :metadata) do
nil -> params
custom -> Map.update!(params, :metadata, &Map.merge(&1, custom))
end
end
defp maybe_merge_invoice_metadata(metadata, opts) do
case Keyword.get(opts, :invoice_id) do
nil -> metadata
invoice_id -> Map.put(metadata, :invoice_id, to_string(invoice_id))
end
end
defp ensure_customer(user, config) do
# Check if user already has a Stripe customer ID from saved payment methods
case get_stripe_customer_id_for_user(user.uuid) do
nil ->
# Create new customer
params = %{
email: user.email,
metadata: %{
user_id: to_string(user.uuid)
}
}
case stripe_request(:post, "/customers", params, config) do
{:ok, %{"id" => customer_id}} ->
{:ok, customer_id}
{:error, reason} ->
{:error, reason}
end
customer_id ->
{:ok, customer_id}
end
end
defp get_stripe_customer_id_for_user(user_uuid) do
import Ecto.Query
query =
from pm in PhoenixKit.Modules.Billing.PaymentMethod,
where: pm.user_uuid == ^user_uuid,
where: pm.provider == "stripe",
where: not is_nil(pm.provider_customer_id),
where: pm.status == "active",
select: pm.provider_customer_id,
limit: 1
PhoenixKit.RepoHelper.repo().one(query)
end
defp parse_signature(signature) do
parts =
signature
|> String.split(",")
|> Enum.map(fn part ->
case String.split(part, "=", parts: 2) do
[key, value] -> {key, value}
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
|> Map.new()
{:ok, parts}
rescue
_ -> {:error, :invalid_format}
end
defp verify_timestamp(timestamp) do
# Stripe recommends rejecting webhooks older than 5 minutes
timestamp_int = String.to_integer(timestamp)
now = System.system_time(:second)
tolerance = 300
if abs(now - timestamp_int) <= tolerance do
:ok
else
{:error, :timestamp_too_old}
end
rescue
_ -> {:error, :invalid_timestamp}
end
defp verify_signature(payload, timestamp, expected_sig, secret) do
signed_payload = "#{timestamp}.#{payload}"
computed_sig =
:crypto.mac(:hmac, :sha256, secret, signed_payload) |> Base.encode16(case: :lower)
if Plug.Crypto.secure_compare(computed_sig, expected_sig) do
:ok
else
{:error, :signature_mismatch}
end
end
defp normalize_event("checkout.session.completed", object) do
{:ok,
%{
type: "checkout.completed",
data: %{
session_id: object["id"],
payment_status: object["payment_status"],
customer_id: object["customer"],
customer_email: object["customer_email"],
payment_intent_id: object["payment_intent"],
setup_intent_id: object["setup_intent"],
invoice_id: get_in(object, ["metadata", "invoice_id"]),
mode: object["mode"],
amount_total: object["amount_total"],
currency: object["currency"]
}
}}
end
defp normalize_event("checkout.session.expired", object) do
{:ok,
%{
type: "checkout.expired",
data: %{
session_id: object["id"],
invoice_id: get_in(object, ["metadata", "invoice_id"])
}
}}
end
defp normalize_event("payment_intent.succeeded", object) do
{:ok,
%{
type: "payment.succeeded",
data: %{
payment_intent_id: object["id"],
charge_id: object["latest_charge"],
amount: object["amount"],
currency: object["currency"],
customer_id: object["customer"],
payment_method_id: object["payment_method"],
invoice_id: get_in(object, ["metadata", "invoice_id"])
}
}}
end
defp normalize_event("payment_intent.payment_failed", object) do
{:ok,
%{
type: "payment.failed",
data: %{
payment_intent_id: object["id"],
error_code: get_in(object, ["last_payment_error", "code"]),
error_message: get_in(object, ["last_payment_error", "message"]),
customer_id: object["customer"],
invoice_id: get_in(object, ["metadata", "invoice_id"])
}
}}
end
defp normalize_event("charge.refunded", object) do
{:ok,
%{
type: "refund.created",
data: %{
charge_id: object["id"],
amount_refunded: object["amount_refunded"],
currency: object["currency"],
refund_id: List.first(object["refunds"]["data"] || [])["id"]
}
}}
end
defp normalize_event("setup_intent.succeeded", object) do
{:ok,
%{
type: "setup.completed",
data: %{
setup_intent_id: object["id"],
payment_method_id: object["payment_method"],
customer_id: object["customer"],
user_id: get_in(object, ["metadata", "user_id"])
}
}}
end
defp normalize_event(_type, _object) do
{:error, :unknown_event}
end
end