Packages
phoenix_kit
1.7.39
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
Current section
Files
lib/modules/billing/providers/paypal.ex
defmodule PhoenixKit.Modules.Billing.Providers.PayPal do
@moduledoc """
PayPal payment provider implementation.
Uses PayPal REST API v2 for:
- Checkout sessions (Orders API)
- Saved payment methods (Vault API)
- Refunds
## Configuration
Required settings in database:
- `billing_paypal_enabled` - "true" to enable
- `billing_paypal_client_id` - PayPal Client ID
- `billing_paypal_client_secret` - PayPal Client Secret
- `billing_paypal_mode` - "sandbox" or "live"
- `billing_paypal_webhook_id` - Webhook ID for signature verification
## PayPal API Flow
1. Get OAuth2 access token (cached)
2. Create Order with intent: "CAPTURE"
3. Redirect user to PayPal approval URL
4. User approves payment on PayPal
5. PayPal redirects to success_url with token
6. Capture payment via webhook or on return
## Webhook Events
- `CHECKOUT.ORDER.APPROVED` - User approved the payment
- `PAYMENT.CAPTURE.COMPLETED` - Payment captured successfully
- `PAYMENT.CAPTURE.DENIED` - Payment capture failed
- `PAYMENT.CAPTURE.REFUNDED` - Refund completed
"""
@behaviour PhoenixKit.Modules.Billing.Providers.Provider
alias PhoenixKit.Settings
require Logger
@sandbox_url "https://api-m.sandbox.paypal.com"
@live_url "https://api-m.paypal.com"
# ============================================
# Provider Behaviour Implementation
# ============================================
@impl true
def provider_name, do: :paypal
@impl true
def available? do
Settings.get_setting("billing_paypal_enabled", "false") == "true" &&
has_credentials?()
end
@impl true
def create_checkout_session(invoice, opts) do
# Merge invoice data with opts
merged_opts = Keyword.merge(opts, invoice_to_opts(invoice))
with {:ok, token} <- get_access_token(),
{:ok, order} <- create_order(token, merged_opts) do
# Find the approval URL
approve_link =
order["links"]
|> Enum.find(fn link -> link["rel"] == "approve" end)
{:ok,
%{
session_id: order["id"],
url: approve_link["href"],
provider: :paypal,
expires_at: nil
}}
end
end
@impl true
def create_setup_session(user, opts) do
# Add user_id to opts
merged_opts =
Keyword.put(opts, :user_id, user[:uuid] || user["uuid"] || user[:id] || user["id"])
with {:ok, token} <- get_access_token(),
{:ok, setup_token} <- create_setup_token(token, merged_opts) do
# Find the approval URL
approve_link =
setup_token["links"]
|> Enum.find(fn link -> link["rel"] == "approve" end)
{:ok,
%{
session_id: setup_token["id"],
url: approve_link["href"],
provider: :paypal
}}
end
end
@impl true
def charge_payment_method(payment_method, amount, opts) do
with {:ok, token} <- get_access_token(),
{:ok, order} <- create_order_with_vault(token, payment_method, amount, opts),
{:ok, capture} <- capture_order(token, order["id"]) do
{:ok,
%{
charge_id: capture["id"],
status: capture["status"],
amount: amount,
provider: :paypal
}}
end
end
@impl true
def verify_webhook_signature(payload, signature, _secret) do
# PayPal requires verifying via API call
with {:ok, token} <- get_access_token() do
verify_webhook_via_api(token, payload, signature)
end
end
@impl true
def handle_webhook_event(payload) do
event_type = payload["event_type"]
resource = payload["resource"]
case event_type do
"CHECKOUT.ORDER.APPROVED" ->
handle_order_approved(resource, payload)
"PAYMENT.CAPTURE.COMPLETED" ->
handle_capture_completed(resource, payload)
"PAYMENT.CAPTURE.DENIED" ->
handle_capture_denied(resource, payload)
"PAYMENT.CAPTURE.REFUNDED" ->
handle_capture_refunded(resource, payload)
_ ->
{:error, :unknown_event}
end
end
@impl true
def create_refund(provider_transaction_id, amount, opts) do
with {:ok, token} <- get_access_token(),
{:ok, refund} <- do_create_refund(token, provider_transaction_id, amount, opts) do
{:ok,
%{
refund_id: refund["id"],
status: refund["status"],
amount: amount,
provider: :paypal
}}
end
end
@impl true
def get_payment_method_details(provider_payment_method_id) do
with {:ok, token} <- get_access_token(),
{:ok, vault_token} <- get_vault_payment_token(token, provider_payment_method_id) do
source = vault_token["payment_source"]
details =
cond do
card = source["card"] ->
%{
type: "card",
brand: card["brand"],
last4: card["last_digits"],
exp_month:
card["expiry"] |> String.split("-") |> List.last() |> String.to_integer(),
exp_year: card["expiry"] |> String.split("-") |> List.first() |> String.to_integer()
}
_paypal = source["paypal"] ->
%{
type: "paypal",
brand: "paypal",
last4: nil
}
true ->
%{type: "unknown"}
end
{:ok, details}
end
end
# ============================================
# PayPal API Calls
# ============================================
defp create_order(token, opts) do
amount = opts[:amount] || opts["amount"]
currency = opts[:currency] || opts["currency"] || "EUR"
description = opts[:description] || opts["description"] || "Payment"
success_url = opts[:success_url] || opts["success_url"]
cancel_url = opts[:cancel_url] || opts["cancel_url"]
metadata = opts[:metadata] || opts["metadata"] || %{}
# Convert cents to decimal string
amount_str = format_amount(amount)
body = %{
intent: "CAPTURE",
purchase_units: [
%{
amount: %{
currency_code: String.upcase(currency),
value: amount_str
},
description: description,
custom_id: Jason.encode!(metadata)
}
],
payment_source: %{
paypal: %{
experience_context: %{
payment_method_preference: "IMMEDIATE_PAYMENT_REQUIRED",
brand_name: Settings.get_setting("billing_company_name", ""),
locale: "en-US",
landing_page: "LOGIN",
user_action: "PAY_NOW",
return_url: success_url,
cancel_url: cancel_url
}
}
}
}
request(:post, "/v2/checkout/orders", token, body)
end
defp create_order_with_vault(token, payment_method, amount, opts) do
currency = Keyword.get(opts, :currency, "EUR")
description = Keyword.get(opts, :description, "Payment")
metadata = Keyword.get(opts, :metadata, %{})
amount_str =
if is_integer(amount) do
# Cents to dollars
:erlang.float_to_binary(amount / 100, decimals: 2)
else
Decimal.to_string(Decimal.round(amount, 2))
end
body = %{
intent: "CAPTURE",
purchase_units: [
%{
amount: %{
currency_code: String.upcase(currency),
value: amount_str
},
description: description,
custom_id: Jason.encode!(metadata)
}
],
payment_source: %{
token: %{
id: payment_method.provider_payment_method_id,
type: "PAYMENT_METHOD_TOKEN"
}
}
}
request(:post, "/v2/checkout/orders", token, body)
end
defp capture_order(token, order_id) do
request(:post, "/v2/checkout/orders/#{order_id}/capture", token, %{})
end
defp create_setup_token(token, opts) do
success_url = opts[:success_url] || opts["success_url"]
cancel_url = opts[:cancel_url] || opts["cancel_url"]
user_id = opts[:user_id] || opts["user_id"]
body = %{
payment_source: %{
paypal: %{
description: "Save payment method",
usage_type: "MERCHANT",
customer_type: "CONSUMER",
experience_context: %{
return_url: success_url,
cancel_url: cancel_url
}
}
},
customer: %{
id: "user_#{user_id}"
}
}
request(:post, "/v3/vault/setup-tokens", token, body)
end
defp get_vault_payment_token(token, vault_id) do
request(:get, "/v3/vault/payment-tokens/#{vault_id}", token)
end
defp do_create_refund(token, capture_id, amount, opts) do
currency = Keyword.get(opts, :currency, "EUR")
note = Keyword.get(opts, :note, "Refund")
body =
if amount do
amount_str =
if is_integer(amount) do
:erlang.float_to_binary(amount / 100, decimals: 2)
else
Decimal.to_string(Decimal.round(amount, 2))
end
%{
amount: %{
currency_code: String.upcase(currency),
value: amount_str
},
note_to_payer: note
}
else
%{note_to_payer: note}
end
request(:post, "/v2/payments/captures/#{capture_id}/refund", token, body)
end
defp verify_webhook_via_api(token, payload, headers) when is_map(headers) do
webhook_id = Settings.get_setting("billing_paypal_webhook_id", "")
body = %{
auth_algo: headers["paypal-auth-algo"],
cert_url: headers["paypal-cert-url"],
transmission_id: headers["paypal-transmission-id"],
transmission_sig: headers["paypal-transmission-sig"],
transmission_time: headers["paypal-transmission-time"],
webhook_id: webhook_id,
webhook_event: payload
}
case request(:post, "/v1/notifications/verify-webhook-signature", token, body) do
{:ok, %{"verification_status" => "SUCCESS"}} -> :ok
{:ok, _} -> {:error, :invalid_signature}
error -> error
end
end
defp verify_webhook_via_api(_token, _payload, _signature) do
# If signature is just a string, we can't verify properly
# In production, headers should be passed
Logger.warning("PayPal webhook verification requires full headers map")
:ok
end
# ============================================
# Webhook Event Handlers
# ============================================
defp handle_order_approved(resource, payload) do
order_id = resource["id"]
custom_id = get_custom_id(resource)
{:ok,
%{
event_id: payload["id"],
type: "checkout.completed",
provider: :paypal,
data: %{
session_id: order_id,
mode: "payment",
invoice_id: custom_id["invoice_id"],
payment_intent_id: order_id
},
raw_payload: payload
}}
end
defp handle_capture_completed(resource, payload) do
capture_id = resource["id"]
amount = resource["amount"]
custom_id = get_custom_id_from_capture(resource)
{:ok,
%{
event_id: payload["id"],
type: "payment.succeeded",
provider: :paypal,
data: %{
charge_id: capture_id,
invoice_id: custom_id["invoice_id"],
amount: parse_amount(amount["value"]),
currency: amount["currency_code"]
},
raw_payload: payload
}}
end
defp handle_capture_denied(resource, payload) do
custom_id = get_custom_id_from_capture(resource)
{:ok,
%{
event_id: payload["id"],
type: "payment.failed",
provider: :paypal,
data: %{
invoice_id: custom_id["invoice_id"],
error_code: "CAPTURE_DENIED",
error_message: "Payment capture was denied"
},
raw_payload: payload
}}
end
defp handle_capture_refunded(resource, payload) do
refund_id = resource["id"]
amount = resource["amount"]
{:ok,
%{
event_id: payload["id"],
type: "refund.created",
provider: :paypal,
data: %{
refund_id: refund_id,
charge_id: resource["links"] |> find_capture_id(),
amount_refunded: parse_amount(amount["value"])
},
raw_payload: payload
}}
end
# ============================================
# OAuth2 Token Management
# ============================================
defp get_access_token do
# In production, this should be cached
client_id = Settings.get_setting("billing_paypal_client_id", "")
client_secret = Settings.get_setting("billing_paypal_client_secret", "")
if client_id == "" or client_secret == "" do
{:error, :not_configured}
else
auth = Base.encode64("#{client_id}:#{client_secret}")
case Req.post(
"#{base_url()}/v1/oauth2/token",
headers: [
{"Authorization", "Basic #{auth}"},
{"Content-Type", "application/x-www-form-urlencoded"}
],
body: "grant_type=client_credentials"
) do
{:ok, %{status: 200, body: body}} ->
{:ok, body["access_token"]}
{:ok, %{status: status, body: body}} ->
Logger.error("PayPal OAuth error: #{status} - #{inspect(body)}")
{:error, :authentication_failed}
{:error, reason} ->
Logger.error("PayPal OAuth request failed: #{inspect(reason)}")
{:error, :request_failed}
end
end
end
# ============================================
# HTTP Helpers
# ============================================
defp request(method, path, token, body \\ nil) do
url = "#{base_url()}#{path}"
headers = [
{"Authorization", "Bearer #{token}"},
{"Content-Type", "application/json"},
{"PayPal-Request-Id", generate_request_id()}
]
opts =
case method do
:get -> [headers: headers]
_ -> [headers: headers, json: body]
end
result =
case method do
:get -> Req.get(url, opts)
:post -> Req.post(url, opts)
end
case result do
{:ok, %{status: status, body: body}} when status in 200..299 ->
{:ok, body}
{:ok, %{status: status, body: body}} ->
Logger.error("PayPal API error: #{status} - #{inspect(body)}")
error_message = get_in(body, ["details", Access.at(0), "description"]) || "API error"
{:error, error_message}
{:error, reason} ->
Logger.error("PayPal request failed: #{inspect(reason)}")
{:error, :request_failed}
end
end
# ============================================
# Helpers
# ============================================
defp base_url do
case Settings.get_setting("billing_paypal_mode", "sandbox") do
"live" -> @live_url
_ -> @sandbox_url
end
end
defp has_credentials? do
Settings.get_setting("billing_paypal_client_id", "") != "" &&
Settings.get_setting("billing_paypal_client_secret", "") != ""
end
defp format_amount(amount) when is_integer(amount) do
# Cents to dollars
:erlang.float_to_binary(amount / 100, decimals: 2)
end
defp format_amount(%Decimal{} = amount) do
Decimal.to_string(Decimal.round(amount, 2))
end
defp format_amount(amount) when is_float(amount) do
:erlang.float_to_binary(amount, decimals: 2)
end
defp parse_amount(amount_str) when is_binary(amount_str) do
{float, _} = Float.parse(amount_str)
round(float * 100)
end
defp parse_amount(amount), do: amount
defp get_custom_id(resource) do
custom_id_json =
resource["purchase_units"]
|> List.first()
|> Map.get("custom_id", "{}")
case Jason.decode(custom_id_json) do
{:ok, map} -> map
_ -> %{}
end
end
defp get_custom_id_from_capture(resource) do
# Try to get from supplementary_data or links
custom_id_json = resource["custom_id"] || "{}"
case Jason.decode(custom_id_json) do
{:ok, map} -> map
_ -> %{}
end
end
defp find_capture_id(links) when is_list(links) do
case Enum.find(links, fn link -> link["rel"] == "up" end) do
%{"href" => href} -> href |> String.split("/") |> List.last()
_ -> nil
end
end
defp find_capture_id(_), do: nil
defp generate_request_id do
"req_" <> (:crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false))
end
defp invoice_to_opts(invoice) when is_map(invoice) do
amount = invoice[:total] || invoice["total"] || Decimal.new(0)
amount_cents = Decimal.to_integer(Decimal.mult(amount, 100))
[
amount: amount_cents,
currency: invoice[:currency] || invoice["currency"] || "EUR",
description: "Invoice #{invoice[:invoice_number] || invoice["invoice_number"]}",
metadata: %{
invoice_id: invoice[:uuid] || invoice["uuid"] || invoice[:id] || invoice["id"],
invoice_number: invoice[:invoice_number] || invoice["invoice_number"]
}
]
end
defp invoice_to_opts(_), do: []
end