Skip to content

Instantly share code, notes, and snippets.

@benvp
Last active April 18, 2024 12:52
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save benvp/ae32016ca3449c977326ebce420ed493 to your computer and use it in GitHub Desktop.
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
<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 %>
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
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
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
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
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
<button type="button" onclick={"openCheckout('#{@upgrade_success_url}')"}>
Subscribe now
</button>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment