-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #59 from peek-travel/feature/ipn-support
IPN router builder
- Loading branch information
Showing
13 changed files
with
387 additions
and
4 deletions.
There are no files selected for viewing
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,10 @@ | ||
# Used by "mix format" | ||
locals_without_parens = [on: 2] | ||
|
||
[ | ||
inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"], | ||
import_deps: [:tesla], | ||
line_length: 120 | ||
line_length: 120, | ||
locals_without_parens: locals_without_parens, | ||
export: [locals_without_parens: locals_without_parens] | ||
] |
This file contains 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
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
{ | ||
"skip_files": [ | ||
"lib/sweet_xml", | ||
"lib/tipalti/api/soap/client" | ||
"lib/tipalti/api/soap/client", | ||
"lib/tipalti/ipn/client" | ||
] | ||
} |
This file contains 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
This file contains 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
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
defmodule Tipalti.IPN.Client do | ||
@moduledoc false | ||
|
||
use Tesla | ||
import Tipalti.Config | ||
require Logger | ||
alias Tipalti.RequestError | ||
|
||
defmodule Behavior do | ||
@moduledoc false | ||
|
||
@callback ack(String.t()) :: :ok | {:error, RequestError.t() | :bad_ipn} | ||
end | ||
|
||
# NOTE: the `client_recv_timeout` is configurable, but set at compile time | ||
adapter Tesla.Adapter.Hackney, recv_timeout: Application.get_env(:tipalti, :client_recv_timeout, 60_000) | ||
|
||
plug Tesla.Middleware.Headers, [{"Content-Type", "application/x-www-form-urlencoded"}] | ||
|
||
@url [ | ||
sandbox: "https://console.sandbox.tipalti.com/notif/ipn.aspx", | ||
production: "https://console.tipalti.com/notif/ipn.aspx" | ||
] | ||
|
||
@spec ack(String.t()) :: :ok | {:error, RequestError.t() | :bad_ipn} | ||
def ack(payload) do | ||
url = @url[mode()] | ||
log_request(url, payload) | ||
|
||
case post(url, payload) do | ||
{:ok, env = %Tesla.Env{status: 200, body: status}} -> | ||
log_response(env) | ||
|
||
case status do | ||
"VERIFIED" -> :ok | ||
"INVALID" -> {:error, :bad_ipn} | ||
end | ||
|
||
{:ok, env = %Tesla.Env{status: status}} -> | ||
log_response(env) | ||
{:error, {:bad_http_response, status}} | ||
|
||
{:error, reason} -> | ||
:ok = Logger.error(fn -> "[Tipalti IPN] request failed: " <> inspect(reason) end) | ||
{:error, {:request_failed, reason}} | ||
end | ||
end | ||
|
||
defp log_request(url, payload) do | ||
:ok = | ||
Logger.debug(fn -> | ||
""" | ||
[Tipalti IPN] ->> sending event acknowledgement to #{url} | ||
#{payload} | ||
""" | ||
end) | ||
end | ||
|
||
defp log_response(env) do | ||
:ok = | ||
Logger.debug(fn -> | ||
""" | ||
[Tipalti IPN] <<- received #{env.status} | ||
#{env.body} | ||
""" | ||
end) | ||
end | ||
end |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
defmodule Tipalti.IPN.Router do | ||
@moduledoc """ | ||
A router builder for handling incoming Tipalti Instant Payment Notifications (IPN). | ||
## Usage | ||
defmodule MyApp.Tipalti.IPNRouter do | ||
use Tipalti.IPN.Router, scope: "/events" | ||
on "bill_updated", MyApp.Tipalti.OnBillUpdated | ||
end | ||
""" | ||
|
||
require Logger | ||
|
||
import Plug.Conn | ||
|
||
alias Plug.Conn | ||
alias Tipalti.IPN | ||
|
||
@doc false | ||
defmacro __using__(opts) do | ||
scope = Keyword.get(opts, :scope) | ||
|
||
quote do | ||
use Plug.Router | ||
|
||
# @behaviour Tipalti.IPN.Router | ||
|
||
@scope unquote(scope) | ||
|
||
plug :match | ||
plug :dispatch | ||
|
||
import Tipalti.IPN.Router, only: [on: 2, do_call: 2] | ||
|
||
@before_compile Tipalti.IPN.Router | ||
end | ||
end | ||
|
||
@doc false | ||
defmacro __before_compile__(_env) do | ||
quote do | ||
import Plug.Router | ||
|
||
match(_, do: var!(conn)) | ||
|
||
import Plug.Router, only: [] | ||
end | ||
end | ||
|
||
@doc """ | ||
Adds a listening route for incoming POST events. | ||
The route is built from the `:scope` option given to the router, and the event name. | ||
## Example | ||
defmodule MyApp.Tipalti.IPNRouter do | ||
use Tipalti.IPN.Router, scope: "/events" | ||
on "bill_updated", MyApp.Tipalti.OnBillUpdated | ||
end | ||
The above would create a new route responding to POST requests at `/events/bill_updated`. The module given must define | ||
a `call` event that receives the event as a map of string key value pairs, and return `:ok` to signal it successfully | ||
processed the event. | ||
""" | ||
defmacro on(event, module) do | ||
quote do | ||
path = [@scope, unquote(event)] |> Enum.reject(&is_nil/1) |> Enum.join("/") | ||
|
||
post path do | ||
do_call(var!(conn), unquote(module)) | ||
end | ||
end | ||
end | ||
|
||
@doc false | ||
def do_call(conn, module) do | ||
body_reader = Application.get_env(:tipalti, :ipn_body_reader, Conn) | ||
|
||
case body_reader.read_body(conn, []) do | ||
{:ok, body, conn} -> | ||
event_params = Conn.Query.decode(body) | ||
|
||
case handle_event(module, event_params) do | ||
:ok -> | ||
ack!(body) | ||
|
||
conn | ||
|> send_resp(200, "OK") | ||
|> halt() | ||
|
||
error -> | ||
raise "Unable to process IPN: #{inspect(error)}" | ||
end | ||
|
||
{:more, _data, _conn} -> | ||
raise Plug.BadRequestError | ||
|
||
{:error, :timeout} -> | ||
raise Plug.TimeoutError | ||
|
||
{:error, _} -> | ||
raise Plug.BadRequestError | ||
end | ||
end | ||
|
||
defp handle_event(module, %{"type" => type} = event) when type in ~w( | ||
bill_updated | ||
completed | ||
deferred | ||
error | ||
payee_compliance_document_closed | ||
payee_compliance_document_request | ||
payee_compliance_screening_result | ||
payee_details_changed | ||
payee_invoices_sync_now | ||
payer_fees | ||
payment_cancelled | ||
payment_submitted | ||
payments_group_approved | ||
payments_group_declined | ||
) do | ||
module.call(event) | ||
end | ||
|
||
defp handle_event(_, event) do | ||
:ok = Logger.warn("[Tipalti IPN] Invalid event received: #{inspect(event)}") | ||
:ok | ||
end | ||
|
||
defp ack!(body) do | ||
client = Application.get_env(:tipalti, :ipn_client_module, IPN.Client) | ||
|
||
case client.ack(body) do | ||
:ok -> | ||
:ok | ||
|
||
{:error, :bad_ipn} -> | ||
:ok = Logger.warn("[Tipalti IPN] Invalid IPN received") | ||
:ok | ||
|
||
error -> | ||
raise "Unable to ack IPN: #{inspect(error)}" | ||
end | ||
end | ||
end |
This file contains 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
This file contains 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
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
defmodule BodyReader do | ||
alias Plug.Conn | ||
|
||
@callback read_body(Conn.t(), Keyword.t()) :: | ||
{:ok, binary(), Conn.t()} | {:more, binary(), Conn.t()} | {:error, term()} | ||
end |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
Mox.defmock(IPNClientMock, for: Tipalti.IPN.Client.Behavior) | ||
Mox.defmock(BodyReaderMock, for: BodyReader) |
This file contains 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
Oops, something went wrong.