Skip to content

Commit

Permalink
Merge pull request #59 from peek-travel/feature/ipn-support
Browse files Browse the repository at this point in the history
IPN router builder
  • Loading branch information
doughsay authored Feb 16, 2019
2 parents a262385 + 102355e commit 506d170
Show file tree
Hide file tree
Showing 13 changed files with 387 additions and 4 deletions.
6 changes: 5 additions & 1 deletion .formatter.exs
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]
]
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Tipalti

[![Build Status](https://travis-ci.org/peek-travel/tipalti-elixir.svg?branch=master)](https://travis-ci.org/peek-travel/tipalti-elixir) [![codecov](https://codecov.io/gh/peek-travel/tipalti-elixir/branch/master/graph/badge.svg)](https://codecov.io/gh/peek-travel/tipalti-elixir) [![Hex.pm Version](https://img.shields.io/hexpm/v/tipalti.svg?style=flat)](https://hex.pm/packages/tipalti) [![Inline docs](http://inch-ci.org/github/peek-travel/tipalti-elixir.svg)](http://inch-ci.org/github/peek-travel/tipalti-elixir) [![License](https://img.shields.io/hexpm/l/tipalti.svg)](LICENSE.md)

[Tipalti](https://tipalti.com/) integration library for Elixir.

This library includes:
* Payee and Payer SOAP API clients
* iFrame integration helpers

* Payee and Payer SOAP API clients
* iFrame integration helpers

> **NOTE**: Not all API functions have been implemented yet; this library is a work in progress.
Expand All @@ -21,4 +23,17 @@ def deps do
end
```

## Configuration

There are 3 required configuration options for this library to work. See an example `config.exs` below:

```elixir
use Mix.Config

config :tipalti,
payer: "MyPayer",
mode: :sandbox,
master_key: "boguskey"
```

Documentation can be found at [https://hexdocs.pm/tipalti](https://hexdocs.pm/tipalti).
3 changes: 2 additions & 1 deletion coveralls.json
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"
]
}
1 change: 1 addition & 0 deletions lib/tipalti.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule Tipalti do
* `Tipalti.API.Payee` - Payee SOAP API client
* `Tipalti.API.Payer` - Payer SOAP API client
* `Tipalti.IPN.Router` - Router builder Tipalti Instant Payment Notifications (IPN)
* `Tipalti.IFrame.InvoiceHistory` - Invoice History iFrame integration helper
* `Tipalti.IFrame.PaymentsHistory` - Payments History iFrame integration helper
* `Tipalti.IFrame.SetupProcess` - Setup Process iFrame integration helper
Expand Down
1 change: 1 addition & 0 deletions lib/tipalti/api/soap/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule Tipalti.API.SOAP.Client do
require Logger
alias Tipalti.RequestError

# 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/soap+xml; charset=utf-8"}]
Expand Down
68 changes: 68 additions & 0 deletions lib/tipalti/ipn/client.ex
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
149 changes: 149 additions & 0 deletions lib/tipalti/ipn/router.ex
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
3 changes: 3 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ defmodule Tipalti.MixProject do
extras: ["README.md", "LICENSE.md"],
groups_for_modules: [
API: [Tipalti.API.Payee, Tipalti.API.Payer],
IPN: [Tipalti.IPN.Router],
IFrames: [Tipalti.IFrame.InvoiceHistory, Tipalti.IFrame.PaymentsHistory, Tipalti.IFrame.SetupProcess],
"Data types": [
Tipalti.Balance,
Expand Down Expand Up @@ -91,6 +92,8 @@ defmodule Tipalti.MixProject do
{:hackney, "~> 1.11"},
{:inch_ex, ">= 0.0.0", only: :docs},
{:inflex, "~> 1.10"},
{:mox, "~> 0.5", only: :test},
{:plug, "~> 1.6 or ~> 1.7"},
{:tesla, "~> 1.0"},
{:xml_builder, "~> 2.1"}
]
Expand Down
3 changes: 3 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"},
"mox": {:hex, :mox, "0.5.0", "c519b48407017a85f03407a9a4c4ceb7cc6dec5fe886b2241869fb2f08476f9e", [:mix], [], "hexpm"},
"nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
"plug": {:hex, :plug, "1.7.2", "d7b7db7fbd755e8283b6c0a50be71ec0a3d67d9213d74422d9372effc8e87fd1", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"},
"plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"},
"poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"},
"tesla": {:hex, :tesla, "1.2.1", "864783cc27f71dd8c8969163704752476cec0f3a51eb3b06393b3971dc9733ff", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"},
Expand Down
6 changes: 6 additions & 0 deletions test/support/body_reader.ex
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
2 changes: 2 additions & 0 deletions test/support/mocks.ex
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)
2 changes: 2 additions & 0 deletions test/test_helper.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ ExUnit.start()

Application.put_env(:tipalti, :system_time_module, SystemTimeMock)
Application.put_env(:tipalti, :api_client_module, ClientMock)
Application.put_env(:tipalti, :ipn_client_module, IPNClientMock)
Application.put_env(:tipalti, :ipn_body_reader, BodyReaderMock)

{:ok, files} = File.ls("./test/support")

Expand Down
Loading

0 comments on commit 506d170

Please sign in to comment.