Skip to content
157 changes: 143 additions & 14 deletions lib/facebook.ex
Original file line number Diff line number Diff line change
Expand Up @@ -104,18 +104,47 @@ defmodule Facebook do
"""
@type scope :: atom | String.t

@type using_appsecret :: boolean
@typedoc """
A reason for settling a payment dispute.

Reasons:
* `:GRANTED_REPLACEMENT_ITEM`
* `:DENIED_REFUND`
* `:BANNED_USER`
"""
@type dispute_reason :: atom | String.t

@typedoc """
A reason for refunding a payment.

Reasons:
* `:MALICIOUS_FRAUD`
* `:FRIENDLY_FRAUD`
* `:CUSTOMER_SERVICE`
"""
@type refunds_reason :: atom | String.t

@type currency :: String.t
@type amount :: Number.t

@typedoc """
A base64-encoded JSON string, concatenated to a signature with a single dot.
E.g.: "<base64-encoded hmac/sha256 signature>.<base64-encoded JSON payload>"
"""
@type signed_request :: String.t

@type using_app_secret :: boolean

@doc """
If you want to use an appsecret proof, pass it into set_appsecret:
If you want to use an appsecret proof, pass it into set_app_secret:

## Example
iex> Facebook.set_appsecret("appsecret")
iex> Facebook.set_app_secret("app_secret")

See: https://developers.facebook.com/docs/graph-api/securing-requests
"""
def set_appsecret(appsecret) do
Config.appsecret(appsecret)
def set_app_secret(app_secret) do
Config.app_secret(app_secret)
end

@doc """
Expand Down Expand Up @@ -256,8 +285,8 @@ defmodule Facebook do
@spec picture(page_id, type :: String.t, access_token) :: resp
def picture(page_id, type, access_token) do
params = [type: type, redirect: false]
|> add_access_token(access_token)
|> add_app_secret(access_token)
|> add_access_token(access_token)

~s(/#{page_id}/picture)
|> GraphAPI.get([], params: params)
Expand Down Expand Up @@ -493,6 +522,73 @@ defmodule Facebook do
|> summary_count_all
end

@doc """
Gets payment info about a single payment.

## Examples
iex> Facebook.payment("769860109692136", "<App Access Token>", "id,request_id,actions")
{:ok, %{"request_id" => "abc2387238", "id" => "116397053038597", "actions" => [ %{ "type" => "charge", ... } ] } }

See:
* https://developers.facebook.com/docs/graph-api/reference/payment
"""
@spec payment(object_id, access_token, fields) :: resp
def payment(payment_id, access_token, fields \\ "") do
params = [fields: fields]
|> add_access_token(access_token)

~s(/#{payment_id})
|> GraphAPI.get([], params: params)
|> ResponseFormatter.format_response
end

@doc """
Settle a payment dispute.

## Examples
iex> Facebook.payment_dispute("769860109692136", "<App Access Token>", :DENIED_REFUND)
{:ok, %{"success" => true}}

See:
* https://developers.facebook.com/docs/graph-api/reference/payment/dispute
"""
@spec payment_dispute(object_id, access_token, dispute_reason) :: resp
def payment_dispute(payment_id, access_token, reason) do
params = []
|> add_access_token(access_token)
body = URI.encode_query(%{reason: reason})

~s(/#{payment_id}/dispute)
|> GraphAPI.post(body, params: params)
|> ResponseFormatter.format_response
end

@doc """
Refund a payment.

## Examples
iex> Facebook.payment_refunds("769860109692136", "<App Access Token>", "EUR", 10.99, :CUSTOMER_SERVICE)
{:ok, %{"success" => true}}

See:
* https://developers.facebook.com/docs/graph-api/reference/payment/refunds
"""
# credo:disable-for-lines:1 Credo.Check.Readability.MaxLineLength
@spec payment_refunds(object_id, access_token, currency, amount, refunds_reason) :: resp
def payment_refunds(payment_id, access_token, currency, amount, reason) do
params = []
|> add_access_token(access_token)
body = URI.encode_query(%{
currency: currency,
amount: amount,
reason: reason
})

~s(/#{payment_id}/refunds)
|> GraphAPI.post(body, params: params)
|> ResponseFormatter.format_response
end

@doc """
Exchange an authorization code for an access token.

Expand Down Expand Up @@ -616,6 +712,31 @@ defmodule Facebook do
|> ResponseFormatter.format_response()
end

@doc """
Decodes a signed request from a client SDK (in-app payments), verifies the
signature and (if it is valid) returns its decoded contents.
"""
@spec decode_signed_request(signed_request) :: resp
def decode_signed_request(signed_request) when is_binary(signed_request) do
with [signature_str | [payload_str | _]]
<- String.split(signed_request, "."),
{:ok, signature} <- Base.url_decode64(signature_str),
_signature_verification = ^signature <- signature(payload_str),
{:ok, payload} <- Base.url_decode64(payload_str),
{:ok, payload} <- JSON.decode(payload)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO this should be doable in a simpler way.
What's the purpose of the guard in the function? If signed_request is not a binary and you call that function then there won't be another function executed instead.
Also why the with with another guard (if you split a binary it should still be a binary)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm quite new to Elixir, maybe it really can be done better: I used the inner guards to ensure there won't be nil values. Which, you're right, is complete nonsense in the case of split() as it doesn't return nil values. I'll remove them.
However, guard'ing the function itself prevents it from being called with a wrong value and raise an exception instead of trying to call split() with a wrong argument. What do you think about that? How could I do it better?

do
{:ok, payload}
else
_ -> {:error, %{}}
end
end

# Builds a signature just like Facebook does for its signed_requests.
def sign(payload) do
payload_str = Base.url_encode64(payload)
"#{signature_base64(payload_str)}.#{payload_str}"
end

# Request access token and extract the access token from the access token
# response
defp get_access_token(params) do
Expand Down Expand Up @@ -656,21 +777,29 @@ defmodule Facebook do
|> (& {:ok, &1}).()
end

# 'Encrypts' the token together with the app secret according to the
# guidelines of facebook.
defp encrypt(token) do
:sha256
|> :crypto.hmac(Config.appsecret, token)
|> Base.encode16(case: :lower)
# Hashes the token together with the app secret according to the
# guidelines of facebook to build an unencoded/raw signature.
defp signature(str) do
:crypto.hmac(:sha256, Config.app_secret(), str)
end

# Uses signature/1 to build a urlsafe base64-encoded signature
defp signature_base64(str) do
str |> signature() |> Base.url_encode64()
end

# Uses signature/1 to build a lowercase base16-encoded signature
defp signature_base16(str) do
str |> signature() |> Base.encode16(case: :lower)
end

# Add the appsecret_proof to the GraphAPI request params if the app secret is
# defined
defp add_app_secret(params, access_token) do
if is_nil(Config.appsecret) do
if is_nil(Config.app_secret()) do
params
else
params ++ [appsecret_proof: encrypt(access_token)]
params ++ [appsecret_proof: signature_base16(access_token)]
end
end

Expand Down
37 changes: 34 additions & 3 deletions lib/facebook/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,43 @@ defmodule Facebook.Config do
Application.fetch_env! :facebook, :graph_video_url
end

# App secret
# App secret a.k.a. client secret
def appsecret do
Application.fetch_env! :facebook, :appsecret
IO.warn("'appsecret' method is deprecated. Please use 'app_secret'", Macro.Env.stacktrace(__ENV__))
app_secret()
end
def app_secret do
with :error <- Application.fetch_env(:facebook, :appsecret)
do
Application.fetch_env!(:facebook, :app_secret)
else
{:ok, secret} ->
IO.warn("'appsecret' configuration value is deprecated. Please use 'app_secret'", Macro.Env.stacktrace(__ENV__))
secret
end
end

def appsecret(appsecret) do
Application.put_env :facebook, :appsecret, appsecret
IO.warn("'appsecret' method value is deprecated. Please use 'app_secret'", Macro.Env.stacktrace(__ENV__))
app_secret(appsecret)
end
def app_secret(app_secret) do
case Application.fetch_env(:facebook, :appsecret) do
{:ok, _} ->
IO.warn("'appsecret' configuration value is deprecated. Please use 'app_secret'", Macro.Env.stacktrace(__ENV__))
Application.put_env :facebook, :appsecret, app_secret
_ ->
Application.put_env :facebook, :app_secret, app_secret
end
end

# App id, a.k.a. client id
def app_id do
Application.fetch_env! :facebook, :app_id
end

# App access token
def app_access_token do
Application.fetch_env! :facebook, :app_access_token
end
end
4 changes: 3 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ defmodule Facebook.Mixfile do
env: :dev,
graph_url: "https://graph.facebook.com/v2.9",
graph_video_url: "https://graph-video.facebook.com/v2.9",
appsecret: nil
app_secret: nil,
app_id: nil,
app_access_token: nil
]
]
end
Expand Down
74 changes: 74 additions & 0 deletions test/facebook_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ defmodule FacebookTest do
@app_secret "456"
@page_id 19292868552 # This is the facebook for developers page id
@test_page_id 629965917187496 # `page_id` the test user created
@payment_id "11639730386596"

setup do
[
Expand Down Expand Up @@ -363,6 +364,79 @@ defmodule FacebookTest do
end
end

describe "payment" do
test "success", %{app_access_token: app_access_token} do
with_mock :hackney, GraphMock.mock_options(
fn(_) -> GraphMock.payment(:success, :no_fields) end
) do
assert {:ok, %{"id" => "#{@payment_id}", "created_time" => "2018-01-28T00:33:19+0000"}} = Facebook.payment(
@payment_id,
app_access_token
)
end
end
end

describe "payment with fields" do
test "success", %{app_access_token: app_access_token} do
with_mock :hackney, GraphMock.mock_options(
fn(_) -> GraphMock.payment(:success, :with_fields) end
) do
assert {:ok, %{"request_id" => "A76449","id" => "#{@payment_id}", "actions" => [ %{} ]}} = Facebook.payment(
@payment_id,
app_access_token,
"id,request_id,actions,payout_foreign_exchange_rate"
)
end
end
end

describe "payment dispute" do
test "success", %{app_access_token: app_access_token} do
with_mock :hackney, GraphMock.mock_options(
fn(_) -> GraphMock.dispute(:success) end
) do
assert {:ok, %{"success" => true}} = Facebook.payment_dispute(
@payment_id,
app_access_token,
:REFUND_DENIED
)
end
end
end

describe "payment refunds" do
test "success", %{app_access_token: app_access_token} do
with_mock :hackney, GraphMock.mock_options(
fn(_) -> GraphMock.refunds(:success) end
) do
assert {:ok, %{"success" => true}} = Facebook.payment_refunds(
@payment_id,
app_access_token,
"EUR",
10.99,
:CUSTOMER_SERVICE
)
end
end
end

describe "signing" do
Facebook.set_app_secret(@app_secret)

test "payload" do
payload = JSON.encode!(%{id: @payment_id})
assert "EdOhTfnZaIM3-Ht7X_4vgEQnIRq9fpzRgONlMvwRGKI=.eyJpZCI6IjExNjM5NzMwMzg2NTk2In0=" = Facebook.sign(payload)
end

test "decode" do
signed_request = "EdOhTfnZaIM3-Ht7X_4vgEQnIRq9fpzRgONlMvwRGKI=.eyJpZCI6IjExNjM5NzMwMzg2NTk2In0="
assert {:ok, %{
"id" => @payment_id
}} = Facebook.decode_signed_request(signed_request)
end
end

describe "long lived access token" do
test "success", %{access_token: access_token} do
with_mock :hackney, GraphMock.mock_options(
Expand Down
38 changes: 38 additions & 0 deletions test/graph_mock.ex
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,44 @@ defmodule Facebook.GraphMock do
})
end

def payment(:success, :no_fields) do
JSON.encode(%{
"id": "11639730386596",
"created_time": "2018-01-28T00:33:19+0000",
})
end

def payment(:success, :with_fields) do
JSON.encode(%{
"request_id": "A76449",
"id": "11639730386596",
"actions": [
%{
"type": "charge",
"status": "completed",
"currency": "EUR",
"amount": "11.99",
"time_created": "2018-01-28T00:33:19+0000",
"time_updated": "2018-01-28T00:33:20+0000",
"tax_amount": "2.08"
}
],
"payout_foreign_exchange_rate": 1.2308349
})
end

def dispute(:success) do
JSON.encode(%{
"success": true
})
end

def refunds(:success) do
JSON.encode(%{
"success": true
})
end

def mock_options(body_function) do
[
request: request(),
Expand Down