├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── commerce_billing.ex └── commerce_billing │ ├── address.ex │ ├── credit_card.ex │ ├── gateways │ ├── base.ex │ ├── bogus.ex │ └── stripe.ex │ ├── response.ex │ └── worker.ex ├── mix.exs ├── mix.lock └── test ├── commerce_billing_test.exs ├── gateways ├── bogus_test.exs └── stripe_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | NOTES 6 | docs 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.3.0 4 | env: MIX_ENV=test 5 | otp_release: 6 | - 19.0 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2015 Joshua Nussbaum 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Commerce.Billing 2 | ================= 3 | [![Build Status](https://secure.travis-ci.org/joshnuss/commerce_billing.svg?branch=master 4 | "Build Status")](https://travis-ci.org/joshnuss/commerce_billing) 5 | 6 | Payment processing library for Elixir. Based on [Shopify's](http://shopify.com) [ActiveMerchant](http://github.com/Shopify/active_merchant) ruby gem 7 | 8 | ## Supported Gateways 9 | 10 | - Bogus 11 | - Stripe 12 | 13 | ## Advantages of Elixir 14 | 15 | - **Fault tolerant**: Each worker is supervised, so a new worker is started in the event of errors. Network errors are caught and payment is retried (not yet working). 16 | - **Distributed**: Run workers on different machines. 17 | - **Scalable**: Run multiple workers and adjust number of workers as needed. 18 | - **Throughput**: Takes advantage of all cores. For example on my laptop with 4 cores (2 threads per core), I can do 100 authorizations with Stripe in 10 seconds. Thats 864,000 transactions per day. ebay does 1.4M/day. 19 | - **Hot code swap**: Update code while the system is running 20 | 21 | ## Card processing example 22 | 23 | ```elixir 24 | alias Commerce.Billing 25 | alias Billing.{CreditCard, Address, Worker, Gateways} 26 | 27 | config = %{credentials: {"sk_test_BQokikJOvBiI2HlWgH4olfQ2", ""}, 28 | default_currency: "USD"} 29 | 30 | Worker.start_link(Gateways.Stripe, config, name: :my_gateway) 31 | 32 | card = %CreditCard{ 33 | name: "John Smith", 34 | number: "4242424242424242", 35 | expiration: {2017, 12}, 36 | cvc: "123" 37 | } 38 | 39 | address = %Address{ 40 | street1: "123 Main", 41 | city: "New York", 42 | region: "NY", 43 | country: "US", 44 | postal_code: "11111" 45 | } 46 | 47 | case Billing.authorize(:my_gateway, 199.95, card, billing_address: address, 48 | description: "Amazing T-Shirt") do 49 | {:ok, %{authorization: authorization}} -> 50 | IO.puts("Payment authorized #{authorization}") 51 | 52 | {:error, %{code: :declined, reason: reason}} -> 53 | IO.puts("Payment declined #{reason}") 54 | 55 | {:error, %{code: error}} -> 56 | IO.puts("Payment error #{error}") 57 | end 58 | ``` 59 | 60 | ## Road Map 61 | 62 | - Support multiple gateways (PayPal, Stripe, Authorize.net, Braintree etc..) 63 | - Support gateways that bill directly and those that use html integrations. 64 | - Support recurring billing 65 | - Each gateway is hosted in a worker process and supervised. 66 | - Workers can be pooled. (using poolboy) 67 | - Workers can be spread on multiple nodes 68 | - The gateway is selected by first calling the "Gateway Factory" process. The "Gateway Factory" decides which gateway to use. Usually it will just be one type based on configuration setting in mix.exs (i.e. Stripe), but the Factory can be replaced with something fancier. It will enable scenarios like: 69 | - Use one gateway for visa another for mastercard 70 | - Use primary gateway (i.e PayPal), but when PayPal is erroring switch to secondary/backup gateway (i.e. Authorize.net) 71 | - Currency specific gateway, i.e. use one gateway type for USD another for CAD 72 | - Retry on network failure 73 | 74 | ## License 75 | 76 | MIT 77 | 78 | @joshnuss is a freelance software consultant. joshnuss@gmail.com 79 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies. The Mix.Config module provides functions 3 | # to aid in doing so. 4 | use Mix.Config 5 | 6 | # Note this file is loaded before any dependency and is restricted 7 | # to this project. If another project depends on this project, this 8 | # file won't be loaded nor affect the parent project. 9 | 10 | # Sample configuration: 11 | # 12 | # config :my_dep, 13 | # key: :value, 14 | # limit: 42 15 | 16 | # It is also possible to import configuration files, relative to this 17 | # directory. For example, you can emulate configuration per environment 18 | # by uncommenting the line below and defining dev.exs, test.exs and such. 19 | # Configuration from the imported file will override the ones defined 20 | # here (which is why it is important to import them last). 21 | # 22 | # import_config "#{Mix.env}.exs" 23 | -------------------------------------------------------------------------------- /lib/commerce_billing.ex: -------------------------------------------------------------------------------- 1 | defmodule Commerce.Billing do 2 | use Application 3 | 4 | import GenServer, only: [call: 2] 5 | 6 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 7 | # for more information on OTP Applications 8 | def start(_type, _args) do 9 | import Supervisor.Spec, warn: false 10 | 11 | children = [ 12 | # Define workers and child supervisors to be supervised 13 | # worker(Commerce.Billing.Worker, [arg1, arg2, arg3]) 14 | ] 15 | 16 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 17 | # for other strategies and supported options 18 | opts = [strategy: :one_for_one, name: Commerce.Billing.Supervisor] 19 | Supervisor.start_link(children, opts) 20 | end 21 | 22 | def authorize(worker, amount, card, opts \\ []), 23 | do: call(worker, {:authorize, amount, card, opts}) 24 | 25 | def purchase(worker, amount, card, opts \\ []), 26 | do: call(worker, {:purchase, amount, card, opts}) 27 | 28 | def capture(worker, id, opts \\ []), 29 | do: call(worker, {:capture, id, opts}) 30 | 31 | def void(worker, id, opts \\ []), 32 | do: call(worker, {:void, id, opts}) 33 | 34 | def refund(worker, amount, id, opts \\ []), 35 | do: call(worker, {:refund, amount, id, opts}) 36 | 37 | def store(worker, card, opts \\ []), 38 | do: call(worker, {:store, card, opts}) 39 | 40 | def unstore(worker, customer_id, card_id, opts \\ []), 41 | do: call(worker, {:unstore, customer_id, card_id, opts}) 42 | end 43 | -------------------------------------------------------------------------------- /lib/commerce_billing/address.ex: -------------------------------------------------------------------------------- 1 | defmodule Commerce.Billing.Address do 2 | defstruct [:street1, :street2, :city, :region, :country, :postal_code, :phone] 3 | end 4 | -------------------------------------------------------------------------------- /lib/commerce_billing/credit_card.ex: -------------------------------------------------------------------------------- 1 | defmodule Commerce.Billing.CreditCard do 2 | defstruct [:name, :number, :expiration, :cvc] 3 | end 4 | -------------------------------------------------------------------------------- /lib/commerce_billing/gateways/base.ex: -------------------------------------------------------------------------------- 1 | defmodule Commerce.Billing.Gateways.Base do 2 | alias Commerce.Billing.Response 3 | 4 | @doc false 5 | defmacro __using__(_) do 6 | quote location: :keep do 7 | def purchase(_amount, _card_or_id, _opts) do 8 | not_implemented 9 | end 10 | 11 | def authorize(_amount, _card_or_id, _opts) do 12 | not_implemented 13 | end 14 | 15 | def capture(_id, _opts) do 16 | not_implemented 17 | end 18 | 19 | def void(_id, _opts) do 20 | not_implemented 21 | end 22 | 23 | def refund(_amount, _id, _opts) do 24 | not_implemented 25 | end 26 | 27 | def store(_card, _opts) do 28 | not_implemented 29 | end 30 | 31 | def unstore(_customer_id, _card_id, _opts) do 32 | not_implemented 33 | end 34 | 35 | defp http(method, path, params \\ [], opts \\ []) do 36 | credentials = Keyword.get(opts, :credentials) 37 | headers = [{"Content-Type", "application/x-www-form-urlencoded"}] 38 | data = params_to_string(params) 39 | 40 | HTTPoison.request(method, path, data, headers, [hackney: [basic_auth: credentials]]) 41 | end 42 | 43 | defp money_to_cents(amount) when is_float(amount) do 44 | trunc(amount * 100) 45 | end 46 | 47 | defp money_to_cents(amount) do 48 | amount 49 | end 50 | 51 | defp params_to_string(params) do 52 | params |> Enum.filter(fn {_k, v} -> v != nil end) 53 | |> URI.encode_query 54 | end 55 | 56 | @doc false 57 | defp not_implemented do 58 | {:error, Response.error(code: :not_implemented)} 59 | end 60 | 61 | defoverridable [purchase: 3, authorize: 3, capture: 2, void: 2, refund: 3, store: 2, unstore: 3] 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/commerce_billing/gateways/bogus.ex: -------------------------------------------------------------------------------- 1 | defmodule Commerce.Billing.Gateways.Bogus do 2 | use Commerce.Billing.Gateways.Base 3 | 4 | alias Commerce.Billing.{ 5 | CreditCard, 6 | Response 7 | } 8 | 9 | def authorize(_amount, _card_or_id, _opts), 10 | do: success 11 | 12 | def purchase(_amount, _card_or_id, _opts), 13 | do: success 14 | 15 | def capture(id, _opts), 16 | do: success(id) 17 | 18 | def void(id, _opts), 19 | do: success(id) 20 | 21 | def refund(_amount, id, _opts), 22 | do: success(id) 23 | 24 | def store(_card=%CreditCard{}, _opts), 25 | do: success 26 | 27 | def unstore(customer_id, nil, _opts), 28 | do: success(customer_id) 29 | 30 | def unstore(_customer_id, card_id, _opts), 31 | do: success(card_id) 32 | 33 | defp success, 34 | do: {:ok, Response.success(authorization: random_string)} 35 | 36 | defp success(id), 37 | do: {:ok, Response.success(authorization: id)} 38 | 39 | defp random_string(length \\ 10), 40 | do: 1..length |> Enum.map(&random_char/1) |> Enum.join 41 | 42 | defp random_char(_), 43 | do: to_string(:crypto.rand_uniform(0,9)) 44 | end 45 | -------------------------------------------------------------------------------- /lib/commerce_billing/gateways/stripe.ex: -------------------------------------------------------------------------------- 1 | defmodule Commerce.Billing.Gateways.Stripe do 2 | @base_url "https://api.stripe.com/v1" 3 | 4 | @cvc_code_translator %{ 5 | "pass" => "M", 6 | "fail" => "N", 7 | "unchecked" => "P" 8 | } 9 | 10 | @avs_code_translator %{ 11 | {"pass", "pass"} => "Y", 12 | {"pass", "fail"} => "A", 13 | {"pass", "unchecked"} => "B", 14 | {"fail", "pass"} => "Z", 15 | {"fail", "fail"} => "N", 16 | {"unchecked", "pass"} => "P", 17 | {"unchecked", "unchecked"} => "I" 18 | } 19 | 20 | use Commerce.Billing.Gateways.Base 21 | 22 | alias Commerce.Billing.{ 23 | CreditCard, 24 | Address, 25 | Response 26 | } 27 | 28 | import Poison, only: [decode!: 1] 29 | 30 | def purchase(amount, card_or_id, opts), 31 | do: authorize(amount, card_or_id, [{:capture, true} | opts]) 32 | 33 | def authorize(amount, card_or_id, opts) do 34 | config = Keyword.fetch!(opts, :config) 35 | description = Keyword.get(opts, :description) 36 | address = Keyword.get(opts, :billing_address) 37 | customer_id = Keyword.get(opts, :customer_id) 38 | currency = Keyword.get(opts, :currency, config.default_currency) 39 | capture = Keyword.get(opts, :capture, false) 40 | 41 | params = [capture: capture, description: description, 42 | currency: currency, customer: customer_id] ++ 43 | amount_params(amount) ++ 44 | card_params(card_or_id) ++ 45 | address_params(address) ++ 46 | connect_params(opts) 47 | 48 | commit(:post, "charges", params, opts) 49 | end 50 | 51 | def capture(id, opts) do 52 | params = opts 53 | |> Keyword.get(:amount) 54 | |> amount_params 55 | 56 | commit(:post, "charges/#{id}/capture", params, opts) 57 | end 58 | 59 | def void(id, opts), 60 | do: commit(:post, "charges/#{id}/refund", [], opts) 61 | 62 | def refund(amount, id, opts) do 63 | params = amount_params(amount) 64 | 65 | commit(:post, "charges/#{id}/refund", params, opts) 66 | end 67 | 68 | def store(card=%CreditCard{}, opts) do 69 | customer_id = Keyword.get(opts, :customer_id) 70 | params = card_params(card) 71 | 72 | path = if customer_id, do: "customers/#{customer_id}/card", else: "customers" 73 | 74 | commit(:post, path, params, opts) 75 | end 76 | 77 | def unstore(customer_id, nil, opts), 78 | do: commit(:delete, "customers/#{customer_id}", [], opts) 79 | 80 | def unstore(customer_id, card_id, opts), 81 | do: commit(:delete, "customers/#{customer_id}/#{card_id}", [], opts) 82 | 83 | defp amount_params(amount), 84 | do: [amount: money_to_cents(amount)] 85 | 86 | defp card_params(card=%CreditCard{}) do 87 | {expiration_year, expiration_month} = card.expiration 88 | 89 | ["card[number]": card.number, 90 | "card[exp_year]": expiration_year, 91 | "card[exp_month]": expiration_month, 92 | "card[cvc]": card.cvc, 93 | "card[name]": card.name] 94 | end 95 | 96 | defp card_params(id), do: [card: id] 97 | 98 | defp address_params(address=%Address{}) do 99 | ["card[address_line1]": address.street1, 100 | "card[address_line2]": address.street2, 101 | "card[address_city]": address.city, 102 | "card[address_state]": address.region, 103 | "card[address_zip]": address.postal_code, 104 | "card[address_country]": address.country] 105 | end 106 | 107 | defp address_params(_), do: [] 108 | 109 | defp connect_params(opts), 110 | do: Keyword.take(opts, [:destination, :application_fee]) 111 | 112 | defp commit(method, path, params, opts) do 113 | config = Keyword.fetch!(opts, :config) 114 | 115 | method 116 | |> http("#{@base_url}/#{path}", params, credentials: config.credentials) 117 | |> respond 118 | end 119 | 120 | defp respond({:ok, %{status_code: 200, body: body}}) do 121 | data = decode!(body) 122 | {cvc_result, avs_result} = verification_result(data) 123 | 124 | {:ok, Response.success(authorization: data["id"], raw: data, cvc_result: cvc_result, avs_result: avs_result)} 125 | end 126 | 127 | defp respond({:ok, %{body: body, status_code: status_code}}) do 128 | data = decode!(body) 129 | {code, reason} = error(status_code, data["error"]) 130 | {cvc_result, avs_result} = verification_result(data) 131 | 132 | {:error, Response.error(code: code, reason: reason, raw: data, cvc_result: cvc_result, avs_result: avs_result)} 133 | end 134 | 135 | defp verification_result(%{"card" => card}) do 136 | cvc_result = @cvc_code_translator[card["cvc_check"]] 137 | avs_result = @avs_code_translator[{card["address_line1_check"], card["address_zip_check"]}] 138 | 139 | {cvc_result, avs_result} 140 | end 141 | 142 | defp verification_result(_), do: {"N","N"} 143 | 144 | defp error(status, _) when status >= 500, do: {:server_error, nil} 145 | defp error(_, %{"type" => "invalid_request_error"}), do: {:invalid_request, nil} 146 | defp error(_, %{"code" => "incorrect_number"}), do: {:declined, :invalid_number} 147 | defp error(_, %{"code" => "invalid_expiry_year"}), do: {:declined, :invalid_expiration} 148 | defp error(_, %{"code" => "invalid_expiry_month"}), do: {:declined, :invalid_expiration} 149 | defp error(_, %{"code" => "invalid_cvc"}), do: {:declined, :invalid_cvc} 150 | defp error(_, %{"code" => "rate_limit"}), do: {:rate_limit, nil} 151 | defp error(_, _), do: {:declined, :unknown} 152 | end 153 | -------------------------------------------------------------------------------- /lib/commerce_billing/response.ex: -------------------------------------------------------------------------------- 1 | defmodule Commerce.Billing.Response do 2 | defstruct [:success, :authorization, :code, :reason, :avs_result, :cvc_result, :raw] 3 | 4 | def success(opts \\ []) do 5 | new(true, opts) 6 | end 7 | 8 | def error(opts \\ []) do 9 | new(false, opts) 10 | end 11 | 12 | defp new(success, opts) do 13 | Map.merge(%__MODULE__{success: success}, Enum.into(opts, %{})) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/commerce_billing/worker.ex: -------------------------------------------------------------------------------- 1 | defmodule Commerce.Billing.Worker do 2 | use GenServer 3 | 4 | def start_link(gateway, config, opts \\ []) do 5 | GenServer.start_link(__MODULE__, [gateway, config], opts) 6 | end 7 | 8 | def init([gateway, config]) do 9 | {:ok, %{config: config, gateway: gateway}} 10 | end 11 | 12 | def handle_call({:authorize, amount, card, opts}, _from, state) do 13 | response = state.gateway.authorize(amount, card, [{:config, state.config} | opts]) 14 | {:reply, response, state} 15 | end 16 | 17 | def handle_call({:purchase, amount, card, opts}, _from, state) do 18 | response = state.gateway.purchase(amount, card, [{:config, state.config} | opts]) 19 | {:reply, response, state} 20 | end 21 | 22 | def handle_call({:capture, id, opts}, _from, state) do 23 | response = state.gateway.capture(id, [{:config, state.config} | opts]) 24 | {:reply, response, state} 25 | end 26 | 27 | def handle_call({:void, id, opts}, _from, state) do 28 | response = state.gateway.void(id, [{:config, state.config} | opts]) 29 | {:reply, response, state} 30 | end 31 | 32 | def handle_call({:refund, amount, id, opts}, _from, state) do 33 | response = state.gateway.refund(amount, id, [{:config, state.config} | opts]) 34 | {:reply, response, state} 35 | end 36 | 37 | def handle_call({:store, card, opts}, _from, state) do 38 | response = state.gateway.store(card, [{:config, state.config} | opts]) 39 | {:reply, response, state} 40 | end 41 | 42 | def handle_call({:unstore, customer_id, card_id, opts}, _from, state) do 43 | response = state.gateway.unstore(customer_id, card_id, [{:config, state.config} | opts]) 44 | {:reply, response, state} 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Commerce.Billing.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :commerce_billing, 6 | version: "0.0.2", 7 | description: "Credit card processing library", 8 | package: [ 9 | contributors: ["Joshua Nussbaum"], 10 | licenses: ["MIT"], 11 | links: %{github: "https://github.com/joshnuss/commerce_billing"} 12 | ], 13 | elixir: ">= 1.2.0", 14 | deps: deps] 15 | end 16 | 17 | # Configuration for the OTP application 18 | # 19 | # Type `mix help compile.app` for more information 20 | def application do 21 | [applications: [:httpoison, :hackney], 22 | mod: {Commerce.Billing, []}] 23 | end 24 | 25 | # Dependencies can be hex.pm packages: 26 | # 27 | # {:mydep, "~> 0.3.0"} 28 | # 29 | # Or git/path repositories: 30 | # 31 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1"} 32 | # 33 | # Type `mix help deps` for more examples and options 34 | defp deps do 35 | [{:poison, "~> 3.0"}, 36 | {:httpoison, ">= 0.7.1"}, 37 | {:ex_doc, ">= 0.6.0", only: :dev}, 38 | {:mock, ">= 0.1.0", only: :test}] 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"ex_doc": {:hex, :ex_doc, "0.7.3", "8f52bfbfcbc0206dd08dd94aae86a3fd5330ba2f37c73cfea2918ed4c96d1769", [:mix], [{:earmark, "~> 0.1.17 or ~> 0.2", [hex: :earmark, optional: true]}]}, 2 | "hackney": {:hex, :hackney, "1.3.0", "3465b937a2ee45e743c3d4b44e0c8606eb377fd05c972899056f30b01aea69d6", [:make, :rebar], [{:idna, "~> 1.0.2", [hex: :idna, optional: false]}, {:ssl_verify_hostname, "~> 1.0.5", [hex: :ssl_verify_hostname, optional: false]}]}, 3 | "httpoison": {:hex, :httpoison, "0.7.1", "54105b19ca9b2d4d16afed7e6823ba0a304002cfd5677b0d8703eb40933b4680", [:mix], [{:hackney, "~> 1.3.0", [hex: :hackney, optional: false]}]}, 4 | "idna": {:hex, :idna, "1.0.2", "397e3d001c002319da75759b0a81156bf11849c71d565162436d50020cb7265e", [:make], []}, 5 | "meck": {:hex, :meck, "0.8.3", "4628a1334c69610c5bd558b04dc78d723d8ec5445c123856de34c77f462b5ee5", [:rebar], []}, 6 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []}, 7 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], []}, 8 | "mock": {:hex, :mock, "0.1.1", "e21469ca27ba32aa7b18b61699db26f7a778171b21c0e5deb6f1218a53278574", [:mix], [{:meck, "~> 0.8.2", [hex: :meck, optional: false]}]}, 9 | "poison": {:hex, :poison, "3.0.0", "625ebd64d33ae2e65201c2c14d6c85c27cc8b68f2d0dd37828fde9c6920dd131", [:mix], []}, 10 | "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.5", "2e73e068cd6393526f9fa6d399353d7c9477d6886ba005f323b592d389fb47be", [:make], []}} 11 | -------------------------------------------------------------------------------- /test/commerce_billing_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Commerce.BillingTest do 2 | use ExUnit.Case 3 | 4 | alias Commerce.Billing.Worker 5 | import Commerce.Billing 6 | 7 | defmodule FakeGateway do 8 | def authorize(100, :card, _) do 9 | :authorization_response 10 | end 11 | 12 | def purchase(100, :card, _) do 13 | :purchase_response 14 | end 15 | 16 | def capture(1234, _) do 17 | :capture_response 18 | end 19 | 20 | def void(1234, _) do 21 | :void_response 22 | end 23 | 24 | def refund(100, 1234, _) do 25 | :refund_response 26 | end 27 | 28 | def store(:card, _) do 29 | :store_response 30 | end 31 | 32 | def unstore(123, 456, _) do 33 | :unstore_response 34 | end 35 | end 36 | 37 | setup do 38 | {:ok, worker} = Worker.start_link(FakeGateway, :config) 39 | {:ok, worker: worker} 40 | end 41 | 42 | test "authorization", %{worker: worker} do 43 | assert authorize(worker, 100, :card, []) == :authorization_response 44 | end 45 | 46 | test "purchase", %{worker: worker} do 47 | assert purchase(worker, 100, :card, []) == :purchase_response 48 | end 49 | 50 | test "capture", %{worker: worker} do 51 | assert capture(worker, 1234, []) == :capture_response 52 | end 53 | 54 | test "void", %{worker: worker} do 55 | assert void(worker, 1234, []) == :void_response 56 | end 57 | 58 | test "refund", %{worker: worker} do 59 | assert refund(worker, 100, 1234, []) == :refund_response 60 | end 61 | 62 | test "store", %{worker: worker} do 63 | assert store(worker, :card, []) == :store_response 64 | end 65 | 66 | test "unstore", %{worker: worker} do 67 | assert unstore(worker, 123, 456, []) == :unstore_response 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/gateways/bogus_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Commerce.Billing.Gateways.BogusTest do 2 | use ExUnit.Case 3 | 4 | alias Commerce.Billing.Response 5 | alias Commerce.Billing.Gateways.Bogus, as: Gateway 6 | 7 | test "authorize" do 8 | {:ok, %Response{authorization: authorization, success: success}} = 9 | Gateway.authorize(10.95, :card, []) 10 | 11 | assert success 12 | assert authorization != nil 13 | end 14 | 15 | test "purchase" do 16 | {:ok, %Response{authorization: authorization, success: success}} = 17 | Gateway.purchase(10.95, :card, []) 18 | 19 | assert success 20 | assert authorization != nil 21 | end 22 | 23 | test "capture" do 24 | {:ok, %Response{authorization: authorization, success: success}} = 25 | Gateway.capture(1234, []) 26 | 27 | assert success 28 | assert authorization != nil 29 | end 30 | 31 | test "void" do 32 | {:ok, %Response{authorization: authorization, success: success}} = 33 | Gateway.void(1234, []) 34 | 35 | assert success 36 | assert authorization != nil 37 | end 38 | 39 | test "store" do 40 | {:ok, %Response{success: success}} = 41 | Gateway.store(%Commerce.Billing.CreditCard{}, []) 42 | 43 | assert success 44 | end 45 | 46 | test "unstore with customer" do 47 | {:ok, %Response{success: success}} = 48 | Gateway.unstore(1234, nil, []) 49 | 50 | assert success 51 | end 52 | 53 | test "unstore with card" do 54 | {:ok, %Response{success: success}} = 55 | Gateway.unstore(nil, 456, []) 56 | 57 | assert success 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/gateways/stripe_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Commerce.Billing.Gateways.StripeTest do 2 | use ExUnit.Case, async: false 3 | 4 | import Mock 5 | 6 | alias Commerce.Billing.{ 7 | CreditCard, 8 | Address, 9 | Response 10 | } 11 | alias Commerce.Billing.Gateways.Stripe, as: Gateway 12 | 13 | defmacrop with_post(url, {status, response}, statement, do: block) do 14 | quote do 15 | {:ok, agent} = Agent.start_link(fn -> nil end) 16 | 17 | requestFn = fn(:post, unquote(url), params, [{"Content-Type", "application/x-www-form-urlencoded"}], [hackney: [basic_auth: {'user', 'pass'}]]) -> 18 | Agent.update(agent, fn(_) -> params end) 19 | {:ok, %{status_code: unquote(status), body: unquote(response)}} 20 | end 21 | 22 | with_mock HTTPoison, [request: requestFn] do 23 | unquote(statement) 24 | var!(params) = Agent.get(agent, &(URI.decode_query(&1))) 25 | 26 | unquote(block) 27 | 28 | Agent.stop(agent) 29 | end 30 | end 31 | end 32 | 33 | defmacrop with_delete(url, {status, response}, do: block) do 34 | quote do 35 | requestFn = fn(:delete, unquote(url), params, [{"Content-Type", "application/x-www-form-urlencoded"}], [hackney: [basic_auth: {'user', 'pass'}]]) -> 36 | {:ok, %{status_code: unquote(status), body: unquote(response)}} 37 | end 38 | 39 | with_mock HTTPoison, [request: requestFn], do: unquote(block) 40 | end 41 | end 42 | 43 | setup do 44 | config = %{credentials: {'user', 'pass'}, default_currency: "USD"} 45 | {:ok, config: config} 46 | end 47 | 48 | test "authorize success with credit card", %{config: config} do 49 | raw = ~S/ 50 | { 51 | "id": "1234", 52 | "card": { 53 | "cvc_check": "pass", 54 | "address_line1_check": "unchecked", 55 | "address_zip_check": "pass" 56 | } 57 | } 58 | / 59 | card = %CreditCard{name: "John Smith", number: "123456", cvc: "123", expiration: {2015, 11}} 60 | address = %Address{street1: "123 Main", street2: "Suite 100", city: "New York", region: "NY", country: "US", postal_code: "11111"} 61 | 62 | with_post "https://api.stripe.com/v1/charges", {200, raw}, 63 | response = Gateway.authorize(10.95, card, billing_address: address, config: config) do 64 | 65 | {:ok, %Response{authorization: authorization, success: success, 66 | avs_result: avs_result, cvc_result: cvc_result}} = response 67 | 68 | assert success 69 | assert params["capture"] == "false" 70 | assert params["currency"] == "USD" 71 | assert params["amount"] == "1095" 72 | assert params["card[name]"] == "John Smith" 73 | assert params["card[number]"] == "123456" 74 | assert params["card[exp_month]"] == "11" 75 | assert params["card[exp_year]"] == "2015" 76 | assert params["card[cvc]"] == "123" 77 | assert params["card[address_line1]"] == "123 Main" 78 | assert params["card[address_line2]"] == "Suite 100" 79 | assert params["card[address_city]"] == "New York" 80 | assert params["card[address_state]"] == "NY" 81 | assert params["card[address_country]"] == "US" 82 | assert params["card[address_zip]"] == "11111" 83 | assert authorization == "1234" 84 | assert avs_result == "P" 85 | assert cvc_result == "M" 86 | end 87 | end 88 | 89 | test "purchase success with credit card", %{config: config} do 90 | raw = ~S/ 91 | { 92 | "id": "1234", 93 | "card": { 94 | "cvc_check": "pass", 95 | "address_line1_check": "unchecked", 96 | "address_zip_check": "pass" 97 | } 98 | } 99 | / 100 | card = %CreditCard{name: "John Smith", number: "123456", cvc: "123", expiration: {2015, 11}} 101 | address = %Address{street1: "123 Main", street2: "Suite 100", city: "New York", region: "NY", country: "US", postal_code: "11111"} 102 | 103 | with_post "https://api.stripe.com/v1/charges", {200, raw}, 104 | response = Gateway.purchase(10.95, card, billing_address: address, config: config) do 105 | 106 | {:ok, %Response{authorization: authorization, success: success, 107 | avs_result: avs_result, cvc_result: cvc_result}} = response 108 | 109 | assert success 110 | assert params["capture"] == "true" 111 | assert params["currency"] == "USD" 112 | assert params["amount"] == "1095" 113 | assert params["card[name]"] == "John Smith" 114 | assert params["card[number]"] == "123456" 115 | assert params["card[exp_month]"] == "11" 116 | assert params["card[exp_year]"] == "2015" 117 | assert params["card[cvc]"] == "123" 118 | assert params["card[address_line1]"] == "123 Main" 119 | assert params["card[address_line2]"] == "Suite 100" 120 | assert params["card[address_city]"] == "New York" 121 | assert params["card[address_state]"] == "NY" 122 | assert params["card[address_country]"] == "US" 123 | assert params["card[address_zip]"] == "11111" 124 | assert authorization == "1234" 125 | assert avs_result == "P" 126 | assert cvc_result == "M" 127 | end 128 | end 129 | 130 | test "purchase success with credit card to a Connect account", %{config: config} do 131 | raw = ~S/ 132 | { 133 | "id": "1234", 134 | "card": { 135 | "cvc_check": "pass", 136 | "address_line1_check": "unchecked", 137 | "address_zip_check": "pass" 138 | } 139 | } 140 | / 141 | card = %CreditCard{name: "John Smith", number: "123456", cvc: "123", expiration: {2015, 11}} 142 | address = %Address{street1: "123 Main", street2: "Suite 100", city: "New York", region: "NY", country: "US", postal_code: "11111"} 143 | destination = "stripe_id" 144 | application_fee = 123 145 | 146 | with_post "https://api.stripe.com/v1/charges", {200, raw}, 147 | response = Gateway.purchase(10.95, card, billing_address: address, config: config, destination: destination, application_fee: application_fee) do 148 | 149 | {:ok, %Response{authorization: authorization, success: success, 150 | avs_result: avs_result, cvc_result: cvc_result}} = response 151 | 152 | assert success 153 | assert params["capture"] == "true" 154 | assert params["currency"] == "USD" 155 | assert params["amount"] == "1095" 156 | assert params["card[name]"] == "John Smith" 157 | assert params["card[number]"] == "123456" 158 | assert params["card[exp_month]"] == "11" 159 | assert params["card[exp_year]"] == "2015" 160 | assert params["card[cvc]"] == "123" 161 | assert params["card[address_line1]"] == "123 Main" 162 | assert params["card[address_line2]"] == "Suite 100" 163 | assert params["card[address_city]"] == "New York" 164 | assert params["card[address_state]"] == "NY" 165 | assert params["card[address_country]"] == "US" 166 | assert params["card[address_zip]"] == "11111" 167 | assert params["destination"] == destination 168 | assert params["application_fee"] == "123" 169 | assert authorization == "1234" 170 | assert avs_result == "P" 171 | assert cvc_result == "M" 172 | end 173 | end 174 | 175 | test "capture success", %{config: config} do 176 | raw = ~S/{"id": "1234"}/ 177 | 178 | with_post "https://api.stripe.com/v1/charges/1234/capture", {200, raw}, 179 | response = Gateway.capture(1234, amount: 19.95, config: config) do 180 | 181 | {:ok, %Response{authorization: authorization, success: success}} = response 182 | 183 | assert success 184 | assert params["amount"] == "1995" 185 | assert authorization == "1234" 186 | end 187 | end 188 | 189 | test "void success", %{config: config} do 190 | raw = ~S/{"id": "1234"}/ 191 | 192 | with_post "https://api.stripe.com/v1/charges/1234/refund", {200, raw}, 193 | response = Gateway.void(1234, config: config) do 194 | 195 | {:ok, %Response{authorization: authorization, success: success}} = response 196 | 197 | assert success 198 | assert params["amount"] == nil 199 | assert authorization == "1234" 200 | end 201 | end 202 | 203 | test "refund success", %{config: config} do 204 | raw = ~S/{"id": "1234"}/ 205 | 206 | with_post "https://api.stripe.com/v1/charges/1234/refund", {200, raw}, 207 | response = Gateway.refund(19.95, 1234, config: config) do 208 | 209 | {:ok, %Response{authorization: authorization, success: success}} = response 210 | 211 | assert success 212 | assert params["amount"] == "1995" 213 | assert authorization == "1234" 214 | end 215 | end 216 | 217 | test "store credit card without customer", %{config: config} do 218 | raw = ~S/{"id": "1234"}/ 219 | card = %CreditCard{name: "John Smith", number: "123456", cvc: "123", expiration: {2015, 11}} 220 | 221 | with_post "https://api.stripe.com/v1/customers", {200, raw}, 222 | response = Gateway.store(card, config: config) do 223 | 224 | {:ok, %Response{authorization: authorization, success: success}} = response 225 | 226 | assert success 227 | assert params["card[name]"] == "John Smith" 228 | assert params["card[number]"] == "123456" 229 | assert params["card[exp_month]"] == "11" 230 | assert params["card[exp_year]"] == "2015" 231 | assert params["card[cvc]"] == "123" 232 | assert authorization == "1234" 233 | end 234 | end 235 | 236 | test "store credit card with customer", %{config: config} do 237 | raw = ~S/{"id": "1234"}/ 238 | card = %CreditCard{name: "John Smith", number: "123456", cvc: "123", expiration: {2015, 11}} 239 | 240 | with_post "https://api.stripe.com/v1/customers/1234/card", {200, raw}, 241 | response = Gateway.store(card, customer_id: 1234, config: config) do 242 | 243 | {:ok, %Response{authorization: authorization, success: success}} = response 244 | 245 | assert success 246 | assert params["card[name]"] == "John Smith" 247 | assert params["card[number]"] == "123456" 248 | assert params["card[exp_month]"] == "11" 249 | assert params["card[exp_year]"] == "2015" 250 | assert params["card[cvc]"] == "123" 251 | assert authorization == "1234" 252 | end 253 | end 254 | 255 | test "unstore credit card", %{config: config} do 256 | with_delete "https://api.stripe.com/v1/customers/123/456", {200, "{}"} do 257 | {:ok, %Response{success: success}} = Gateway.unstore(123, 456, config: config) 258 | 259 | assert success 260 | end 261 | end 262 | 263 | test "unstore customer", %{config: config} do 264 | with_delete "https://api.stripe.com/v1/customers/123", {200, "{}"} do 265 | {:ok, %Response{success: success}} = Gateway.unstore(123, nil, config: config) 266 | 267 | assert success 268 | end 269 | end 270 | end 271 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------