Last active
April 14, 2025 19:53
-
-
Save benvp/ae32016ca3449c977326ebce420ed493 to your computer and use it in GitHub Desktop.
Integrate Elixir/Phoenix with paddle.com -> See https://twitter.com/benvp_/status/1617624545791205379
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script src="https://cdn.paddle.com/paddle/paddle.js"></script> | |
<%= if vendor_id = Application.get_env(:my_app, :paddle)[:vendor_id] do %> | |
<%= if Application.get_env(:my_app, :paddle)[:sandbox] do %> | |
<script type="text/javascript"> | |
Paddle.Environment.set("sandbox"); | |
Paddle.Setup({ vendor: <%= vendor_id %> }); | |
</script> | |
<% else %> | |
<script type="text/javascript"> | |
Paddle.Setup({ vendor: <%= vendor_id %> }); | |
</script> | |
<% end %> | |
<script type="text/javascript"> | |
function openCheckout(successUrl) { | |
Paddle.Checkout.open({ | |
product: <%= @paddle_product_id %>, | |
success: successUrl, | |
locale: "<%= @locale %>", | |
email: "<%= @current_user.email %>", | |
passthrough: | |
"<%= Jason.encode!(%{ user_id: @current_user.id}) |> raw() |> javascript_escape() %>", | |
}); | |
} | |
</script> | |
<%= if assigns[:license] do %> | |
<script type="text/javascript"> | |
function openCancelCheckout(successUrl) { | |
Paddle.Checkout.open({ | |
override: "<%= @license.cancel_url |> raw() %>", | |
success: successUrl, | |
product: <%= @paddle_product_id %>, | |
locale: "<%= @locale %>", | |
email: "<%= @current_user.email %>", | |
passthrough: | |
"<%= Jason.encode!(%{ user_id: @current_user.id}) |> raw() |> javascript_escape() %>", | |
}); | |
} | |
</script> | |
<% end %> | |
<% end %> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule MyApp.Licensing.License do | |
use Ecto.Schema | |
import Ecto.Changeset | |
alias MyApp.Accounts.User | |
@primary_key false | |
@foreign_key_type :binary_id | |
schema "licenses" do | |
belongs_to :user, User, type: :binary_id, primary_key: true | |
field :issued_at, :utc_datetime | |
field :valid_until, :utc_datetime | |
field :cancelled_at, :utc_datetime | |
# paddle specific fields | |
field :subscription_id, :string | |
field :customer_id, :string | |
field :cancel_url, :string | |
field :update_url, :string | |
timestamps(type: :utc_datetime) | |
end | |
@doc false | |
def changeset(license, attrs) do | |
license | |
|> cast(attrs, [ | |
:user_id, | |
:valid_until, | |
:issued_at, | |
:cancelled_at, | |
:subscription_id, | |
:customer_id, | |
:cancel_url, | |
:update_url | |
]) | |
|> validate_required([ | |
:valid_until, | |
:issued_at, | |
:subscription_id, | |
:customer_id, | |
:cancel_url, | |
:update_url | |
]) | |
|> assoc_constraint(:user) | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule MyAppWeb.PaddleController do | |
use MyAppWeb, :controller | |
require Logger | |
alias MyApp.{Accounts, Licensing} | |
alias MyApp.Licensing.License | |
def webhook(conn, %{"alert_name" => "subscription_created"} = params) do | |
user = fetch_user(params) | |
attrs = %{ | |
subscription_id: params["subscription_id"], | |
cancel_url: params["cancel_url"], | |
update_url: params["update_url"], | |
customer_id: params["user_id"] | |
} | |
with true <- is_valid_product(params["subscription_plan_id"]), | |
{:ok, %License{} = license} <- Licensing.issue_license(user, attrs) do | |
Logger.info("Issued license. #{inspect(license)}") | |
send_resp(conn, 200, "") | |
else | |
{:error, changeset} -> | |
Logger.error("Unable to issue license. Error: #{inspect(changeset)}") | |
send_resp(conn, 400, "") | |
false -> | |
invalid_product_error(conn) | |
end | |
end | |
def webhook(conn, %{"alert_name" => "subscription_cancelled"} = params) do | |
user = fetch_user(params) | |
IO.inspect(params) | |
with true <- is_valid_product(params["subscription_plan_id"]), | |
{:ok, %License{} = license} <- Licensing.cancel_license(user) do | |
Logger.info("License has been cancelled. #{inspect(license)}") | |
send_resp(conn, 200, "") | |
else | |
{:error, changeset} -> | |
Logger.error("Unable to cancel license. Error: #{inspect(changeset)}") | |
send_resp(conn, 400, "") | |
false -> | |
invalid_product_error(conn) | |
end | |
end | |
def webhook(conn, %{"alert_name" => "subscription_updated"} = params) do | |
user = fetch_user(params) | |
attrs = %{ | |
cancel_url: params["cancel_url"], | |
update_url: params["update_url"] | |
} | |
with true <- is_valid_product(params["subscription_plan_id"]), | |
license <- Licensing.get_license(user), | |
{:ok, license} <- Licensing.update_license(license, attrs) do | |
Logger.info("License has been updated. #{inspect(license)}") | |
send_resp(conn, 200, "") | |
else | |
{:error, error} -> | |
Logger.error("Unable to update license. Error: #{inspect(error)}") | |
send_resp(conn, 400, "") | |
false -> | |
invalid_product_error(conn) | |
end | |
end | |
def webhook(conn, _) do | |
send_resp(conn, 501, "") | |
end | |
defp is_valid_product(product_id) when is_binary(product_id) do | |
String.to_integer(product_id) == product_id() | |
end | |
defp is_valid_product(product_id) when is_integer(product_id) do | |
product_id == product_id() | |
end | |
defp product_id() do | |
Application.get_env(:MyApp, :paddle)[:product_id] | |
end | |
defp fetch_user(%{"passthrough" => passthrough}) do | |
%{"user_id" => user_id} = Jason.decode!(passthrough) | |
Accounts.get_user!(user_id) | |
end | |
defp invalid_product_error(conn) do | |
conn | |
|> put_status(400) | |
|> json(%{ | |
error: %{ | |
status: 400, | |
message: "invalid product id" | |
} | |
}) | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule PaddleSignature do | |
@behaviour Plug | |
import Plug.Conn | |
import Phoenix.Controller, only: [json: 2] | |
@impl true | |
def init(opts), do: opts | |
@impl true | |
def call(conn, _) do | |
with {:ok, signature, message} <- parse(conn), | |
:ok <- verify(signature, message) do | |
conn | |
else | |
{:error, error} -> | |
conn | |
|> put_status(400) | |
|> json(%{ | |
error: %{ | |
status: 400, | |
message: error | |
} | |
}) | |
|> halt() | |
end | |
end | |
defp parse(%{body_params: %{"p_signature" => p_signature}} = conn) do | |
signature = Base.decode64!(p_signature) | |
message = | |
conn.body_params | |
|> Map.drop(["p_signature"]) | |
|> Map.new(fn {k, v} -> {k, to_string(v)} end) | |
|> PhpSerializer.serialize() | |
{:ok, signature, message} | |
end | |
defp parse(_), do: {:error, "p_signature missing"} | |
defp verify(signature, message) do | |
[rsa_entry] = | |
public_key() | |
|> :public_key.pem_decode() | |
rsa_public_key = :public_key.pem_entry_decode(rsa_entry) | |
case :public_key.verify(message, :sha, signature, rsa_public_key) do | |
true -> :ok | |
_ -> {:error, "signature is not correct"} | |
end | |
end | |
defp public_key do | |
Application.fetch_env!(:my_app, :paddle) | |
|> Keyword.fetch!(:public_key) | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule PaddleWhitelist do | |
@behaviour Plug | |
import Plug.Conn | |
import Phoenix.Controller, only: [json: 2] | |
@impl true | |
def init(opts), do: opts | |
@impl true | |
def call(conn, _) do | |
if is_whitelisted?(conn) do | |
conn | |
else | |
conn | |
|> put_status(403) | |
|> json(%{ | |
error: %{ | |
status: 403, | |
message: "host not allowed" | |
} | |
}) | |
|> halt() | |
end | |
end | |
def is_whitelisted?(%{remote_ip: remote_ip} = _conn) do | |
remote_ip in ip_whitelist() | |
end | |
defp ip_whitelist do | |
Application.fetch_env!(:my_app, :paddle) | |
|> Keyword.fetch!(:ip_whitelist) | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule MyAppWeb.Router do | |
use MyAppWeb, :router | |
pipeline :webhook do | |
plug RemoteIp | |
plug PaddleWhitelist | |
plug PaddleSignature | |
end | |
scope "/webhooks", MyAppWeb do | |
pipe_through :webhook | |
post "/paddle", PaddleController, :webhook | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment