├── .formatter.exs
├── test
├── braintree_test.exs
├── util_test.exs
├── xml
│ ├── entity_test.exs
│ ├── encoder_test.exs
│ └── decoder_test.exs
├── integration
│ ├── discount_test.exs
│ ├── settlement_batch_summary_test.exs
│ ├── add_on_test.exs
│ ├── client_token_test.exs
│ ├── credit_card_verification_test.exs
│ ├── payment_method_nonce_test.exs
│ ├── paypal_account_test.exs
│ ├── transaction_line_item_test.exs
│ ├── plan_test.exs
│ ├── subscription_test.exs
│ ├── merchant_account_test.exs
│ ├── test_transaction_test.exs
│ ├── address_test.exs
│ └── customer_test.exs
├── test_helper.exs
├── add_on_test.exs
├── transaction_test.exs
├── support
│ ├── config_helper.ex
│ └── webhook_test_helper.ex
├── discount_test.exs
├── settlement_batch_summary_test.exs
├── merchant
│ ├── funding_test.exs
│ ├── business_test.exs
│ ├── individual_test.exs
│ └── account_test.exs
├── address_test.exs
├── error_response_test.exs
├── webhook
│ ├── validation_test.exs
│ └── digest_test.exs
├── webhook_test.exs
├── client_token_test.exs
├── credit_card_verification_test.exs
├── plan_test.exs
├── customer_test.exs
└── http_test.exs
├── .gitignore
├── .github
├── dependabot.yml
└── workflows
│ └── ci.yml
├── config
└── config.exs
├── lib
├── merchant
│ ├── business.ex
│ ├── funding.ex
│ ├── individual.ex
│ └── account.ex
├── construction.ex
├── error_response.ex
├── webhook.ex
├── venmo_account.ex
├── webhook
│ ├── digest.ex
│ └── validation.ex
├── client_token.ex
├── apple_pay_card.ex
├── testing
│ ├── nonces.ex
│ ├── credit_card_numbers.ex
│ └── test_transaction.ex
├── add_on.ex
├── discount.ex
├── android_pay_card.ex
├── us_bank_account.ex
├── xml
│ ├── encoder.ex
│ ├── entity.ex
│ └── decoder.ex
├── payment_method_nonce.ex
├── util.ex
├── transaction_line_item.ex
├── credit_card.ex
├── search.ex
├── paypal_account.ex
├── braintree.ex
├── credit_card_verification.ex
├── settlement_batch_summary.ex
├── address.ex
├── plan.ex
├── payment_method.ex
├── customer.ex
├── subscription.ex
├── transaction.ex
└── http.ex
├── LICENSE.txt
├── mix.exs
├── priv
└── entities.txt
├── README.md
├── mix.lock
└── CHANGELOG.md
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"]
3 | ]
4 |
--------------------------------------------------------------------------------
/test/braintree_test.exs:
--------------------------------------------------------------------------------
1 | defmodule BraintreeTest do
2 | use ExUnit.Case
3 | doctest Braintree
4 | end
5 |
--------------------------------------------------------------------------------
/test/util_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.UtilTest do
2 | use ExUnit.Case
3 |
4 | doctest Braintree.Util
5 | end
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /_build
2 | /cover
3 | /deps
4 | /doc
5 | erl_crash.dump
6 | *.ez
7 | TODO
8 | config/*.secret.exs
9 | .tool-versions
10 |
--------------------------------------------------------------------------------
/test/xml/entity_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Brainy.XML.EntityTest do
2 | use ExUnit.Case, async: true
3 |
4 | doctest Braintree.XML.Entity
5 | end
6 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: mix
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | time: "11:00"
8 | open-pull-requests-limit: 10
9 | ignore:
10 | - dependency-name: credo
11 | versions:
12 | - "> 1.3.0"
13 |
--------------------------------------------------------------------------------
/test/integration/discount_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Integration.DiscountTest do
2 | use ExUnit.Case, async: true
3 |
4 | @moduletag :integration
5 |
6 | alias Braintree.Discount
7 |
8 | describe "all/0" do
9 | test "it gets a successful listing of discounts" do
10 | {:ok, _any} = Discount.all()
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | # Avoid running integration tests without the necessary configuration. This
2 | # prevents running integration tests on CI, where the encrypted values may not
3 | # be available.
4 | unless Braintree.get_env(:merchant_id) do
5 | IO.puts("Missing configuration, skipping integration tests")
6 |
7 | ExUnit.configure(exclude: [:integration])
8 | end
9 |
10 | ExUnit.start()
11 |
--------------------------------------------------------------------------------
/test/add_on_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.AddOnTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.AddOn
5 |
6 | test "new/1 creates structs with default values" do
7 | [addon] = AddOn.new([%{"id" => "123"}])
8 |
9 | assert addon.amount == 0
10 | refute addon.never_expires?
11 | assert addon.number_of_billing_cycles == 0
12 | assert addon.quantity == 0
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/test/transaction_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.TransactionTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Transaction
5 |
6 | test "paypal transaction attributes are included as a map" do
7 | transaction = %Transaction{
8 | paypal: %{
9 | payer_email: "nick@example.com"
10 | }
11 | }
12 |
13 | refute transaction.id
14 | assert transaction.paypal.payer_email == "nick@example.com"
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/integration/settlement_batch_summary_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Integration.SettlementBatchSummaryTest do
2 | use ExUnit.Case, async: true
3 |
4 | @moduletag :integration
5 |
6 | alias Braintree.SettlementBatchSummary, as: Summary
7 |
8 | test "generate/1 displays total sales and credits for a period" do
9 | {:ok, %Summary{} = summary} = Summary.generate("2016-09-06")
10 |
11 | assert summary
12 | assert summary.records
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/test/support/config_helper.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Test.Support.ConfigHelper do
2 | @moduledoc false
3 |
4 | def with_applicaton_config(key, value, fun) do
5 | original = Braintree.get_env(key, :none)
6 |
7 | try do
8 | Braintree.put_env(key, value)
9 | fun.()
10 | after
11 | case original do
12 | :none -> Application.delete_env(:braintree, key)
13 | _ -> Braintree.put_env(key, original)
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | config :braintree,
4 | environment: :sandbox,
5 | merchant_id: System.get_env("BRAINTREE_MERCHANT_ID"),
6 | public_key: System.get_env("BRAINTREE_PUBLIC_KEY"),
7 | private_key: System.get_env("BRAINTREE_PRIVATE_KEY"),
8 | master_merchant_id: System.get_env("BRAINTREE_MASTER_MERCHANT_ID")
9 |
10 | try do
11 | import_config "#{Mix.env()}.secret.exs"
12 | rescue
13 | [Code.LoadError, File.Error] -> IO.puts("No secret file for #{Mix.env()}")
14 | end
15 |
--------------------------------------------------------------------------------
/test/discount_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.DiscountTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Discount
5 |
6 | describe "new/1" do
7 | test "builds a sane struct" do
8 | discount =
9 | Discount.new(%{
10 | "id" => "asdf1234",
11 | "amount" => "25.00"
12 | })
13 |
14 | assert discount.id == "asdf1234"
15 | refute discount.never_expires?
16 | assert discount.number_of_billing_cycles == 0
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/test/integration/add_on_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Integration.AddOnTest do
2 | use ExUnit.Case, async: true
3 |
4 | @moduletag :integration
5 |
6 | alias Braintree.AddOn
7 |
8 | # This test relies on there being at least one add on in the sandbox account.
9 | test "all/0 fetches all add ons" do
10 | {:ok, [%AddOn{} = add_on | _]} = AddOn.all()
11 |
12 | assert add_on
13 | assert add_on.id
14 | assert add_on.kind
15 | assert add_on.name
16 | refute add_on.amount == 0
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/merchant/business.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Merchant.Business do
2 | @moduledoc """
3 | Represents the business section of a merchant account.
4 |
5 | For additional reference, see:
6 | https://developers.braintreepayments.com/reference/response/merchant-account/ruby
7 | """
8 |
9 | use Braintree.Construction
10 |
11 | alias Braintree.Address
12 |
13 | @type t :: %__MODULE__{
14 | address: Address.t(),
15 | legal_name: String.t(),
16 | dba_name: String.t(),
17 | tax_id: String.t()
18 | }
19 |
20 | defstruct address: %Address{},
21 | legal_name: nil,
22 | dba_name: nil,
23 | tax_id: nil
24 | end
25 |
--------------------------------------------------------------------------------
/test/settlement_batch_summary_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.SettlementBatchSummaryTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.SettlementBatchSummary, as: Summary
5 |
6 | describe "new/1" do
7 | test "creates a struct with record structs" do
8 | records = [
9 | %{
10 | "card_type" => "MasterCard",
11 | "kind" => "sale",
12 | "count" => "12",
13 | "custom_field_1" => "value"
14 | }
15 | ]
16 |
17 | summary = Summary.new(%{"records" => records})
18 |
19 | [record] = summary.records
20 |
21 | assert record.card_type == "MasterCard"
22 | assert record.count == "12"
23 | assert record.kind == "sale"
24 | assert record.custom_field_1 == "value"
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/merchant/funding.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Merchant.Funding do
2 | @moduledoc """
3 | Represents the funding section of a merchant account.
4 |
5 | For additional reference, see:
6 | https://developers.braintreepayments.com/reference/response/merchant-account/ruby
7 | """
8 |
9 | use Braintree.Construction
10 |
11 | @type t :: %__MODULE__{
12 | descriptor: String.t(),
13 | destination: String.t(),
14 | email: String.t(),
15 | mobile_phone: String.t(),
16 | routing_number: String.t(),
17 | account_number_last_4: String.t()
18 | }
19 |
20 | defstruct descriptor: nil,
21 | destination: nil,
22 | email: nil,
23 | mobile_phone: nil,
24 | routing_number: nil,
25 | account_number_last_4: nil
26 | end
27 |
--------------------------------------------------------------------------------
/lib/construction.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Construction do
2 | @moduledoc """
3 | This module provides a `use` macro to help convert raw HTTP responses into
4 | structs.
5 | """
6 |
7 | import Braintree.Util, only: [atomize: 1]
8 |
9 | defmacro __using__(_) do
10 | quote do
11 | alias Braintree.Construction
12 |
13 | def new(params) when is_map(params) or is_list(params) do
14 | Construction.new(__MODULE__, params)
15 | end
16 |
17 | defoverridable new: 1
18 | end
19 | end
20 |
21 | @doc """
22 | Convert a response into one or more typed structs.
23 | """
24 | @spec new(module(), map() | [map()]) :: struct() | [struct()]
25 | def new(module, params) when is_list(params) do
26 | Enum.map(params, &new(module, &1))
27 | end
28 |
29 | def new(module, params) when is_map(params) do
30 | struct(module, atomize(params))
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/lib/merchant/individual.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Merchant.Individual do
2 | @moduledoc """
3 | Represents the individual section of a merchant account.
4 |
5 | For additional reference, see:
6 | https://developers.braintreepayments.com/reference/response/merchant-account/ruby
7 | """
8 |
9 | use Braintree.Construction
10 |
11 | alias Braintree.Address
12 |
13 | @type t :: %__MODULE__{
14 | address: Address.t(),
15 | first_name: String.t(),
16 | last_name: String.t(),
17 | email: String.t(),
18 | phone: String.t(),
19 | date_of_birth: String.t(),
20 | ssn_last_4: String.t()
21 | }
22 |
23 | defstruct address: %Address{},
24 | first_name: nil,
25 | last_name: nil,
26 | email: nil,
27 | phone: nil,
28 | date_of_birth: nil,
29 | ssn_last_4: nil
30 | end
31 |
--------------------------------------------------------------------------------
/lib/error_response.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.ErrorResponse do
2 | @moduledoc """
3 | A general purpose response wrapper that is built for any failed API
4 | response.
5 |
6 | See the following pages for details about the various processor responses:
7 |
8 | * https://developers.braintreepayments.com/reference/general/processor-responses/authorization-responses
9 | * https://developers.braintreepayments.com/reference/general/processor-responses/settlement-responses
10 | * https://developers.braintreepayments.com/reference/general/processor-responses/avs-cvv-responses
11 | """
12 |
13 | use Braintree.Construction
14 |
15 | @type t :: %__MODULE__{
16 | errors: map,
17 | message: String.t(),
18 | params: map,
19 | transaction: map
20 | }
21 |
22 | defstruct errors: %{},
23 | message: "",
24 | params: %{},
25 | transaction: %{}
26 | end
27 |
--------------------------------------------------------------------------------
/test/integration/client_token_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Integration.ClientToken do
2 | use ExUnit.Case, async: true
3 |
4 | @moduletag :integration
5 |
6 | alias Braintree.{ClientToken, Customer}
7 |
8 | describe "generate/1" do
9 | test "without any params" do
10 | {:ok, client_token} = ClientToken.generate()
11 |
12 | assert client_token
13 | assert client_token =~ ~r/.+/
14 | refute client_token =~ "{\"version\":1"
15 | end
16 |
17 | test "with a custom version" do
18 | {:ok, client_token} = ClientToken.generate(%{version: 1})
19 |
20 | assert client_token =~ "{\"version\":1"
21 | end
22 |
23 | test "with a customer id" do
24 | {:ok, customer} = Customer.create()
25 | {:ok, client_token} = ClientToken.generate(%{customer_id: customer.id, version: 1})
26 |
27 | assert client_token
28 | assert client_token =~ ~r/.+/
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/test/merchant/funding_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Merchant.FundingTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Merchant.Funding
5 |
6 | test "all funding attributes are included" do
7 | funding = %Funding{
8 | descriptor: "descriptor",
9 | destination: "bank",
10 | email: "email@braintree.com",
11 | mobile_phone: "1234567890",
12 | routing_number: "00001111",
13 | account_number_last_4: "4141"
14 | }
15 |
16 | assert funding.descriptor == "descriptor"
17 | assert funding.destination == "bank"
18 | assert funding.email == "email@braintree.com"
19 | assert funding.mobile_phone == "1234567890"
20 | assert funding.routing_number == "00001111"
21 | assert funding.account_number_last_4 == "4141"
22 | end
23 |
24 | test "new/1 creates struct" do
25 | funding = Funding.new(%{"descriptor" => "descriptor"})
26 |
27 | assert funding.descriptor == "descriptor"
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/test/merchant/business_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Merchant.BusinessTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Merchant.Business
5 |
6 | test "all business attributes are included" do
7 | business = %Business{
8 | legal_name: "Ladders.io",
9 | dba_name: "Ladders",
10 | tax_id: "123456",
11 | address: %{
12 | street_address: "10 Ladders St"
13 | }
14 | }
15 |
16 | assert business.legal_name == "Ladders.io"
17 | assert business.dba_name == "Ladders"
18 | assert business.tax_id == "123456"
19 | assert business.address.street_address == "10 Ladders St"
20 | end
21 |
22 | test "new/1 with address" do
23 | business =
24 | Business.new(%{
25 | "address" => %{"street_address" => "101 N Main St"},
26 | "legal_name" => "Ladders.io"
27 | })
28 |
29 | assert business.legal_name == "Ladders.io"
30 | assert business.address.street_address == "101 N Main St"
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/lib/webhook.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Webhook do
2 | @moduledoc """
3 | This module provides convenience methods for parsing Braintree webhook payloads.
4 | """
5 |
6 | alias Braintree.Webhook.Validation
7 |
8 | @doc """
9 | Return a map containing the payload and signature from the braintree webhook event.
10 | """
11 | @spec parse(String.t() | nil, String.t() | nil, Keyword.t()) ::
12 | {:ok, map} | {:error, String.t()}
13 | def parse(signature, payload, opts \\ [])
14 | def parse(nil, _payload, _opts), do: {:error, "Signature cannot be nil"}
15 | def parse(_sig, nil, _opts), do: {:error, "Payload cannot be nil"}
16 |
17 | def parse(sig, payload, opts) do
18 | with :ok <- Validation.validate_signature(sig, payload, opts),
19 | {:ok, decoded} <- Base.decode64(payload, ignore: :whitespace) do
20 | {:ok, %{"payload" => decoded, "signature" => sig}}
21 | else
22 | :error -> {:error, "Could not decode payload"}
23 | {:error, error_msg} -> {:error, error_msg}
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/venmo_account.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.VenmoAccount do
2 | @moduledoc """
3 | VenmoAccount structs are not created directly, but are built within
4 | responses from other endpoints, such as `Braintree.Customer`.
5 | """
6 |
7 | use Braintree.Construction
8 |
9 | @type t :: %__MODULE__{
10 | created_at: String.t(),
11 | customer_global_id: String.t(),
12 | customer_id: String.t(),
13 | default: String.t(),
14 | global_id: String.t(),
15 | image_url: String.t(),
16 | source_description: String.t(),
17 | token: String.t(),
18 | updated_at: String.t(),
19 | username: String.t(),
20 | venmo_user_id: String.t()
21 | }
22 |
23 | defstruct customer_global_id: nil,
24 | created_at: nil,
25 | customer_id: nil,
26 | default: nil,
27 | global_id: nil,
28 | image_url: nil,
29 | source_description: nil,
30 | token: nil,
31 | updated_at: nil,
32 | username: nil,
33 | venmo_user_id: nil
34 | end
35 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Parker Selbert
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/test/integration/credit_card_verification_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Integration.CreditCardVerificationTest do
2 | use ExUnit.Case, async: true
3 |
4 | @moduletag :integration
5 |
6 | alias Braintree.{CreditCardVerification, Customer, PaymentMethod}
7 | alias Braintree.Testing.Nonces
8 |
9 | describe "search/1" do
10 | test "with valid params" do
11 | {:ok, customer} = Customer.create(%{first_name: "Waldo", last_name: "Smith"})
12 |
13 | {:ok, _payment_method} =
14 | PaymentMethod.create(%{
15 | customer_id: customer.id,
16 | payment_method_nonce: Nonces.transactable(),
17 | options: %{
18 | verify_card: true,
19 | verification_amount: "45.3"
20 | }
21 | })
22 |
23 | search_params = %{customer_id: %{is: customer.id}}
24 |
25 | assert {:ok, [%CreditCardVerification{} = verification | _]} =
26 | CreditCardVerification.search(search_params)
27 |
28 | assert verification.amount == "45.30"
29 | end
30 |
31 | test "returns not found" do
32 | assert {:error, :not_found} = CreditCardVerification.search(%{customer_id: %{is: "Surly"}})
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/lib/webhook/digest.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Webhook.Digest do
2 | @moduledoc """
3 | This module provides convenience methods to help validate Braintree signatures and associated payloads for webhooks.
4 | """
5 |
6 | @doc """
7 | A wrapper function that does a secure comparision accounting for timing attacks.
8 | """
9 | @spec secure_compare(String.t(), String.t()) :: boolean()
10 | def secure_compare(left, right) when is_binary(left) and is_binary(right) do
11 | Plug.Crypto.secure_compare(left, right)
12 | end
13 |
14 | def secure_compare(_, _), do: false
15 |
16 | @doc """
17 | Returns the message as a hex-encoded string to validate it matches the signature from the braintree webhook event.
18 | """
19 | @spec hexdigest(String.t() | nil, String.t() | nil) :: String.t()
20 | def hexdigest(nil, _), do: ""
21 | def hexdigest(_, nil), do: ""
22 |
23 | def hexdigest(private_key, message) do
24 | key_digest = :crypto.hash(:sha, private_key)
25 |
26 | :sha
27 | |> hmac(key_digest, message)
28 | |> Base.encode16(case: :lower)
29 | end
30 |
31 | if System.otp_release() >= "22" do
32 | defp hmac(digest, key, data), do: :crypto.mac(:hmac, digest, key, data)
33 | else
34 | defp hmac(digest, key, data), do: :crypto.hmac(digest, key, data)
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/test/merchant/individual_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Merchant.IndividualTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Address
5 | alias Braintree.Merchant.Individual
6 |
7 | test "all individual attributes are included" do
8 | individual = %Individual{
9 | first_name: "Jenna",
10 | last_name: "Smith",
11 | email: "smith@braintree.com",
12 | phone: "1234567890",
13 | date_of_birth: "01-09-1990",
14 | ssn_last_4: "2222",
15 | address: %Address{
16 | street_address: "101 N Main St"
17 | }
18 | }
19 |
20 | assert individual.first_name == "Jenna"
21 | assert individual.last_name == "Smith"
22 | assert individual.email == "smith@braintree.com"
23 | assert individual.phone == "1234567890"
24 | assert individual.date_of_birth == "01-09-1990"
25 | assert individual.ssn_last_4 == "2222"
26 | assert individual.address.street_address == "101 N Main St"
27 | end
28 |
29 | test "new/1 with address" do
30 | individual =
31 | Individual.new(%{
32 | "address" => %{"street_address" => "101 N Main St"},
33 | "first_name" => "Jenna"
34 | })
35 |
36 | assert individual.first_name == "Jenna"
37 | assert individual.address.street_address == "101 N Main St"
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/test/support/webhook_test_helper.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Test.Support.WebhookTestHelper do
2 | @moduledoc false
3 |
4 | alias Braintree.Webhook.Digest
5 |
6 | def sample_notification(kind, id, source_merchant_id) do
7 | payload = Base.encode64(sample_xml(kind, id, source_merchant_id))
8 |
9 | signature_string =
10 | "#{braintree_public_key()}|#{Digest.hexdigest(braintree_private_key(), payload)}"
11 |
12 | %{"bt_signature" => signature_string, "bt_payload" => payload}
13 | end
14 |
15 | def sample_xml(kind, data, source_merchant_id) do
16 | source_merchant_xml =
17 | if source_merchant_id == nil do
18 | "#{source_merchant_id}"
19 | else
20 | nil
21 | end
22 |
23 | ~s"""
24 |
25 | 2020-01-01T00:00:00Z
26 | #{kind}
27 | #{source_merchant_xml}
28 |
29 | #{subject_sample_xml(kind, data)}
30 |
31 |
32 | """
33 | end
34 |
35 | defp subject_sample_xml(_kind, _id) do
36 | ~s"""
37 | true
38 | """
39 | end
40 |
41 | defp braintree_public_key, do: Braintree.get_env(:public_key, "public_key")
42 | defp braintree_private_key, do: Braintree.get_env(:private_key, "private_key")
43 | end
44 |
--------------------------------------------------------------------------------
/lib/client_token.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.ClientToken do
2 | @moduledoc """
3 | Generate a token required by the client SDK to communicate with Braintree.
4 |
5 | For additional reference see:
6 | https://developers.braintreepayments.com/reference/request/client-token/generate/ruby
7 | """
8 |
9 | alias Braintree.HTTP
10 |
11 | @version 2
12 |
13 | @doc """
14 | Create a client token, or return an error response.
15 |
16 | ## Options
17 |
18 | * `:version` - The default value is 2. Current supported versions are 1, 2,
19 | and 3. Please check your client-side SDKs in use before changing this
20 | value.
21 |
22 | ## Example
23 |
24 | {:ok, token} = Braintree.ClientToken.generate()
25 |
26 | Generate a specific token version:
27 |
28 | {:ok, token} = Braintree.ClientToken.generate(%{version: 3})
29 | """
30 | @spec generate(map, Keyword.t()) :: {:ok, binary} | HTTP.error()
31 | def generate(params \\ %{}, opts \\ []) when is_map(params) do
32 | params = %{client_token: with_version(params)}
33 |
34 | with {:ok, payload} <- HTTP.post("client_token", params, opts) do
35 | %{"client_token" => %{"value" => value}} = payload
36 |
37 | {:ok, value}
38 | end
39 | end
40 |
41 | defp with_version(%{version: _} = params), do: params
42 | defp with_version(params), do: Map.put(params, :version, @version)
43 | end
44 |
--------------------------------------------------------------------------------
/test/address_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.AddressTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Address
5 |
6 | test "all address attributes are included" do
7 | address = %Address{
8 | customer_id: "131866",
9 | first_name: "Jenna",
10 | last_name: "Smith",
11 | company: "Braintree",
12 | street_address: "1 E Main St",
13 | extended_address: "Suite 403",
14 | locality: "Chicago",
15 | region: "Illinois",
16 | postal_code: "60622",
17 | country_code_alpha2: "US",
18 | country_code_alpha3: "USA",
19 | country_code_numeric: "840",
20 | country_name: "United States of America"
21 | }
22 |
23 | assert address.id == nil
24 | assert address.customer_id == "131866"
25 | assert address.first_name == "Jenna"
26 | assert address.last_name == "Smith"
27 | assert address.company == "Braintree"
28 | assert address.street_address == "1 E Main St"
29 | assert address.extended_address == "Suite 403"
30 | assert address.locality == "Chicago"
31 | assert address.region == "Illinois"
32 | assert address.postal_code == "60622"
33 | assert address.country_code_alpha2 == "US"
34 | assert address.country_code_alpha3 == "USA"
35 | assert address.country_code_numeric == "840"
36 | assert address.country_name == "United States of America"
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/test/error_response_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.ErrorResponseTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.ErrorResponse
5 |
6 | test "converting an api error response" do
7 | response = %{
8 | "errors" => %{
9 | "customer" => %{
10 | "credit_card" => %{
11 | "errors" => [
12 | %{"attribute" => "cvv", "code" => "81706", "message" => "CVV is required."}
13 | ]
14 | },
15 | "errors" => []
16 | }
17 | },
18 | "message" => "CVV is required.",
19 | "params" => %{
20 | "customer" => %{
21 | "credit_card" => %{
22 | "expiration_date" => "01/2016",
23 | "options" => %{
24 | "verify_card" => "true"
25 | }
26 | }
27 | }
28 | },
29 | "transaction" => %{
30 | "currency_iso_code" => "USD",
31 | "payment_instrument_type" => "paypal_account",
32 | "processor_response_code" => "2000",
33 | "processor_response_text" => "Do Not Honor",
34 | "status" => "processor_declined"
35 | }
36 | }
37 |
38 | error_response = ErrorResponse.new(response)
39 |
40 | assert error_response.message == "CVV is required."
41 | refute error_response.errors == %{}
42 |
43 | refute error_response.params == %{}
44 | assert error_response.params[:customer]
45 |
46 | refute error_response.transaction == %{}
47 | assert error_response.transaction[:processor_response_code]
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/lib/apple_pay_card.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.ApplePayCard do
2 | @moduledoc """
3 | ApplePayCard structs are not created directly, but are built within
4 | responsees from other endpoints, such as `Braintree.Customer`.
5 | """
6 |
7 | use Braintree.Construction
8 | alias Braintree.Address
9 |
10 | @type t :: %__MODULE__{
11 | billing_address: Address.t(),
12 | bin: String.t(),
13 | card_type: String.t(),
14 | cardholder_name: String.t(),
15 | created_at: String.t(),
16 | customer_id: String.t(),
17 | default: String.t(),
18 | expiration_month: String.t(),
19 | expiration_year: String.t(),
20 | expired: String.t(),
21 | image_url: String.t(),
22 | last_4: String.t(),
23 | payment_instrument_name: String.t(),
24 | source_description: String.t(),
25 | subscriptions: [any],
26 | token: String.t(),
27 | updated_at: String.t()
28 | }
29 |
30 | defstruct billing_address: nil,
31 | bin: nil,
32 | card_type: nil,
33 | cardholder_name: nil,
34 | created_at: nil,
35 | customer_id: nil,
36 | default: false,
37 | expiration_month: nil,
38 | expiration_year: nil,
39 | expired: nil,
40 | image_url: nil,
41 | last_4: nil,
42 | payment_instrument_name: nil,
43 | source_description: nil,
44 | subscriptions: [],
45 | token: nil,
46 | updated_at: nil
47 | end
48 |
--------------------------------------------------------------------------------
/test/webhook/validation_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Webhook.ValidationTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Test.Support.WebhookTestHelper
5 | alias Braintree.Webhook.Validation
6 |
7 | describe "Validation#validate_signature/2" do
8 | setup do
9 | %{"bt_payload" => _payload, "bt_signature" => _signature} =
10 | WebhookTestHelper.sample_notification("check", nil, "source_merchant_123")
11 | end
12 |
13 | test "returns :ok with valid signature and payload", %{
14 | "bt_payload" => payload,
15 | "bt_signature" => signature
16 | } do
17 | assert Validation.validate_signature(signature, payload) == :ok
18 | end
19 |
20 | test "returns error tuple with invalid signature", %{"bt_payload" => payload} do
21 | assert Validation.validate_signature("fake_signature", payload) ==
22 | {:error, "No matching public key"}
23 | end
24 |
25 | test "returns error tuple with invalid payload", %{"bt_signature" => signature} do
26 | assert Validation.validate_signature(signature, "fake_payload") ==
27 | {:error, "Signature does not match payload, one has been modified"}
28 | end
29 |
30 | test "returns error tuple with nil payload", %{"bt_signature" => signature} do
31 | assert Validation.validate_signature(signature, nil) == {:error, "Payload cannot be nil"}
32 | end
33 |
34 | test "returns error tuple with nil signature", %{"bt_payload" => payload} do
35 | assert Validation.validate_signature(nil, payload) == {:error, "Signature cannot be nil"}
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/testing/nonces.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Testing.Nonces do
2 | @moduledoc """
3 | A collection of static payment nonces provided to simplify testing server
4 | side code.
5 |
6 | Nonces are preferred over credit card numbers when testing payment methods.
7 | Only a subset of nonces are defined here, for the full list see the sandbox
8 | documentation about [payment method nonces][pmn].
9 |
10 | [pmn]: https://developers.braintreepayments.com/reference/general/testing/ruby#payment-method-nonces
11 | """
12 | def transactable do
13 | "fake-valid-nonce"
14 | end
15 |
16 | def consumed do
17 | "fake-consumed-nonce"
18 | end
19 |
20 | def paypal_future_payment do
21 | "fake-paypal-billing-agreement-nonce"
22 | end
23 |
24 | def android_pay_visa_nonce do
25 | "fake-android-pay-visa-nonce"
26 | end
27 |
28 | def android_pay_mastercard_nonce do
29 | "fake-android-pay-mastercard-nonce"
30 | end
31 |
32 | def android_pay_amex_nonce do
33 | "fake-android-pay-amex-nonce"
34 | end
35 |
36 | def android_pay_discover_nonce do
37 | "fake-android-pay-discover-nonce"
38 | end
39 |
40 | def apple_pay_visa do
41 | "fake-apple-pay-visa-nonce"
42 | end
43 |
44 | def apple_pay_master_card do
45 | "fake-apple-pay-mastercard-nonce"
46 | end
47 |
48 | def apple_pay_am_ex do
49 | "fake-apple-pay-amex-nonce"
50 | end
51 |
52 | def abstract_transactable do
53 | "fake-abstract-transactable-nonce"
54 | end
55 |
56 | def coinbase do
57 | "fake-coinbase-nonce"
58 | end
59 |
60 | def venmo_account do
61 | "fake-venmo-account-nonce"
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/test/webhook/digest_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Webhook.DigestTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Webhook.Digest
5 |
6 | describe "WebhookDigest#hexdigest/2" do
7 | test "returns the sha1 hmac of the input string (test case 6 from RFC 2202)" do
8 | private_key = String.duplicate("\xaa", 80)
9 | data = "Test Using Larger Than Block-Size Key - Hash Key First"
10 | assert Digest.hexdigest(private_key, data) == "aa4ae5e15272d00e95705637ce8a3b55ed402112"
11 | end
12 |
13 | test "returns the sha1 hmac of the input string (test case 7 from RFC 2202)" do
14 | private_key = String.duplicate("\xaa", 80)
15 | data = "Test Using Larger Than Block-Size Key and Larger Than One Block-Size Data"
16 | assert Digest.hexdigest(private_key, data) == "e8e99d0f45237d786d6bbaa7965c7808bbff1a91"
17 | end
18 |
19 | test "doesn't blow up if message is nil" do
20 | assert Digest.hexdigest("key", nil) == ""
21 | end
22 |
23 | test "doesn't blow up if key is nil" do
24 | assert Digest.hexdigest(nil, "key") == ""
25 | end
26 | end
27 |
28 | describe "Digest#secure_compare/2" do
29 | test "returns true if two strings are equal" do
30 | assert Digest.secure_compare("A_string", "A_string") == true
31 | end
32 |
33 | test "returns false if two strings are different and the same length" do
34 | assert Digest.secure_compare("A_string", "A_strong") == false
35 | end
36 |
37 | test "returns false if one is a prefix of the other" do
38 | assert Digest.secure_compare("A_string", "A_string_that_is_longer") == false
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/test/webhook_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.WebhookTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Test.Support.WebhookTestHelper
5 | alias Braintree.Webhook
6 |
7 | describe "Webhook#parse/2" do
8 | setup do
9 | %{"bt_payload" => _payload, "bt_signature" => _signature} =
10 | WebhookTestHelper.sample_notification("check", nil, "source_merchant_123")
11 | end
12 |
13 | test "returns decoded payload tuple with valid signature and payload", %{
14 | "bt_payload" => payload,
15 | "bt_signature" => signature
16 | } do
17 | assert {:ok, parsed} = Webhook.parse(signature, payload)
18 |
19 | assert parsed["payload"] =~ ""
20 | assert parsed["payload"] =~ "check"
21 | assert parsed["signature"] =~ ~r/\w{18}|\w{40}/
22 | end
23 |
24 | test "returns error tuple with invalid signature", %{"bt_payload" => payload} do
25 | assert {:error, "No matching public key"} = Webhook.parse("fake_signature", payload)
26 | end
27 |
28 | test "returns error tuple with invalid payload", %{"bt_signature" => signature} do
29 | assert {:error, "Signature does not match payload, one has been modified"} =
30 | Webhook.parse(signature, "fake_payload")
31 | end
32 |
33 | test "returns error tuple with nil payload", %{"bt_signature" => signature} do
34 | assert {:error, "Payload cannot be nil"} = Webhook.parse(signature, nil)
35 | end
36 |
37 | test "returns error tuple with nil signature", %{"bt_payload" => payload} do
38 | assert {:error, "Signature cannot be nil"} = Webhook.parse(nil, payload)
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/add_on.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.AddOn do
2 | @moduledoc """
3 | Add-ons and discounts are created in the Control Panel. You cannot create or
4 | update them through the API.
5 |
6 | Add-ons and discounts can be applied manually on a case-by-case basis, or you
7 | can associate them with certain plans to apply them automatically to new
8 | subscriptions. When creating a subscription, it will automatically inherit
9 | any add-ons and/or discounts associated with the plan. You can override those
10 | details at the time you create or update the subscription.
11 | """
12 |
13 | use Braintree.Construction
14 |
15 | alias Braintree.HTTP
16 |
17 | @type t :: %__MODULE__{
18 | id: String.t(),
19 | amount: String.t(),
20 | current_billing_cycle: integer,
21 | description: String.t(),
22 | kind: String.t(),
23 | name: String.t(),
24 | never_expires?: boolean,
25 | number_of_billing_cycles: integer,
26 | quantity: integer
27 | }
28 |
29 | defstruct id: nil,
30 | amount: 0,
31 | current_billing_cycle: nil,
32 | description: nil,
33 | kind: nil,
34 | name: nil,
35 | never_expires?: false,
36 | number_of_billing_cycles: 0,
37 | quantity: 0
38 |
39 | @doc """
40 | Returns a list of Braintree::AddOn structs.
41 |
42 | ## Example
43 |
44 | {:ok, addons} = Braintree.AddOns.all()
45 | """
46 | @spec all(Keyword.t()) :: {:ok, [t]} | HTTP.error()
47 | def all(opts \\ []) do
48 | with {:ok, %{"add_ons" => add_ons}} <- HTTP.get("add_ons", opts) do
49 | {:ok, new(add_ons)}
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/test/integration/payment_method_nonce_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Integration.PaymentMethodNonceTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Customer
5 | alias Braintree.PaymentMethodNonce
6 | alias Braintree.Testing.CreditCardNumbers
7 |
8 | @moduletag :integration
9 |
10 | test "create/1 succeeds when provided valid token" do
11 | {:ok, customer} =
12 | Customer.create(%{
13 | first_name: "Rick",
14 | last_name: "Grimes",
15 | credit_card: %{
16 | number: master_card(),
17 | expiration_date: "01/2016",
18 | cvv: "100"
19 | }
20 | })
21 |
22 | [card] = customer.credit_cards
23 | {:ok, payment_method_nonce} = PaymentMethodNonce.create(card.token)
24 |
25 | assert payment_method_nonce.type == "CreditCard"
26 | end
27 |
28 | test "find/1 fails when invalid nonce provided" do
29 | assert {:error, :not_found} = PaymentMethodNonce.find("bogus")
30 | end
31 |
32 | test "find/1 succeeds when valid token provided" do
33 | {:ok, customer} =
34 | Customer.create(%{
35 | first_name: "Rick",
36 | last_name: "Grimes",
37 | credit_card: %{
38 | number: master_card(),
39 | expiration_date: "01/2016",
40 | cvv: "100"
41 | }
42 | })
43 |
44 | [card] = customer.credit_cards
45 | {:ok, payment_method_nonce} = PaymentMethodNonce.create(card.token)
46 |
47 | {:ok, found_nonce} = PaymentMethodNonce.find(payment_method_nonce.nonce)
48 |
49 | assert found_nonce.type == payment_method_nonce.type
50 | assert found_nonce.nonce == payment_method_nonce.nonce
51 | end
52 |
53 | defp master_card do
54 | CreditCardNumbers.master_cards() |> List.first()
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/discount.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Discount do
2 | @moduledoc """
3 | Add-ons and discounts are created in the Control Panel. You cannot create or update them through the API.
4 |
5 | Add-ons and discounts can be applied manually on a case-by-case
6 | basis, or you can associate them with certain plans to apply them
7 | automatically to new subscriptions. When creating a subscription,
8 | it will automatically inherit any add-ons and/or discounts
9 | associated with the plan. You can override those details at the
10 | time you create or update the subscription.
11 | """
12 |
13 | use Braintree.Construction
14 |
15 | alias Braintree.HTTP
16 |
17 | @type t :: %__MODULE__{
18 | id: String.t(),
19 | amount: String.t(),
20 | current_billing_cycle: pos_integer,
21 | description: String.t(),
22 | kind: String.t(),
23 | name: String.t(),
24 | never_expires?: boolean,
25 | number_of_billing_cycles: pos_integer,
26 | quantity: pos_integer
27 | }
28 |
29 | defstruct id: nil,
30 | amount: nil,
31 | current_billing_cycle: nil,
32 | description: nil,
33 | kind: nil,
34 | name: nil,
35 | never_expires?: false,
36 | number_of_billing_cycles: 0,
37 | quantity: nil
38 |
39 | @doc """
40 | Returns a collection of Braintree::Discount objects.
41 |
42 | ## Example
43 |
44 | {:ok, discounts} = Braintree.Discount.all()
45 | """
46 | @spec all(Keyword.t()) :: {:ok, t} | HTTP.error()
47 | def all(opts \\ []) do
48 | with {:ok, payload} <- HTTP.get("discounts", opts) do
49 | %{"discounts" => discounts} = payload
50 |
51 | {:ok, new(discounts)}
52 | end
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/lib/android_pay_card.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.AndroidPayCard do
2 | @moduledoc """
3 | AndroidPayCard structs are not created directly, but are built within
4 | responses from other endpoints, such as `Braintree.Customer`.
5 |
6 | For additional reference see:
7 | https://developers.braintreepayments.com/reference/response/android-pay-card/ruby
8 | """
9 | use Braintree.Construction
10 |
11 | @type t :: %__MODULE__{
12 | bin: String.t(),
13 | created_at: String.t(),
14 | customer_id: String.t(),
15 | default: boolean,
16 | expiration_month: String.t(),
17 | expiration_year: String.t(),
18 | google_transaction_id: String.t(),
19 | image_url: String.t(),
20 | is_network_tokenized: boolean,
21 | source_card_last_4: String.t(),
22 | source_card_type: String.t(),
23 | source_description: String.t(),
24 | subscriptions: [any],
25 | token: String.t(),
26 | updated_at: String.t(),
27 | virtual_card_last_4: String.t(),
28 | virtual_card_type: String.t()
29 | }
30 |
31 | defstruct bin: nil,
32 | billing_address: nil,
33 | created_at: nil,
34 | customer_id: nil,
35 | default: false,
36 | expiration_month: nil,
37 | expiration_year: nil,
38 | google_transaction_id: nil,
39 | image_url: nil,
40 | is_network_tokenized: false,
41 | source_card_last_4: nil,
42 | source_card_type: nil,
43 | source_description: nil,
44 | subscriptions: [],
45 | token: nil,
46 | updated_at: nil,
47 | virtual_card_last_4: nil,
48 | virtual_card_type: nil
49 | end
50 |
--------------------------------------------------------------------------------
/test/client_token_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.ClientTokenTest do
2 | # Don't test asynchronously in this file. It relies on Bypass.
3 | use ExUnit.Case
4 |
5 | import Braintree.Test.Support.ConfigHelper
6 |
7 | alias Braintree.{ClientToken, ErrorResponse}
8 |
9 | @error_gzip :zlib.gzip("""
10 |
11 |
12 | Test Error.
13 |
14 | """)
15 |
16 | describe "generate/1" do
17 | test "returns an ErrorResponse when the API responds with 422" do
18 | bypass = Bypass.open()
19 |
20 | Bypass.expect(bypass, fn conn ->
21 | Plug.Conn.resp(conn, 422, @error_gzip)
22 | end)
23 |
24 | with_applicaton_config(:sandbox_endpoint, "localhost:#{bypass.port}/", fn ->
25 | assert {
26 | :error,
27 | %ErrorResponse{message: "Test Error."}
28 | } = ClientToken.generate()
29 | end)
30 | end
31 |
32 | test "returns an error tuple with a code when the response is not 200 or 422" do
33 | bypass = Bypass.open()
34 |
35 | Bypass.expect(bypass, fn conn ->
36 | Plug.Conn.resp(conn, 500, "Something went wrong")
37 | end)
38 |
39 | with_applicaton_config(:sandbox_endpoint, "localhost:#{bypass.port}/", fn ->
40 | assert {:error, :server_error} = ClientToken.generate()
41 | end)
42 | end
43 |
44 | test "returns an error tuple with a code when the connection fails" do
45 | bypass = Bypass.open()
46 | Bypass.down(bypass)
47 |
48 | with_applicaton_config(:sandbox_endpoint, "localhost:#{bypass.port}/", fn ->
49 | assert {:error, :econnrefused} = ClientToken.generate()
50 | end)
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/lib/testing/credit_card_numbers.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Testing.CreditCardNumbers do
2 | @moduledoc """
3 | The functions contained in this module provide credit card numbers that
4 | should be used when working in the sandbox environment. The sandbox will not
5 | accept any credit card numbers other than the ones listed below.
6 |
7 | See http://www.braintreepayments.com/docs/ruby/reference/sandbox
8 | """
9 |
10 | def all do
11 | am_exes() ++
12 | carte_blanches() ++
13 | diners_clubs() ++ discovers() ++ jcbs() ++ master_cards() ++ unknowns() ++ visas()
14 | end
15 |
16 | def am_exes do
17 | ~w(378282246310005 371449635398431 378734493671000)
18 | end
19 |
20 | def carte_blanches do
21 | ~w(30569309025904)
22 | end
23 |
24 | def diners_clubs do
25 | ~w(38520000023237)
26 | end
27 |
28 | def discovers do
29 | ~w(6011111111111117 6011000990139424)
30 | end
31 |
32 | def jcbs do
33 | ~w(3530111333300000 3566002020360505)
34 | end
35 |
36 | def master_cards do
37 | ~w(5105105105105100 5555555555554444)
38 | end
39 |
40 | def unknowns do
41 | ~w(1000000000000008)
42 | end
43 |
44 | def visas do
45 | ~w(4009348888881881 4012888888881881 4111111111111111 4000111111111115 4500600000000061)
46 | end
47 |
48 | defmodule FailsSandboxVerification do
49 | @moduledoc """
50 | These are vendor specific numbers that will always fail verification.
51 | """
52 |
53 | def all do
54 | [am_ex(), discover(), master_card(), visa()]
55 | end
56 |
57 | def am_ex do
58 | "378734493671000"
59 | end
60 |
61 | def discover do
62 | "6011000990139424"
63 | end
64 |
65 | def master_card do
66 | "5105105105105100"
67 | end
68 |
69 | def visa do
70 | "4000111111111115"
71 | end
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/test/credit_card_verification_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.CreditCardVerificationTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.{Address, CreditCard, CreditCardVerification}
5 |
6 | test "all credit card verification attributes are included" do
7 | verification = %CreditCardVerification{
8 | amount: "45.30",
9 | avs_error_response_code: "E",
10 | avs_postal_code_response_code: "E",
11 | avs_street_address_response_code: "E",
12 | billing: %Address{first_name: "Jenna"},
13 | created_at: "now",
14 | credit_card: %CreditCard{cardholder_name: "Jenna Smith"},
15 | currency_iso_code: "usd",
16 | cvv_response_code: "E",
17 | gateway_rejection_reason: "gateway_declined",
18 | id: "1",
19 | merchant_account_id: "2",
20 | processor_response_code: "A",
21 | processor_response_text: "B",
22 | risk_data: %{decision: "Approved"},
23 | status: "failed"
24 | }
25 |
26 | assert verification.amount == "45.30"
27 | assert verification.avs_error_response_code == "E"
28 | assert verification.avs_postal_code_response_code == "E"
29 | assert verification.avs_street_address_response_code == "E"
30 | assert verification.billing == %Address{first_name: "Jenna"}
31 | assert verification.created_at == "now"
32 | assert verification.credit_card == %CreditCard{cardholder_name: "Jenna Smith"}
33 | assert verification.currency_iso_code == "usd"
34 | assert verification.cvv_response_code == "E"
35 | assert verification.gateway_rejection_reason == "gateway_declined"
36 | assert verification.id == "1"
37 | assert verification.merchant_account_id == "2"
38 | assert verification.processor_response_code == "A"
39 | assert verification.processor_response_text == "B"
40 | assert verification.risk_data == %{decision: "Approved"}
41 | assert verification.status == "failed"
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/lib/us_bank_account.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.UsBankAccount do
2 | @moduledoc """
3 | UsBankAccount structs are not created directly, but are built within
4 | responses from other endpoints, such as `Braintree.Customer`.
5 | """
6 |
7 | use Braintree.Construction
8 |
9 | @type t :: %__MODULE__{
10 | account_number: String.t(),
11 | account_type: String.t(),
12 | ach_mandate: map(),
13 | bank_name: String.t(),
14 | business_name: String.t(),
15 | customer_id: String.t(),
16 | customer_global_id: String.t(),
17 | account_holder_name: String.t(),
18 | default: String.t(),
19 | first_name: String.t(),
20 | global_id: String.t(),
21 | image_url: String.t(),
22 | last_4: String.t(),
23 | last_name: String.t(),
24 | ownership_type: String.t(),
25 | routing_number: String.t(),
26 | token: String.t(),
27 | vaulted_in_blue: String.t(),
28 | verifications: [any],
29 | verified: boolean,
30 | verified_by: String.t(),
31 | created_at: String.t(),
32 | updated_at: String.t()
33 | }
34 |
35 | defstruct account_number: nil,
36 | account_type: nil,
37 | ach_mandate: nil,
38 | bank_name: nil,
39 | business_name: nil,
40 | customer_id: nil,
41 | customer_global_id: nil,
42 | account_holder_name: nil,
43 | default: nil,
44 | first_name: nil,
45 | global_id: nil,
46 | image_url: nil,
47 | last_4: nil,
48 | last_name: nil,
49 | ownership_type: nil,
50 | routing_number: nil,
51 | token: nil,
52 | vaulted_in_blue: nil,
53 | verifications: [],
54 | verified: nil,
55 | verified_by: nil,
56 | created_at: nil,
57 | updated_at: nil
58 | end
59 |
--------------------------------------------------------------------------------
/test/integration/paypal_account_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Integration.PaypalAccountTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.{Customer, PaymentMethod, PaypalAccount}
5 | alias Braintree.Testing.Nonces
6 |
7 | @moduletag :integration
8 |
9 | test "find/1 can successfully find a paypal account" do
10 | {:ok, customer} = Customer.create(%{first_name: "Test", last_name: "User"})
11 |
12 | {:ok, payment_method} =
13 | PaymentMethod.create(%{
14 | customer_id: customer.id,
15 | payment_method_nonce: Nonces.paypal_future_payment()
16 | })
17 |
18 | {:ok, paypal_account} = PaypalAccount.find(payment_method.token)
19 |
20 | assert paypal_account.email == "jane.doe@paypal.com"
21 | assert paypal_account.token =~ ~r/^\w+$/
22 | end
23 |
24 | test "find/1 fails with an invalid token" do
25 | assert {:error, :not_found} = PaypalAccount.find("bogus")
26 | end
27 |
28 | test "update/2 can successfully update a paypal account" do
29 | {:ok, customer} =
30 | Customer.create(%{
31 | first_name: "Test",
32 | last_name: "User"
33 | })
34 |
35 | {:ok, payment_method} =
36 | PaymentMethod.create(%{
37 | customer_id: customer.id,
38 | payment_method_nonce: Nonces.paypal_future_payment()
39 | })
40 |
41 | {:ok, paypal_account} =
42 | PaypalAccount.update(payment_method.token, %{
43 | options: %{make_default: true}
44 | })
45 |
46 | assert paypal_account.default
47 | end
48 |
49 | test "delete/1 can successfully delete a paypal account" do
50 | {:ok, customer} =
51 | Customer.create(%{
52 | first_name: "Test",
53 | last_name: "User"
54 | })
55 |
56 | {:ok, payment_method} =
57 | PaymentMethod.create(%{
58 | customer_id: customer.id,
59 | payment_method_nonce: Nonces.paypal_future_payment()
60 | })
61 |
62 | assert :ok = PaypalAccount.delete(payment_method.token)
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/lib/webhook/validation.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Webhook.Validation do
2 | @moduledoc """
3 | This module provides convenience methods to help validate Braintree signatures and associated payloads for webhooks.
4 | """
5 |
6 | alias Braintree.Webhook.Digest
7 |
8 | @doc """
9 | Validate the webhook signature and payload from braintree.
10 | """
11 | @spec validate_signature(String.t() | nil, String.t() | nil, Keyword.t()) ::
12 | :ok | {:error, String.t()}
13 | def validate_signature(signature, payload, opts \\ [])
14 | def validate_signature(nil, _payload, _opts), do: {:error, "Signature cannot be nil"}
15 | def validate_signature(_sig, nil, _opts), do: {:error, "Payload cannot be nil"}
16 |
17 | def validate_signature(sig, payload, opts) do
18 | sig
19 | |> matching_sig_pair(opts)
20 | |> compare_sig_pair(payload, opts)
21 | end
22 |
23 | defp matching_sig_pair(sig_string, opts) do
24 | sig_string
25 | |> String.split("&")
26 | |> Enum.filter(&String.contains?(&1, "|"))
27 | |> Enum.map(&String.split(&1, "|"))
28 | |> Enum.find([], fn [public_key, _signature] -> public_key == braintree_public_key(opts) end)
29 | end
30 |
31 | defp compare_sig_pair([], _, _), do: {:error, "No matching public key"}
32 |
33 | defp compare_sig_pair([_public_key, sig], payload, opts) do
34 | if Enum.any?([payload, payload <> "\n"], &secure_compare(sig, &1, opts)) do
35 | :ok
36 | else
37 | {:error, "Signature does not match payload, one has been modified"}
38 | end
39 | end
40 |
41 | defp secure_compare(signature, payload, opts) do
42 | payload_signature = Digest.hexdigest(braintree_private_key(opts), payload)
43 |
44 | Digest.secure_compare(signature, payload_signature)
45 | end
46 |
47 | defp braintree_public_key(opts),
48 | do: Keyword.get_lazy(opts, :public_key, fn -> Braintree.get_env(:public_key) end)
49 |
50 | defp braintree_private_key(opts),
51 | do: Keyword.get_lazy(opts, :private_key, fn -> Braintree.get_env(:private_key) end)
52 | end
53 |
--------------------------------------------------------------------------------
/test/plan_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.PlanTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Plan
5 |
6 | test "new/1 constructs a list of plans with nested maps available" do
7 | [first_plan, second_plan] =
8 | Plan.new([
9 | %{
10 | "add_ons" => [],
11 | "balance" => nil,
12 | "billing_day_of_month" => nil,
13 | "billing_frequency" => 1,
14 | "created_at" => "2016-07-07T21:28:00Z",
15 | "currency_iso_code" => "USD",
16 | "description" => "Plan description",
17 | "discounts" => [],
18 | "id" => "1234",
19 | "name" => "Plan name",
20 | "number_of_billing_cycles" => nil,
21 | "price" => "14.99",
22 | "trial_duration" => nil,
23 | "trial_duration_unit" => nil,
24 | "trial_period" => false,
25 | "updated_at" => "2016-07-07T21:28:00Z"
26 | },
27 | %{
28 | "add_ons" => [
29 | %{
30 | "amount" => "5.99",
31 | "merchant_id" => "12345"
32 | }
33 | ],
34 | "balance" => nil,
35 | "billing_day_of_month" => nil,
36 | "billing_frequency" => 1,
37 | "created_at" => "2016-07-07T21:28:00Z",
38 | "currency_iso_code" => "USD",
39 | "description" => "Plan description",
40 | "discounts" => [],
41 | "id" => "5678",
42 | "name" => "Plan name",
43 | "number_of_billing_cycles" => nil,
44 | "price" => "14.99",
45 | "trial_duration" => nil,
46 | "trial_duration_unit" => nil,
47 | "trial_period" => false,
48 | "updated_at" => "2016-07-07T21:28:00Z"
49 | }
50 | ])
51 |
52 | assert first_plan.add_ons == []
53 | assert first_plan.discounts == []
54 |
55 | assert second_plan.discounts == []
56 |
57 | [addon] = second_plan.add_ons
58 |
59 | assert addon["amount"] == "5.99"
60 | assert addon["merchant_id"] == "12345"
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/lib/xml/encoder.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.XML.Encoder do
2 | @moduledoc """
3 | XML encoding tailored to dumping Braintree compatible params.
4 | """
5 |
6 | @type xml :: binary
7 |
8 | import Braintree.Util, only: [hyphenate: 1]
9 | import Braintree.XML.Entity, only: [encode: 1]
10 |
11 | @doctype ~s|\n|
12 |
13 | @doc ~S"""
14 | Converts a map into the equivalent XML representation.
15 |
16 | ## Examples
17 |
18 | iex> Braintree.XML.Encoder.dump(%{a: %{b: 1, c: 2}})
19 | ~s|\n\n1\n2\n|
20 |
21 | iex> Braintree.XML.Encoder.dump(%{a: %{b: ""}})
22 | ~s|\n\n<tag>\n|
23 | """
24 | @spec dump(map) :: xml
25 | def dump(map) do
26 | generated =
27 | map
28 | |> escape_entity
29 | |> generate
30 |
31 | @doctype <> generated
32 | end
33 |
34 | defp generate(term) when is_map(term),
35 | do: term |> Map.to_list() |> Enum.map_join("\n", &generate/1)
36 |
37 | defp generate(term) when is_list(term),
38 | do: term |> Enum.map_join("\n", fn item -> "- \n#{generate(item)}\n
" end)
39 |
40 | defp generate(value) when is_binary(value), do: value
41 |
42 | defp generate({name, value}) when is_map(value),
43 | do: "<#{hyphenate(name)}>\n#{generate(value)}\n#{hyphenate(name)}>"
44 |
45 | defp generate({name, value}) when is_list(value),
46 | do: "<#{hyphenate(name)} type=\"array\">\n#{generate(value)}\n#{hyphenate(name)}>"
47 |
48 | defp generate({name, value}), do: "<#{hyphenate(name)}>#{value}#{hyphenate(name)}>"
49 |
50 | defp escape_entity(entity) when is_map(entity),
51 | do: for({key, value} <- entity, into: %{}, do: {key, escape_entity(value)})
52 |
53 | defp escape_entity(entity) when is_list(entity),
54 | do: for(value <- entity, do: escape_entity(value))
55 |
56 | defp escape_entity(entity) when is_binary(entity), do: encode(entity)
57 |
58 | defp escape_entity(entity), do: entity
59 | end
60 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on: push
4 |
5 | jobs:
6 | ci:
7 | env:
8 | MIX_ENV: test
9 | BRAINTREE_MERCHANT_ID: ${{ secrets.BRAINTREE_MERCHANT_ID }}
10 | BRAINTREE_PUBLIC_KEY: ${{ secrets.BRAINTREE_PUBLIC_KEY }}
11 | BRAINTREE_PRIVATE_KEY: ${{ secrets.BRAINTREE_PRIVATE_KEY }}
12 | BRAINTREE_MASTER_MERCHANT_ID: ${{ secrets.BRAINTREE_MASTER_MERCHANT_ID }}
13 | strategy:
14 | fail-fast: false
15 | matrix:
16 | include:
17 | - pair:
18 | elixir: '1.16'
19 | otp: '26.1'
20 | lint: lint
21 | exclude_tags: 'nothing'
22 |
23 | runs-on: ubuntu-latest
24 |
25 | steps:
26 | - uses: actions/checkout@v2
27 |
28 | - uses: erlef/setup-beam@v1
29 | with:
30 | otp-version: ${{matrix.pair.otp}}
31 | elixir-version: ${{matrix.pair.elixir}}
32 |
33 | - uses: actions/cache@v2
34 | with:
35 | path: |
36 | deps
37 | _build
38 | key: ${{ runner.os }}-mix-${{matrix.pair.elixir}}-${{matrix.pair.otp}}-${{ hashFiles('**/mix.lock') }}
39 | restore-keys: |
40 | ${{ runner.os }}-mix-${{matrix.pair.elixir}}-${{matrix.pair.otp}}-
41 |
42 | - name: Run mix deps.get
43 | run: mix deps.get --only test
44 |
45 | - name: Run mix format
46 | run: mix format --check-formatted
47 | if: ${{ matrix.lint }}
48 |
49 | - name: Run mix deps.unlock
50 | run: mix deps.unlock --check-unused
51 | if: ${{ matrix.lint }}
52 |
53 | - name: Run mix deps.compile
54 | run: mix deps.compile
55 |
56 | - name: Run mix compile
57 | run: mix compile --warnings-as-errors
58 | if: ${{ matrix.lint }}
59 |
60 | - name: Run credo
61 | run: mix credo --strict
62 | if: ${{ matrix.lint }}
63 |
64 | - name: Run mix test
65 | run: mix test --exclude ${{ matrix.exclude_tags }}
66 |
67 | - name: Run dialyzer
68 | run: mix dialyzer
69 | if: ${{ matrix.lint }}
70 |
--------------------------------------------------------------------------------
/lib/payment_method_nonce.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.PaymentMethodNonce do
2 | @moduledoc """
3 | Create a payment method nonce from an existing payment method token
4 | """
5 |
6 | use Braintree.Construction
7 |
8 | alias Braintree.HTTP
9 |
10 | @type t :: %__MODULE__{
11 | default: String.t(),
12 | description: String.t(),
13 | nonce: String.t(),
14 | three_d_secure_info: String.t(),
15 | type: String.t(),
16 | details: map,
17 | is_locked: boolean,
18 | consumed: boolean,
19 | security_questions: [any]
20 | }
21 |
22 | defstruct default: nil,
23 | description: nil,
24 | nonce: nil,
25 | three_d_secure_info: nil,
26 | type: nil,
27 | is_locked: false,
28 | details: nil,
29 | consumed: false,
30 | security_questions: nil
31 |
32 | @doc """
33 | Create a payment method nonce from `token`
34 |
35 | ## Example
36 |
37 | {:ok, payment_method_nonce} = Braintree.PaymentMethodNonce.create(token)
38 |
39 | payment_method_nonce.nonce
40 | """
41 | @spec create(String.t(), Keyword.t()) :: {:ok, t} | HTTP.error()
42 | def create(payment_method_token, opts \\ []) do
43 | path = "payment_methods/#{payment_method_token}/nonces"
44 |
45 | with {:ok, payload} <- HTTP.post(path, opts) do
46 | {:ok, new(payload)}
47 | end
48 | end
49 |
50 | @doc """
51 | Find a payment method nonce, or return an error response if token invalid
52 |
53 | ## Example
54 |
55 | {:ok, payment_method} = Braintree.PaymentMethodNonce.find(token)
56 |
57 | payment_method.type #CreditCard
58 | """
59 | @spec find(String.t(), Keyword.t()) :: {:ok, t} | HTTP.error()
60 | def find(nonce, opts \\ []) do
61 | path = "payment_method_nonces/" <> nonce
62 |
63 | with {:ok, payload} <- HTTP.get(path, opts) do
64 | {:ok, new(payload)}
65 | end
66 | end
67 |
68 | @doc false
69 | def new(%{"payment_method_nonce" => map}) do
70 | super(map)
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/lib/testing/test_transaction.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Testing.TestTransaction do
2 | @moduledoc """
3 | Create transactions for testing purposes only.
4 |
5 | Transition to settled, settlement_confirmed, or settlement_declined states.
6 | """
7 |
8 | alias Braintree.ErrorResponse, as: Error
9 | alias Braintree.HTTP
10 | alias Braintree.Transaction
11 |
12 | @doc """
13 | Use a `transaction_id` to transition to settled status. This
14 | allows for testing of refunds.
15 |
16 | ## Example
17 |
18 | {:ok, transaction} = TestTransaction.settle(transaction_id: "123")
19 |
20 | transaction.status # "settled"
21 | """
22 | @spec settle(String.t()) :: {:ok, any} | {:error, Error.t()}
23 | def settle(transaction_id) do
24 | path = "transactions/#{transaction_id}/settle"
25 |
26 | with {:ok, payload} <- HTTP.put(path), do: response(payload)
27 | end
28 |
29 | @doc """
30 | Use a `transaction_id` to transition to settled_confirmed status
31 |
32 | ## Example
33 |
34 | {:ok, transaction} = TestTransaction.settlement_confirm(
35 | transaction_id: "123")
36 |
37 | transaction.status # "settlement_confirmed"
38 | """
39 | @spec settlement_confirm(String.t()) :: {:ok, any} | {:error, Error.t()}
40 | def settlement_confirm(transaction_id) do
41 | path = "transactions/#{transaction_id}/settlement_confirm"
42 |
43 | with {:ok, payload} <- HTTP.put(path), do: response(payload)
44 | end
45 |
46 | @doc """
47 | Use a `transaction_id` to transition to settlement_declined status
48 |
49 | ## Example
50 |
51 | {:ok, transaction} = TestTransaction.settlement_decline(
52 | transaction_id: "123")
53 |
54 | transaction.status # "settlement_declined"
55 | """
56 | @spec settlement_decline(String.t()) :: {:ok, any} | {:error, Error.t()}
57 | def settlement_decline(transaction_id) do
58 | path = "transactions/#{transaction_id}/settlement_decline"
59 |
60 | with {:ok, payload} <- HTTP.put(path), do: response(payload)
61 | end
62 |
63 | defp response(%{"transaction" => map}) do
64 | {:ok, Transaction.new(map)}
65 | end
66 | end
67 |
--------------------------------------------------------------------------------
/test/integration/transaction_line_item_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Integration.TransactionLineItemTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Testing.Nonces
5 | alias Braintree.{Transaction, TransactionLineItem}
6 |
7 | @moduletag :integration
8 | test "find/1 returns a list of transaction line items" do
9 | [line_item, other_line_item] =
10 | line_items = [
11 | %{
12 | name: "Product Name",
13 | description: "Super profitable product",
14 | kind: "debit",
15 | quantity: "10",
16 | unit_amount: "9.5",
17 | unit_of_measure: "unit",
18 | total_amount: "95.00",
19 | tax_amount: "5.00",
20 | unit_tax_amount: "0.50",
21 | discount_amount: "1.00",
22 | product_code: "54321",
23 | commodity_code: "98765",
24 | url: "https://product.com"
25 | },
26 | %{
27 | name: "Other Product Name",
28 | description: "Other product that is still super profitable",
29 | kind: "debit",
30 | quantity: "10",
31 | unit_amount: "8.5",
32 | unit_of_measure: "unit",
33 | total_amount: "85.00",
34 | tax_amount: "4.00",
35 | unit_tax_amount: "0.40",
36 | discount_amount: "2.00",
37 | product_code: "54322",
38 | commodity_code: "98766",
39 | url: "https://otherproduct.com"
40 | }
41 | ]
42 |
43 | {:ok, transaction} =
44 | Transaction.sale(%{
45 | amount: "100.00",
46 | payment_method_nonce: Nonces.transactable(),
47 | line_items: line_items
48 | })
49 |
50 | {:ok, [transaction_line_item, other_transaction_line_item]} =
51 | TransactionLineItem.find_all(transaction.id)
52 |
53 | line_item_keys = Map.keys(line_item)
54 |
55 | assert Map.take(transaction_line_item, line_item_keys) == Map.take(line_item, line_item_keys)
56 |
57 | assert Map.take(other_transaction_line_item, line_item_keys) ==
58 | Map.take(other_line_item, line_item_keys)
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/lib/xml/entity.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.XML.Entity do
2 | @moduledoc """
3 | XML entity conversion for known entities.
4 | """
5 |
6 | @external_resource entities = Path.join([__DIR__, "../../priv/entities.txt"])
7 |
8 | @doc """
9 | Replace all escaped HTML entities, except those that would produce invalid XML
10 |
11 | ## Examples
12 |
13 | iex> Braintree.XML.Entity.decode("<tag>")
14 | "<tag>"
15 |
16 | iex> Braintree.XML.Entity.decode("Søren")
17 | "Søren"
18 |
19 | iex> Braintree.XML.Entity.decode("Normal")
20 | "Normal"
21 |
22 | iex> Braintree.XML.Entity.decode("First & Last")
23 | "First & Last"
24 |
25 | iex> Braintree.XML.Entity.decode(""air quotes"")
26 | ~s("air quotes")
27 | """
28 | @spec decode(String.t()) :: String.t()
29 | def decode(string) do
30 | Regex.replace(~r/\&([^\s]+);/U, string, &replace/2)
31 | end
32 |
33 | @doc """
34 | Encode all illegal XML characters by replacing them with corresponding
35 | entities.
36 |
37 | ## Examples
38 |
39 | iex> Braintree.XML.Entity.encode("")
40 | "<tag>"
41 |
42 | iex> Braintree.XML.Entity.encode("Here & There")
43 | "Here & There"
44 | """
45 | @spec encode(String.t()) :: String.t()
46 | def encode(string) do
47 | string
48 | |> String.graphemes()
49 | |> Enum.map_join(&escape/1)
50 | end
51 |
52 | for line <- File.stream!(entities) do
53 | [name, character, codepoint] = String.split(line, ",")
54 |
55 | defp replace(_, unquote(name)), do: unquote(character)
56 | defp replace(_, unquote(codepoint)), do: unquote(character)
57 | end
58 |
59 | defp replace(_, "#x" <> code), do: <>
60 | defp replace(_, "#" <> code), do: <>
61 | defp replace(original, _), do: original
62 |
63 | defp escape("'"), do: "'"
64 | defp escape("\""), do: """
65 | defp escape("&"), do: "&"
66 | defp escape("<"), do: "<"
67 | defp escape(">"), do: ">"
68 | defp escape(original), do: original
69 | end
70 |
--------------------------------------------------------------------------------
/lib/util.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Util do
2 | @moduledoc """
3 | General purpose utility functions.
4 | """
5 |
6 | @doc """
7 | Converts hyphenated values to underscore delimited strings.
8 |
9 | ## Examples
10 |
11 | iex> Braintree.Util.underscorize("brain-tree")
12 | "brain_tree"
13 |
14 | iex> Braintree.Util.underscorize(:"brain-tree")
15 | "brain_tree"
16 | """
17 | @spec underscorize(String.t() | atom) :: String.t()
18 | def underscorize(value) when is_atom(value), do: underscorize(Atom.to_string(value))
19 |
20 | def underscorize(value) when is_binary(value), do: String.replace(value, "-", "_")
21 |
22 | @doc """
23 | Converts underscored values to hyphenated strings.
24 |
25 | ## Examples
26 |
27 | iex> Braintree.Util.hyphenate("brain_tree")
28 | "brain-tree"
29 |
30 | iex> Braintree.Util.hyphenate(:brain_tree)
31 | "brain-tree"
32 | """
33 | @spec hyphenate(String.t() | atom) :: String.t()
34 | def hyphenate(value) when is_atom(value), do: value |> to_string() |> hyphenate()
35 |
36 | def hyphenate(value) when is_binary(value), do: String.replace(value, "_", "-")
37 |
38 | @doc """
39 | Recursively convert a map of string keys into a map with atom keys. Intended
40 | to prepare responses for conversion into structs. Note that it converts any
41 | string into an atom, whether it existed or not.
42 |
43 | For unknown maps with unknown keys this is potentially dangerous, but should
44 | be fine when used with known Braintree endpoints.
45 |
46 | ## Example
47 |
48 | iex> Braintree.Util.atomize(%{"a" => 1, "b" => %{"c" => 2}})
49 | %{a: 1, b: %{c: 2}}
50 |
51 | iex> Braintree.Util.atomize(%{a: 1, b: %{"c" => 2}})
52 | %{a: 1, b: %{c: 2}}
53 | """
54 | @spec atomize(map) :: map
55 | def atomize(map) when is_map(map) do
56 | Enum.into(map, %{}, fn
57 | {key, val} when is_atom(key) and is_map(val) -> {key, atomize(val)}
58 | {key, val} when is_atom(key) -> {key, val}
59 | {key, val} when is_map(val) -> {String.to_atom(key), atomize(val)}
60 | {key, val} -> {String.to_atom(key), val}
61 | end)
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Mixfile do
2 | use Mix.Project
3 |
4 | @version "0.16.0"
5 |
6 | def project do
7 | [
8 | app: :braintree,
9 | version: @version,
10 | elixir: "~> 1.9",
11 | elixirc_paths: elixirc_paths(Mix.env()),
12 | start_permanent: Mix.env() == :prod,
13 | test_coverage: [tool: ExCoveralls],
14 | description: description(),
15 | package: package(),
16 | name: "Braintree",
17 | deps: deps(),
18 | docs: docs(),
19 | dialyzer: [
20 | flags: [:unmatched_returns, :error_handling, :race_conditions]
21 | ]
22 | ]
23 | end
24 |
25 | def application do
26 | [
27 | extra_applications: [:logger, :xmerl, :telemetry],
28 | env: [
29 | environment: :sandbox,
30 | master_merchant_id: {:system, "BRAINTREE_MASTER_MERCHANT_ID"},
31 | merchant_id: {:system, "BRAINTREE_MERCHANT_ID"},
32 | private_key: {:system, "BRAINTREE_PRIVATE_KEY"},
33 | public_key: {:system, "BRAINTREE_PUBLIC_KEY"}
34 | ]
35 | ]
36 | end
37 |
38 | defp description do
39 | """
40 | Native Braintree client library for Elixir
41 | """
42 | end
43 |
44 | defp package do
45 | [
46 | maintainers: ["Parker Selbert"],
47 | licenses: ["MIT"],
48 | links: %{"GitHub" => "https://github.com/sorentwo/braintree-elixir"},
49 | files: ~w(lib priv mix.exs README.md CHANGELOG.md LICENSE.txt)
50 | ]
51 | end
52 |
53 | defp deps do
54 | [
55 | {:hackney, "~> 1.15"},
56 | {:plug, "~> 1.12"},
57 | {:telemetry, "~> 1.0 or ~> 0.4"},
58 | {:ex_doc, "~> 0.25", only: [:dev, :test], runtime: false},
59 | {:credo, "~> 1.6", only: [:dev, :test], runtime: false},
60 | {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false},
61 | {:bypass, "~> 2.1", only: :test}
62 | ]
63 | end
64 |
65 | defp docs do
66 | [
67 | main: "readme",
68 | source_ref: @version,
69 | formatters: ["html"],
70 | extras: [
71 | "CHANGELOG.md",
72 | "README.md"
73 | ]
74 | ]
75 | end
76 |
77 | # Specifies which paths to compile per environment.
78 | defp elixirc_paths(:test), do: ["lib", "test/support"]
79 | defp elixirc_paths(_), do: ["lib"]
80 | end
81 |
--------------------------------------------------------------------------------
/lib/transaction_line_item.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.TransactionLineItem do
2 | @moduledoc """
3 | For fetching line items for a given transaction.
4 |
5 | https://developers.braintreepayments.com/reference/response/transaction-line-item/ruby
6 | """
7 |
8 | use Braintree.Construction
9 |
10 | alias Braintree.HTTP
11 |
12 | @type t :: %__MODULE__{
13 | commodity_code: String.t(),
14 | description: String.t(),
15 | discount_amount: String.t(),
16 | kind: String.t(),
17 | name: String.t(),
18 | product_code: String.t(),
19 | quantity: String.t(),
20 | tax_amount: String.t(),
21 | total_amount: String.t(),
22 | unit_amount: String.t(),
23 | unit_of_measure: String.t(),
24 | unit_tax_amount: String.t(),
25 | url: String.t()
26 | }
27 |
28 | defstruct commodity_code: nil,
29 | description: nil,
30 | discount_amount: nil,
31 | kind: nil,
32 | name: nil,
33 | product_code: nil,
34 | quantity: nil,
35 | tax_amount: nil,
36 | total_amount: nil,
37 | unit_amount: nil,
38 | unit_of_measure: nil,
39 | unit_tax_amount: nil,
40 | url: nil
41 |
42 | @doc """
43 | Find transaction line items for the given transaction id.
44 |
45 | ## Example
46 |
47 | {:ok, transaction_line_items} = TransactionLineItem.find("123")
48 | """
49 | @spec find_all(String.t(), Keyword.t()) :: {:ok, [t]} | HTTP.error()
50 | def find_all(transaction_id, opts \\ []) do
51 | path = "transactions/#{transaction_id}/line_items"
52 |
53 | with {:ok, payload} <- HTTP.get(path, opts) do
54 | {:ok, new(payload)}
55 | end
56 | end
57 |
58 | @doc """
59 | Converts a list of transaction line item maps into a list of transaction line items.
60 |
61 | ## Example
62 |
63 | transaction_line_items =
64 | Braintree.TransactionLineItem.new(%{
65 | "name" => "item name",
66 | "total_amount" => "100.00"
67 | })
68 | """
69 | @spec new(%{required(line_items :: String.t()) => [map]}) :: [t]
70 | def new(%{"line_items" => line_item_maps}) do
71 | Enum.map(line_item_maps, &super/1)
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/lib/credit_card.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.CreditCard do
2 | @moduledoc """
3 | CreditCard structs are not created directly, but are built within
4 | responsees from other endpoints, such as `Braintree.Customer`.
5 | """
6 |
7 | use Braintree.Construction
8 | alias Braintree.Address
9 |
10 | @type t :: %__MODULE__{
11 | bin: String.t(),
12 | billing_address: Address.t(),
13 | card_type: String.t(),
14 | cardholder_name: String.t(),
15 | commercial: String.t(),
16 | country_of_issuance: String.t(),
17 | customer_id: String.t(),
18 | customer_location: String.t(),
19 | debit: String.t(),
20 | default: String.t(),
21 | durbin_regulated: String.t(),
22 | expiration_month: String.t(),
23 | expiration_year: String.t(),
24 | expired: String.t(),
25 | healthcare: String.t(),
26 | image_url: String.t(),
27 | issuing_bank: String.t(),
28 | last_4: String.t(),
29 | payroll: String.t(),
30 | prepaid: String.t(),
31 | token: String.t(),
32 | unique_number_identifier: String.t(),
33 | created_at: String.t(),
34 | updated_at: String.t(),
35 | venmo_sdk: boolean,
36 | subscriptions: [any],
37 | verifications: [any]
38 | }
39 |
40 | defstruct bin: nil,
41 | billing_address: nil,
42 | card_type: nil,
43 | cardholder_name: nil,
44 | commercial: "Unknown",
45 | country_of_issuance: "Unknown",
46 | customer_id: nil,
47 | customer_location: nil,
48 | debit: "Unknown",
49 | default: false,
50 | durbin_regulated: "Unknown",
51 | expiration_month: nil,
52 | expiration_year: nil,
53 | expired: nil,
54 | healthcare: "Unknown",
55 | image_url: nil,
56 | issuing_bank: "Unknown",
57 | last_4: nil,
58 | payroll: "Unknown",
59 | prepaid: "Unknown",
60 | token: nil,
61 | unique_number_identifier: nil,
62 | created_at: nil,
63 | updated_at: nil,
64 | venmo_sdk: "Unknown",
65 | subscriptions: [],
66 | verifications: []
67 | end
68 |
--------------------------------------------------------------------------------
/test/integration/plan_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Integration.PlanTest do
2 | use ExUnit.Case, async: true
3 |
4 | @moduletag :integration
5 |
6 | alias Braintree.Plan
7 |
8 | # Create a new plan for testing and delete on exit
9 | setup_all do
10 | {:ok, %Plan{} = existing_plan} =
11 | Plan.create(%{
12 | name: "testing_plan_#{System.unique_integer([:positive])}",
13 | billing_frequency: 1,
14 | currency_iso_code: "USD",
15 | price: "10.00"
16 | })
17 |
18 | on_exit(fn -> Plan.delete(existing_plan.id) end)
19 |
20 | %{existing_plan: existing_plan}
21 | end
22 |
23 | test "all/0 fetches all plans", %{existing_plan: existing_plan} do
24 | {:ok, [%Plan{} = plan | _] = plans} = Plan.all()
25 |
26 | assert plan
27 | assert plan.id
28 | assert plan.name
29 | assert Enum.any?(plans, fn plan -> plan.id == existing_plan.id end)
30 | end
31 |
32 | test "create/0 creates a new plan" do
33 | {:ok, %Plan{} = plan} =
34 | Plan.create(%{
35 | name: "testing_create_plan",
36 | billing_frequency: 1,
37 | currency_iso_code: "USD",
38 | price: "10.00"
39 | })
40 |
41 | assert plan
42 | assert plan.id
43 | assert plan.name == "testing_create_plan"
44 | assert plan.price == "10.00"
45 |
46 | assert :ok = Plan.delete(plan.id)
47 | end
48 |
49 | test "find/1 finds a plan", %{existing_plan: existing_plan} do
50 | {:ok, %Plan{} = plan} = Plan.find(existing_plan.id)
51 |
52 | assert plan
53 | assert plan.id
54 | assert plan.name == existing_plan.name
55 | end
56 |
57 | test "update/2 updates a plan", %{existing_plan: existing_plan} do
58 | {:ok, %Plan{} = plan} = Plan.update(existing_plan.id, %{name: "updated_name"})
59 |
60 | assert plan
61 | assert plan.id == existing_plan.id
62 | assert plan.name == "updated_name"
63 |
64 | # Cleanup and revert back to old name
65 | {:ok, %Plan{}} = Plan.update(existing_plan.id, %{name: existing_plan.name})
66 | end
67 |
68 | test "delete/1 deletes a plan" do
69 | {:ok, %Plan{id: id}} =
70 | Plan.create(%{
71 | name: "testing_delete_plan",
72 | billing_frequency: 1,
73 | currency_iso_code: "USD",
74 | price: "10.00"
75 | })
76 |
77 | assert :ok = Plan.delete(id)
78 | assert {:error, _} = Plan.find(id)
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/lib/search.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Search do
2 | @moduledoc """
3 | This module performs advanced search on a resource.
4 |
5 | For additional reference see:
6 | https://developers.braintreepayments.com/reference/general/searching/search-fields/ruby
7 | """
8 |
9 | alias Braintree.HTTP
10 |
11 | @doc """
12 | Perform an advanced search on a given resource and create new structs
13 | based on the initializer given.
14 |
15 | ## Example
16 | search_params = %{first_name: %{is: "Jenna"}}
17 | {:ok, customers} = Braintree.Search.perform(search_params, "customers", &Braintree.Customer.new/1)
18 |
19 | """
20 | @spec perform(map, String.t(), fun(), Keyword.t()) :: {:ok, [any]} | HTTP.error()
21 | def perform(params, resource, initializer, opts \\ []) when is_map(params) do
22 | with {:ok, payload} <- HTTP.post(resource <> "/advanced_search_ids", %{search: params}, opts) do
23 | fetch_all_records(payload, resource, initializer, opts)
24 | end
25 | end
26 |
27 | defp fetch_all_records(%{"search_results" => %{"ids" => []}}, _resource, _initializer, _opts) do
28 | {:error, :not_found}
29 | end
30 |
31 | defp fetch_all_records(
32 | %{"search_results" => %{"page_size" => page_size, "ids" => ids}},
33 | resource,
34 | initializer,
35 | opts
36 | ) do
37 | records =
38 | ids
39 | |> Enum.chunk_every(page_size)
40 | |> Enum.flat_map(fn ids_chunk ->
41 | fetch_records_chunk(ids_chunk, resource, initializer, opts)
42 | end)
43 |
44 | {:ok, records}
45 | end
46 |
47 | # Credit card verifications and transactions are an odd case because path to endpoints is
48 | # different from the object name in the XML.
49 | defp fetch_records_chunk(ids, resource, initializer, opts)
50 | when is_list(ids) and resource in ~w(verifications transactions) do
51 | search_params = %{search: %{ids: ids}}
52 | key_name = "credit_card_#{resource}"
53 |
54 | with {:ok, %{^key_name => data}} <-
55 | HTTP.post(resource <> "/advanced_search", search_params, opts) do
56 | initializer.(data)
57 | end
58 | end
59 |
60 | defp fetch_records_chunk(ids, resource, initializer, opts) when is_list(ids) do
61 | search_params = %{search: %{ids: ids}}
62 |
63 | with {:ok, %{^resource => data}} <-
64 | HTTP.post(resource <> "/advanced_search", search_params, opts) do
65 | initializer.(data)
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/test/integration/subscription_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Integration.SubscriptionTest do
2 | use ExUnit.Case, async: true
3 |
4 | @moduletag :integration
5 |
6 | alias Braintree.{Customer, Subscription}
7 |
8 | def create_test_subscription do
9 | {:ok, customer} = Customer.create(%{payment_method_nonce: "fake-valid-nonce"})
10 | [card] = customer.credit_cards
11 | Subscription.create(%{payment_method_token: card.token, plan_id: "starter"})
12 | end
13 |
14 | test "create/1 with a plan_id and add_ons" do
15 | assert {:ok, customer} = Customer.create(%{payment_method_nonce: "fake-valid-nonce"})
16 | [card] = customer.credit_cards
17 |
18 | add_ons = %{
19 | add: [%{inherited_from_id: "gold"}],
20 | update: [%{existing_id: "silver", quantity: 2}],
21 | remove: ["bronze"]
22 | }
23 |
24 | assert {:ok, _subscription} =
25 | Subscription.create(%{
26 | payment_method_token: card.token,
27 | plan_id: "business",
28 | add_ons: add_ons
29 | })
30 | end
31 |
32 | test "find/1 with a subscription_id" do
33 | {:ok, subscription} = create_test_subscription()
34 | assert {:ok, subscription} = Subscription.find(subscription.id)
35 |
36 | assert subscription.plan_id == "starter"
37 | assert %Subscription{} = subscription
38 | end
39 |
40 | test "cancel/1 with a subscription_id" do
41 | {:ok, subscription} = create_test_subscription()
42 | assert {:ok, subscription} = Subscription.cancel(subscription.id)
43 |
44 | assert subscription.status == "Canceled"
45 | assert %Subscription{} = subscription
46 | end
47 |
48 | test "retry_charge/1" do
49 | {:ok, subscription} = create_test_subscription()
50 |
51 | assert {:error, error} = Subscription.retry_charge(subscription.id)
52 | assert error.message =~ "Subscription status must be Past Due in order to retry."
53 | end
54 |
55 | test "update/2 with a subscription_id" do
56 | {:ok, subscription} = create_test_subscription()
57 |
58 | assert {:ok, subscription} =
59 | Subscription.update(subscription.id, %{
60 | plan_id: "business",
61 | price: "16.99"
62 | })
63 |
64 | assert subscription.plan_id == "business"
65 | assert subscription.price == "16.99"
66 | end
67 |
68 | describe "search/1" do
69 | test "returns not found if no result" do
70 | assert {:error, :not_found} = Subscription.search(%{plan_id: %{is: "invalid-starter"}})
71 | end
72 |
73 | test "returns server error for invalid search params" do
74 | assert {:error, :server_error} = Subscription.search(%{})
75 | end
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/lib/paypal_account.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.PaypalAccount do
2 | @moduledoc """
3 | Find, update and delete Paypal Accounts using PaymentMethod token
4 | """
5 |
6 | use Braintree.Construction
7 |
8 | alias Braintree.HTTP
9 |
10 | @type t :: %__MODULE__{
11 | billing_agreement_id: String.t(),
12 | created_at: String.t(),
13 | customer_id: String.t(),
14 | email: String.t(),
15 | image_url: String.t(),
16 | payer_info: String.t(),
17 | token: String.t(),
18 | updated_at: String.t(),
19 | default: boolean,
20 | is_channel_initated: boolean,
21 | subscriptions: [any]
22 | }
23 |
24 | defstruct billing_agreement_id: nil,
25 | created_at: nil,
26 | customer_id: nil,
27 | default: false,
28 | email: nil,
29 | image_url: nil,
30 | is_channel_initated: false,
31 | payer_info: nil,
32 | subscriptions: [],
33 | token: nil,
34 | updated_at: nil
35 |
36 | @doc """
37 | Find a paypal account record using `token` or return an error
38 | response if the token is invalid.
39 |
40 | ## Example
41 |
42 | {:ok, paypal_account} = Braintree.PaypalAccount.find(token)
43 | """
44 | @spec find(String.t(), Keyword.t()) :: {:ok, t} | HTTP.error()
45 | def find(token, opts \\ []) do
46 | path = "payment_methods/paypal_account/" <> token
47 |
48 | with {:ok, %{"paypal_account" => map}} <- HTTP.get(path, opts) do
49 | {:ok, new(map)}
50 | end
51 | end
52 |
53 | @doc """
54 | Update a paypal account record using `token` or return an error
55 | response if the token is invalid.
56 |
57 | ## Example
58 |
59 | {:ok, paypal_account} = Braintree.PaypalAccount.update(
60 | token,
61 | %{options: %{make_default: true}
62 | )
63 | """
64 | @spec update(String.t(), map, Keyword.t()) :: {:ok, t} | HTTP.error()
65 | def update(token, params, opts \\ []) do
66 | path = "payment_methods/paypal_account/" <> token
67 |
68 | with {:ok, %{"paypal_account" => map}} <- HTTP.put(path, %{paypal_account: params}, opts) do
69 | {:ok, new(map)}
70 | end
71 | end
72 |
73 | @doc """
74 | Delete a paypal account record using `token` or return an error
75 | response if the token is invalid.
76 |
77 | ## Example
78 |
79 | {:ok, paypal_account} = Braintree.PaypalAccount.delete(token)
80 | """
81 | @spec delete(String.t(), Keyword.t()) :: {:ok, t} | HTTP.error()
82 | def delete(token, opts \\ []) do
83 | path = "payment_methods/paypal_account/" <> token
84 |
85 | with {:ok, _payload} <- HTTP.delete(path, opts) do
86 | :ok
87 | end
88 | end
89 | end
90 |
--------------------------------------------------------------------------------
/lib/braintree.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree do
2 | @moduledoc """
3 | A native Braintree client library for Elixir. Only a subset of the API is
4 | supported and this is a work in progress. That said, it is already used in
5 | production, and any modules that have been implemented can be used.
6 |
7 | For general reference please see:
8 | https://developers.braintreepayments.com/reference/overview
9 | """
10 |
11 | defmodule ConfigError do
12 | @moduledoc """
13 | Raised at runtime when a config variable is missing.
14 | """
15 |
16 | defexception [:message]
17 |
18 | @doc """
19 | Build a new ConfigError exception.
20 | """
21 | @impl true
22 | def exception(value) do
23 | message = "missing config for :#{value}"
24 |
25 | %ConfigError{message: message}
26 | end
27 | end
28 |
29 | @doc """
30 | Convenience function for retrieving braintree specfic environment values, but
31 | will raise an exception if values are missing.
32 |
33 | ## Example
34 |
35 | iex> Braintree.get_env(:random_value)
36 | ** (Braintree.ConfigError) missing config for :random_value
37 |
38 | iex> Braintree.get_env(:random_value, "random")
39 | "random"
40 |
41 | iex> Application.put_env(:braintree, :random_value, "not-random")
42 | ...> value = Braintree.get_env(:random_value)
43 | ...> Application.delete_env(:braintree, :random_value)
44 | ...> value
45 | "not-random"
46 |
47 | iex> System.put_env("RANDOM", "not-random")
48 | ...> Application.put_env(:braintree, :system_value, {:system, "RANDOM"})
49 | ...> value = Braintree.get_env(:system_value)
50 | ...> System.delete_env("RANDOM")
51 | ...> value
52 | "not-random"
53 | """
54 | @spec get_env(atom, any) :: any
55 | def get_env(key, default \\ nil) do
56 | case Application.fetch_env(:braintree, key) do
57 | {:ok, {:system, var}} when is_binary(var) ->
58 | fallback_or_raise(var, System.get_env(var), default)
59 |
60 | {:ok, value} ->
61 | value
62 |
63 | :error ->
64 | fallback_or_raise(key, nil, default)
65 | end
66 | end
67 |
68 | @doc """
69 | Convenience function for setting `braintree` application environment
70 | variables.
71 |
72 | ## Example
73 |
74 | iex> Braintree.put_env(:thingy, "thing")
75 | ...> Braintree.get_env(:thingy)
76 | "thing"
77 | """
78 | @spec put_env(atom, any) :: :ok
79 | def put_env(key, value) do
80 | Application.put_env(:braintree, key, value)
81 | end
82 |
83 | defp fallback_or_raise(key, nil, nil), do: raise(ConfigError, key)
84 | defp fallback_or_raise(_, nil, default) when is_function(default, 0), do: default.()
85 | defp fallback_or_raise(_, nil, default), do: default
86 | defp fallback_or_raise(_, value, _), do: value
87 | end
88 |
--------------------------------------------------------------------------------
/lib/credit_card_verification.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.CreditCardVerification do
2 | @moduledoc """
3 | Manage credit card verifications.
4 |
5 | For additional reference see:
6 | https://developers.braintreepayments.com/reference/response/credit-card-verification/ruby
7 | """
8 |
9 | use Braintree.Construction
10 |
11 | alias Braintree.{Address, CreditCard, Search}
12 | alias Braintree.ErrorResponse, as: Error
13 |
14 | @type t :: %__MODULE__{
15 | amount: String.t(),
16 | avs_error_response_code: String.t(),
17 | avs_postal_code_response_code: String.t(),
18 | avs_street_address_response_code: String.t(),
19 | billing: Address.t(),
20 | created_at: String.t(),
21 | credit_card: CreditCard.t(),
22 | currency_iso_code: String.t(),
23 | cvv_response_code: String.t(),
24 | gateway_rejection_reason: String.t(),
25 | id: String.t(),
26 | merchant_account_id: String.t(),
27 | processor_response_code: String.t(),
28 | processor_response_text: String.t(),
29 | risk_data: map,
30 | status: String.t()
31 | }
32 |
33 | defstruct amount: nil,
34 | avs_error_response_code: nil,
35 | avs_postal_code_response_code: nil,
36 | avs_street_address_response_code: nil,
37 | billing: %Address{},
38 | created_at: nil,
39 | credit_card: %CreditCard{},
40 | currency_iso_code: nil,
41 | cvv_response_code: nil,
42 | gateway_rejection_reason: nil,
43 | id: nil,
44 | merchant_account_id: nil,
45 | processor_response_code: nil,
46 | processor_response_text: nil,
47 | risk_data: %{},
48 | status: nil
49 |
50 | @doc """
51 | To search for credit card verifications, pass a map of search parameters.
52 |
53 | ## Example:
54 |
55 | search_params = %{amount: %{min: "10.0", max: "15.0"},
56 | status: ["approved", "pending"]}
57 |
58 | {:ok, verifications} = Braintree.CreditCardVerification.search(search_params)
59 | """
60 | @spec search(map, Keyword.t()) :: {:ok, t} | {:error, Error.t()}
61 | def search(params, opts \\ []) when is_map(params) do
62 | Search.perform(params, "verifications", &new/1, opts)
63 | end
64 |
65 | @doc """
66 | Convert a map into a CreditCardVerification struct.
67 |
68 | ## Example
69 |
70 | verification = Braintree.CreditCardVerification.new(%{"credit_card_card_type" => "Visa"})
71 | """
72 | def new(%{"credit_card_verification" => map}) do
73 | new(map)
74 | end
75 |
76 | def new(map) when is_map(map) do
77 | super(map)
78 | end
79 |
80 | def new(list) when is_list(list) do
81 | Enum.map(list, &new/1)
82 | end
83 | end
84 |
--------------------------------------------------------------------------------
/test/merchant/account_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Merchant.AccountTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Address
5 | alias Braintree.Merchant.{Account, Business, Funding, Individual}
6 |
7 | test "merchant account includes all attributes" do
8 | merchant = %Account{
9 | id: "ladders-merchant",
10 | status: "pending",
11 | currency_iso_code: "USD",
12 | default: true,
13 | master_merchant_account: "ladders_store",
14 | individual: %Individual{
15 | first_name: "Jane",
16 | address: %Address{street_address: "101 N Main St"}
17 | },
18 | business: %Business{
19 | legal_name: "Ladders.io",
20 | address: %Address{street_address: "102 N Main St"}
21 | },
22 | funding: %Funding{account_number_last_4: "7890"}
23 | }
24 |
25 | assert merchant.id == "ladders-merchant"
26 | assert merchant.status == "pending"
27 | assert merchant.currency_iso_code == "USD"
28 | assert merchant.default == true
29 | assert merchant.master_merchant_account == "ladders_store"
30 | assert merchant.individual.first_name == "Jane"
31 | assert merchant.individual.address.street_address == "101 N Main St"
32 | assert merchant.business.legal_name == "Ladders.io"
33 | assert merchant.business.address.street_address == "102 N Main St"
34 | assert merchant.funding.account_number_last_4 == "7890"
35 | end
36 |
37 | test "new/1 works with all sub-modules" do
38 | data = %{
39 | "merchant_account" => %{
40 | "id" => "ladders-merchant",
41 | "status" => "pending",
42 | "currency_iso_code" => "USD",
43 | "default" => true,
44 | "master_merchant_account" => "ladders_store",
45 | "individual" => %{
46 | "first_name" => "Jane",
47 | "address" => %{
48 | "street_address" => "101 N Main St"
49 | }
50 | },
51 | "business" => %{
52 | "legal_name" => "Ladders.io",
53 | "address" => %{
54 | "street_address" => "102 N Main St"
55 | }
56 | },
57 | "funding" => %{
58 | "account_number_last_4" => "7890"
59 | }
60 | }
61 | }
62 |
63 | merchant = Account.new(data)
64 |
65 | assert merchant.id == "ladders-merchant"
66 | assert merchant.status == "pending"
67 | assert merchant.currency_iso_code == "USD"
68 | assert merchant.default == true
69 | assert merchant.master_merchant_account == "ladders_store"
70 | assert merchant.individual.first_name == "Jane"
71 | assert merchant.individual.address.street_address == "101 N Main St"
72 | assert merchant.business.legal_name == "Ladders.io"
73 | assert merchant.business.address.street_address == "102 N Main St"
74 | assert merchant.funding.account_number_last_4 == "7890"
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/lib/merchant/account.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Merchant.Account do
2 | @moduledoc """
3 | Represents a merchant account in a marketplace.
4 |
5 | For additional reference, see:
6 | https://developers.braintreepayments.com/reference/response/merchant-account/ruby
7 | """
8 |
9 | use Braintree.Construction
10 |
11 | alias Braintree.HTTP
12 | alias Braintree.Merchant.{Business, Funding, Individual}
13 |
14 | @type t :: %__MODULE__{
15 | individual: Individual.t(),
16 | business: Business.t(),
17 | funding: Funding.t(),
18 | id: String.t(),
19 | master_merchant_account: String.t(),
20 | status: String.t(),
21 | currency_iso_code: String.t(),
22 | default: boolean
23 | }
24 |
25 | defstruct individual: %Individual{},
26 | business: %Business{},
27 | funding: %Funding{},
28 | id: nil,
29 | master_merchant_account: nil,
30 | status: nil,
31 | currency_iso_code: nil,
32 | default: false
33 |
34 | @doc """
35 | Create a merchant account or return an error response after failed validation
36 |
37 | ## Example
38 |
39 | {:ok, merchant} = Braintree.Merchant.Account.create(%{
40 | tos_accepted: true,
41 | })
42 | """
43 | @spec create(map, Keyword.t()) :: {:ok, t} | HTTP.error()
44 | def create(params \\ %{}, opts \\ []) do
45 | with {:ok, payload} <-
46 | HTTP.post("merchant_accounts/create_via_api", %{merchant_account: params}, opts) do
47 | {:ok, new(payload)}
48 | end
49 | end
50 |
51 | @doc """
52 | To update a merchant, use its ID along with new attributes.
53 | The same validations apply as when creating a merchant.
54 | Any attribute not passed will remain unchanged.
55 |
56 | ## Example
57 |
58 | {:ok, merchant} = Braintree.Merchant.update("merchant_id", %{
59 | funding_details: %{account_number: "1234567890"}
60 | })
61 |
62 | merchant.funding_details.account_number # "1234567890"
63 | """
64 | @spec update(binary, map, Keyword.t()) :: {:ok, t} | HTTP.error()
65 | def update(id, params, opts \\ []) when is_binary(id) do
66 | with {:ok, payload} <-
67 | HTTP.put("merchant_accounts/#{id}/update_via_api", %{merchant_account: params}, opts) do
68 | {:ok, new(payload)}
69 | end
70 | end
71 |
72 | @doc """
73 | If you want to look up a single merchant using ID, use the find method.
74 |
75 | ## Example
76 |
77 | merchant = Braintree.Merchant.find("merchant_id")
78 | """
79 | @spec find(binary, Keyword.t()) :: {:ok, t} | HTTP.error()
80 | def find(id, opts \\ []) when is_binary(id) do
81 | with {:ok, payload} <- HTTP.get("merchant_accounts/" <> id, opts) do
82 | {:ok, new(payload)}
83 | end
84 | end
85 |
86 | @doc """
87 | Convert a map into a `Braintree.Merchant.Account` struct.
88 | """
89 | def new(%{"merchant_account" => map}), do: super(map)
90 | end
91 |
--------------------------------------------------------------------------------
/lib/settlement_batch_summary.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.SettlementBatchSummary do
2 | @moduledoc """
3 | The settlement batch summary displays the total sales and credits for each
4 | batch for a particular date. The transactions can be grouped by a single
5 | custom field's values.
6 |
7 | https://developers.braintreepayments.com/reference/request/settlement-batch-summary/generate/ruby
8 | """
9 |
10 | use Braintree.Construction
11 |
12 | import Braintree.Util, only: [atomize: 1]
13 |
14 | alias Braintree.HTTP
15 |
16 | defmodule Record do
17 | @moduledoc """
18 | A record contains details for a transaction in a summary.
19 | """
20 |
21 | @type t :: %__MODULE__{
22 | card_type: String.t(),
23 | count: String.t(),
24 | merchant_account_id: String.t(),
25 | kind: String.t(),
26 | amount_settled: String.t()
27 | }
28 |
29 | defstruct card_type: nil,
30 | count: "0",
31 | merchant_account_id: nil,
32 | kind: nil,
33 | amount_settled: nil
34 |
35 | @doc """
36 | Convert a list of records into structs, including any custom fields that
37 | were used as the grouping value.
38 | """
39 | def new(params) when is_map(params) do
40 | atomized = atomize(params)
41 | summary = Construction.new(__MODULE__, params)
42 |
43 | case Map.keys(atomized) -- Map.keys(summary) do
44 | [custom_key] -> Map.put(summary, custom_key, atomized[custom_key])
45 | _ -> summary
46 | end
47 | end
48 |
49 | def new(params) when is_list(params) do
50 | Enum.map(params, &new/1)
51 | end
52 | end
53 |
54 | @type t :: %__MODULE__{records: [Record.t()]}
55 |
56 | defstruct records: []
57 |
58 | @doc """
59 | Generate a report of all settlements for a particular date. The
60 | field used for custom grouping will always be set as
61 | `custom_field`, regardless of its name.
62 |
63 | ## Example
64 |
65 | Braintree.SettlementBatchSummary("2016-9-5", "custom_field_1")
66 | """
67 | @spec generate(binary, binary | nil, Keyword.t()) :: {:ok, [t]} | HTTP.error()
68 | def generate(settlement_date, custom_field \\ nil, opts \\ []) do
69 | criteria = build_criteria(settlement_date, custom_field)
70 | params = %{settlement_batch_summary: criteria}
71 |
72 | with {:ok, payload} <- HTTP.post("settlement_batch_summary", params, opts) do
73 | %{"settlement_batch_summary" => summary} = payload
74 |
75 | {:ok, new(summary)}
76 | end
77 | end
78 |
79 | @spec build_criteria(binary, binary | nil) :: map
80 | defp build_criteria(settlement_date, nil) do
81 | %{settlement_date: settlement_date}
82 | end
83 |
84 | defp build_criteria(settlement_date, custom_field) do
85 | %{settlement_date: settlement_date, group_by_custom_field: custom_field}
86 | end
87 |
88 | @doc """
89 | Convert a map including records into a summary struct with a list
90 | of record structs.
91 | """
92 | def new(%{"records" => records}) do
93 | struct(__MODULE__, records: Record.new(records))
94 | end
95 | end
96 |
--------------------------------------------------------------------------------
/test/xml/encoder_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.XML.EncoderTest do
2 | use ExUnit.Case, async: true
3 |
4 | doctest Braintree.XML.Encoder
5 |
6 | import Braintree.XML.Encoder, only: [dump: 1]
7 |
8 | @xml_tag ~s||
9 |
10 | describe "dump/1" do
11 | test "with content" do
12 | assert [xml_tag | nodes] =
13 | %{company: "Soren", first_name: "Parker"}
14 | |> dump()
15 | |> String.split("\n")
16 |
17 | # Order isn't guaranteed when iterating on a map.
18 | assert xml_tag == @xml_tag
19 | assert ~s|Soren| in nodes
20 | assert ~s|Parker| in nodes
21 | end
22 |
23 | test "with children" do
24 | assert [xml_tag | nodes] =
25 | %{company: "Soren", nested: %{name: "Parker"}}
26 | |> dump()
27 | |> String.split("\n")
28 |
29 | assert xml_tag == @xml_tag
30 |
31 | assert nodes == ~w[Soren Parker ] or
32 | nodes == ~w[ Parker Soren]
33 |
34 | assert [xml_tag | nodes] =
35 | %{company: "Soren", nested: [%{name: "Parker"}, %{name: "Shannon"}]}
36 | |> dump()
37 | |> String.split("\n")
38 |
39 | assert xml_tag == @xml_tag
40 |
41 | assert nodes in [
42 | [
43 | "Soren",
44 | ~s||,
45 | "- ",
46 | "Parker",
47 | "
",
48 | "- ",
49 | "Shannon",
50 | "
",
51 | ""
52 | ],
53 | [
54 | ~s||,
55 | "- ",
56 | "Parker",
57 | "
",
58 | "- ",
59 | "Shannon",
60 | "
",
61 | "",
62 | "Soren"
63 | ]
64 | ]
65 |
66 | assert [xml_tag | nodes] =
67 | %{company: "Soren", pets: ["cat", "dog"]}
68 | |> dump()
69 | |> String.split("\n")
70 |
71 | assert xml_tag == @xml_tag
72 |
73 | assert nodes in [
74 | [
75 | "Soren",
76 | ~s||,
77 | "- ",
78 | "cat",
79 | "
",
80 | "- ",
81 | "dog",
82 | "
",
83 | ""
84 | ],
85 | [
86 | ~s||,
87 | "- ",
88 | "cat",
89 | "
",
90 | "- ",
91 | "dog",
92 | "
",
93 | "",
94 | "Soren"
95 | ]
96 | ]
97 | end
98 | end
99 | end
100 |
--------------------------------------------------------------------------------
/test/integration/merchant_account_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Integration.MerchantAccountTest do
2 | use ExUnit.Case, async: true
3 |
4 | @moduletag :integration
5 |
6 | alias Braintree.Merchant.Account
7 |
8 | @master_merchant_id Braintree.get_env(:master_merchant_id)
9 | @params %{
10 | individual: %{
11 | first_name: "Jane",
12 | last_name: "Doe",
13 | email: "jane@14ladders.com",
14 | phone: "5553334444",
15 | date_of_birth: "1981-11-19",
16 | ssn: "456-45-4567",
17 | address: %{
18 | street_address: "111 Main St",
19 | locality: "Chicago",
20 | region: "IL",
21 | postal_code: "60622"
22 | }
23 | },
24 | business: %{
25 | legal_name: "Jane's Ladders",
26 | dba_name: "Jane's Ladders",
27 | tax_id: "98-7654321",
28 | address: %{
29 | street_address: "111 Main St",
30 | locality: "Chicago",
31 | region: "IL",
32 | postal_code: "60622"
33 | }
34 | },
35 | funding: %{
36 | descriptor: "Blue Ladders",
37 | destination: "bank",
38 | email: "funding@blueladders.com",
39 | mobile_phone: "5555555555",
40 | account_number: "1123581321",
41 | routing_number: "071101307"
42 | },
43 | tos_accepted: true,
44 | master_merchant_account_id: @master_merchant_id
45 | }
46 |
47 | def merchant_id do
48 | "ladders_store_#{:rand.uniform(10000)}"
49 | end
50 |
51 | describe "create/1" do
52 | test "without any params" do
53 | assert {:error, :forbidden} = Account.create()
54 | end
55 |
56 | test "with valid params" do
57 | assert {:ok, merchant} = Account.create(Map.put(@params, :id, merchant_id()))
58 |
59 | assert merchant.id =~ ~r/ladders_store_/i
60 | end
61 |
62 | test "with invalid params" do
63 | params = %{"tos_accepted" => false, "master_merchant_account_id" => @master_merchant_id}
64 |
65 | assert {:error, error} = Account.create(params)
66 | assert error.message =~ ~r/terms of service needs to be accepted/i
67 | end
68 | end
69 |
70 | describe "update/2" do
71 | test "with valid params" do
72 | {:ok, merchant} = Account.create(@params)
73 | {:ok, merchant} = Account.update(merchant.id, %{funding: %{account_number: "00001112"}})
74 |
75 | assert merchant.funding.account_number_last_4 == "1112"
76 | end
77 |
78 | test "with invalid params" do
79 | {:ok, merchant} = Account.create(@params)
80 |
81 | assert {:error, error} =
82 | Account.update(merchant.id, %{
83 | funding: %{destination: "Somewhere under the rainbow"}
84 | })
85 |
86 | assert error.message == "Funding destination is invalid."
87 | end
88 |
89 | test "with non existent merchant" do
90 | assert {:error, :not_found} =
91 | Account.update("invalid-merchant-id", %{funding: %{account_number: "1212121"}})
92 | end
93 | end
94 |
95 | describe "find/1" do
96 | test "with valid merchant ID" do
97 | assert {:ok, merchant} = Account.create(@params)
98 |
99 | assert {:ok, _merchant} = Account.find(merchant.id)
100 | end
101 |
102 | test "not found with invalid merchant ID" do
103 | assert {:error, :not_found} = Account.find("invalid-merchant-id")
104 | end
105 | end
106 | end
107 |
--------------------------------------------------------------------------------
/test/integration/test_transaction_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Integration.TestTransactionTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Transaction
5 | alias Braintree.Testing.{Nonces, TestTransaction}
6 |
7 | @moduletag :integration
8 |
9 | test "settle/1 succeeds if sale has been submitted for settlement" do
10 | {:ok, transaction} =
11 | Transaction.sale(%{
12 | amount: "100.00",
13 | payment_method_nonce: Nonces.transactable(),
14 | options: %{submit_for_settlement: true}
15 | })
16 |
17 | {:ok, settle_transaction} = TestTransaction.settle(transaction.id)
18 |
19 | assert settle_transaction.status == "settled"
20 | end
21 |
22 | test "settle/1 fails if sale is authorized only" do
23 | {:ok, transaction} =
24 | Transaction.sale(%{
25 | amount: "100.00",
26 | payment_method_nonce: Nonces.transactable()
27 | })
28 |
29 | {:error, error} = TestTransaction.settle(transaction.id)
30 |
31 | assert error.message ==
32 | "Cannot transition transaction to settled, settlement_confirmed, or settlement_declined"
33 |
34 | refute error.params == %{}
35 | refute error.errors == %{}
36 | end
37 |
38 | test "settle/1 fails if transaction id is invalid" do
39 | assert {:error, :not_found} = TestTransaction.settle("bogus")
40 | end
41 |
42 | test "settle_confirm/1 fails if sale is authorized only" do
43 | {:ok, transaction} =
44 | Transaction.sale(%{
45 | amount: "100.00",
46 | payment_method_nonce: Nonces.transactable()
47 | })
48 |
49 | {:error, error} = TestTransaction.settlement_confirm(transaction.id)
50 |
51 | assert error.message ==
52 | "Cannot transition transaction to settled, settlement_confirmed, or settlement_declined"
53 |
54 | refute error.params == %{}
55 | refute error.errors == %{}
56 | end
57 |
58 | test "settlement_confirm/1 succeeds if sale has been submit for settlement" do
59 | {:ok, transaction} =
60 | Transaction.sale(%{
61 | amount: "100.00",
62 | payment_method_nonce: Nonces.transactable(),
63 | options: %{submit_for_settlement: true}
64 | })
65 |
66 | {:ok, settle_transaction} = TestTransaction.settlement_confirm(transaction.id)
67 |
68 | assert settle_transaction.status == "settlement_confirmed"
69 | end
70 |
71 | test "settlement_decline/1 fails if sale is authorized only" do
72 | {:ok, transaction} =
73 | Transaction.sale(%{
74 | amount: "100.00",
75 | payment_method_nonce: Nonces.transactable()
76 | })
77 |
78 | {:error, error} = TestTransaction.settlement_decline(transaction.id)
79 |
80 | assert error.message ==
81 | "Cannot transition transaction to settled, settlement_confirmed, or settlement_declined"
82 |
83 | refute error.params == %{}
84 | refute error.errors == %{}
85 | end
86 |
87 | test "settlement_decline/1 succeeds if sale has been submit for settlement" do
88 | {:ok, transaction} =
89 | Transaction.sale(%{
90 | amount: "100.00",
91 | payment_method_nonce: Nonces.transactable(),
92 | options: %{submit_for_settlement: true}
93 | })
94 |
95 | {:ok, settle_transaction} = TestTransaction.settlement_decline(transaction.id)
96 |
97 | assert settle_transaction.status == "settlement_declined"
98 | end
99 | end
100 |
--------------------------------------------------------------------------------
/lib/xml/decoder.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.XML.Decoder do
2 | @moduledoc """
3 | XML dumping tailored to encoding params sent by Braintree.
4 | """
5 |
6 | @type xml :: binary()
7 |
8 | import Braintree.Util, only: [underscorize: 1]
9 | import Braintree.XML.Entity, only: [decode: 1]
10 |
11 | @doc ~S"""
12 | Converts an XML document, or fragment, into a map. Type annotation
13 | attributes are respected, but all other attributes are ignored.
14 |
15 | ## Examples
16 |
17 | iex> Braintree.XML.Decoder.load("12")
18 | %{"a" => %{"b" => 1, "c" => "2"}}
19 |
20 | iex> Braintree.XML.Decoder.load("José")
21 | %{"a" => %{"b" => "José"}}
22 |
23 | iex> Braintree.XML.Decoder.load("First & Last")
24 | %{"a" => %{"b" => "First & Last"}}
25 |
26 | iex> Braintree.XML.Decoder.load(""air quotes"")
27 | %{"a" => %{"b" => ~s("air quotes")}}
28 | """
29 | @spec load(xml) :: map()
30 | def load(""), do: %{}
31 |
32 | def load(xml) do
33 | {name, attributes, values} =
34 | xml
35 | |> decode()
36 | |> :erlang.bitstring_to_list()
37 | |> :xmerl_scan.string()
38 | |> elem(0)
39 | |> parse()
40 |
41 | case attributes do
42 | [type: "array"] -> %{name => transform({attributes, values})}
43 | [type: "collection"] -> %{name => transform({attributes, values})}
44 | _ -> %{name => transform(values)}
45 | end
46 | end
47 |
48 | defp parse(elements) when is_list(elements), do: Enum.map(elements, &parse/1)
49 |
50 | defp parse({:xmlElement, name, _, _, _, _, _, attributes, elements, _, _, _}),
51 | do: {underscorize(name), parse(attributes), parse(elements)}
52 |
53 | defp parse({:xmlAttribute, name, _, _, _, _, _, _, value, _}), do: {name, to_string(value)}
54 |
55 | defp parse({:xmlText, _, _, _, value, _}),
56 | do:
57 | value
58 | |> to_string()
59 | |> String.trim()
60 | |> List.wrap()
61 | |> Enum.reject(&(&1 == ""))
62 | |> List.first()
63 |
64 | defp transform(elements) when is_list(elements) do
65 | if is_text_list?(elements) do
66 | Enum.join(elements, " ")
67 | else
68 | Enum.into(without_nil(elements), %{}, &transform/1)
69 | end
70 | end
71 |
72 | defp transform({[type: "array"], elements}),
73 | do: Enum.map(without_nil(elements), &elem(transform(&1), 1))
74 |
75 | defp transform({[type: "collection"], elements}),
76 | do: Enum.map(only_collection(elements), &elem(transform(&1), 1))
77 |
78 | defp transform({name, [type: "integer"], [value]}), do: {name, String.to_integer(value)}
79 |
80 | defp transform({name, [type: "array"], elements}),
81 | do: {name, Enum.map(without_nil(elements), &elem(transform(&1), 1))}
82 |
83 | defp transform({name, [type: "boolean"], [value]}), do: {name, value == "true"}
84 |
85 | defp transform({name, [nil: "true"], []}), do: {name, nil}
86 |
87 | defp transform({name, _, [value]}), do: {name, value}
88 |
89 | defp transform({name, _, []}), do: {name, ""}
90 |
91 | defp transform({name, _, values}), do: {name, transform(values)}
92 |
93 | defp is_text_list?([last]) when is_binary(last), do: true
94 | defp is_text_list?([hd | rest]) when is_binary(hd), do: is_text_list?(rest)
95 | defp is_text_list?(_), do: false
96 |
97 | defp without_nil(list), do: Enum.reject(list, &is_nil/1)
98 |
99 | defp only_collection(elements),
100 | do:
101 | elements
102 | |> without_nil()
103 | |> Enum.reject(fn {_, value, _} -> value != [] end)
104 | end
105 |
--------------------------------------------------------------------------------
/test/xml/decoder_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.XML.DecoderTest do
2 | use ExUnit.Case, async: true
3 |
4 | doctest Braintree.XML.Decoder
5 |
6 | import Braintree.XML.Decoder, only: [load: 1]
7 |
8 | describe "load/1" do
9 | test "with an empty string" do
10 | assert load("") == %{}
11 | end
12 |
13 | test "with simple values" do
14 | assert load("SorenParker") ==
15 | %{"customer" => %{"company" => "Soren", "name" => "Parker"}}
16 | end
17 |
18 | test "with typed values" do
19 | xml = """
20 |
21 | 65854825
22 |
23 |
24 | 2016-02-02T18:36:33Z
25 |
26 |
27 |
28 | 510510
29 | MasterCard
30 | true
31 | 01
32 | 2016
33 | false
34 |
35 |
36 |
37 |
38 |
39 | """
40 |
41 | assert load(xml) == %{
42 | "customer" => %{
43 | "id" => 65_854_825,
44 | "first_name" => nil,
45 | "last_name" => nil,
46 | "created_at" => "2016-02-02T18:36:33Z",
47 | "custom_fields" => "",
48 | "credit_cards" => [
49 | %{
50 | "bin" => "510510",
51 | "card_type" => "MasterCard",
52 | "default" => true,
53 | "expiration_month" => "01",
54 | "expiration_year" => "2016",
55 | "expired" => false,
56 | "verifications" => []
57 | }
58 | ],
59 | "addresses" => []
60 | }
61 | }
62 | end
63 |
64 | test "with a top level array" do
65 | xml = """
66 |
67 |
68 | 1
69 | 2
70 |
71 |
72 | 2
73 | 2
74 |
75 |
76 | """
77 |
78 | assert load(xml) == %{
79 | "plans" => [
80 | %{"id" => 1, "merchant_id" => 2},
81 | %{"id" => 2, "merchant_id" => 2}
82 | ]
83 | }
84 | end
85 |
86 | test "with a top level collection" do
87 | xml = """
88 |
89 | 1
90 | 50
91 | 2
92 |
93 | 1
94 | Jenna
95 |
96 |
97 | 2
98 | Jenna
99 |
100 |
101 | """
102 |
103 | assert load(xml) == %{
104 | "customers" => [
105 | %{"id" => "1", "first_name" => "Jenna"},
106 | %{"id" => "2", "first_name" => "Jenna"}
107 | ]
108 | }
109 | end
110 | end
111 | end
112 |
--------------------------------------------------------------------------------
/priv/entities.txt:
--------------------------------------------------------------------------------
1 | quot,",34
2 | apos,',39
3 | nbsp, ,160
4 | iexcl,¡,161
5 | cent,¢,162
6 | pound,£,163
7 | curren,¤,164
8 | yen,¥,165
9 | brvbar,¦,166
10 | sect,§,167
11 | uml,¨,168
12 | copy,©,169
13 | ordf,ª,170
14 | laquo,«,171
15 | not,¬,172
16 | shy, ,173
17 | reg,®,174
18 | macr,¯,175
19 | deg,°,176
20 | plusmn,±,177
21 | sup2,²,178
22 | sup3,³,179
23 | acute,´,180
24 | micro,µ,181
25 | para,¶,182
26 | middot,·,183
27 | cedil,¸,184
28 | sup1,¹,185
29 | ordm,º,186
30 | raquo,»,187
31 | frac14,¼,188
32 | frac12,½,189
33 | frac34,¾,190
34 | iquest,¿,191
35 | Agrave,À,192
36 | Aacute,Á,193
37 | Acirc,Â,194
38 | Atilde,Ã,195
39 | Auml,Ä,196
40 | Aring,Å,197
41 | AElig,Æ,198
42 | Ccedil,Ç,199
43 | Egrave,È,200
44 | Eacute,É,201
45 | Ecirc,Ê,202
46 | Euml,Ë,203
47 | Igrave,Ì,204
48 | Iacute,Í,205
49 | Icirc,Î,206
50 | Iuml,Ï,207
51 | ETH,Ð,208
52 | Ntilde,Ñ,209
53 | Ograve,Ò,210
54 | Oacute,Ó,211
55 | Ocirc,Ô,212
56 | Otilde,Õ,213
57 | Ouml,Ö,214
58 | times,×,215
59 | Oslash,Ø,216
60 | Ugrave,Ù,217
61 | Uacute,Ú,218
62 | Ucirc,Û,219
63 | Uuml,Ü,220
64 | Yacute,Ý,221
65 | THORN,Þ,222
66 | szlig,ß,223
67 | agrave,à,224
68 | aacute,á,225
69 | acirc,â,226
70 | atilde,ã,227
71 | auml,ä,228
72 | aring,å,229
73 | aelig,æ,230
74 | ccedil,ç,231
75 | egrave,è,232
76 | eacute,é,233
77 | ecirc,ê,234
78 | euml,ë,235
79 | igrave,ì,236
80 | iacute,í,237
81 | icirc,î,238
82 | iuml,ï,239
83 | eth,ð,240
84 | ntilde,ñ,241
85 | ograve,ò,242
86 | oacute,ó,243
87 | ocirc,ô,244
88 | otilde,õ,245
89 | ouml,ö,246
90 | divide,÷,247
91 | oslash,ø,248
92 | ugrave,ù,249
93 | uacute,ú,250
94 | ucirc,û,251
95 | uuml,ü,252
96 | yacute,ý,253
97 | thorn,þ,254
98 | yuml,ÿ,255
99 | OElig,Œ,338
100 | oelig,œ,339
101 | Scaron,Š,352
102 | scaron,š,353
103 | Yuml,Ÿ,376
104 | fnof,ƒ,402
105 | circ,ˆ,710
106 | tilde,˜,732
107 | Alpha,Α,913
108 | Beta,Β,914
109 | Gamma,Γ,915
110 | Delta,Δ,916
111 | Epsilon,Ε,917
112 | Zeta,Ζ,918
113 | Eta,Η,919
114 | Theta,Θ,920
115 | Iota,Ι,921
116 | Kappa,Κ,922
117 | Lambda,Λ,923
118 | Mu,Μ,924
119 | Nu,Ν,925
120 | Xi,Ξ,926
121 | Omicron,Ο,927
122 | Pi,Π,928
123 | Rho,Ρ,929
124 | Sigma,Σ,931
125 | Tau,Τ,932
126 | Upsilon,Υ,933
127 | Phi,Φ,934
128 | Chi,Χ,935
129 | Psi,Ψ,936
130 | Omega,Ω,937
131 | alpha,α,945
132 | beta,β,946
133 | gamma,γ,947
134 | delta,δ,948
135 | epsilon,ε,949
136 | zeta,ζ,950
137 | eta,η,951
138 | theta,θ,952
139 | iota,ι,953
140 | kappa,κ,954
141 | lambda,λ,955
142 | mu,μ,956
143 | nu,ν,957
144 | xi,ξ,958
145 | omicron,ο,959
146 | pi,π,960
147 | rho,ρ,961
148 | sigmaf,ς,962
149 | sigma,σ,963
150 | tau,τ,964
151 | upsilon,υ,965
152 | phi,φ,966
153 | chi,χ,967
154 | psi,ψ,968
155 | omega,ω,969
156 | thetasym,ϑ,977
157 | upsih,ϒ,978
158 | piv,ϖ,982
159 | ensp, ,8194
160 | emsp, ,8195
161 | thinsp, ,8201
162 | zwnj, ,8204
163 | zwj, ,8205
164 | lrm, ,8206
165 | rlm, ,8207
166 | ndash,–,8211
167 | mdash,—,8212
168 | lsquo,‘,8216
169 | rsquo,’,8217
170 | sbquo,‚,8218
171 | ldquo,“,8220
172 | rdquo,”,8221
173 | bdquo,„,8222
174 | dagger,†,8224
175 | Dagger,‡,8225
176 | bull,•,8226
177 | hellip,…,8230
178 | permil,‰,8240
179 | prime,′,8242
180 | Prime,″,8243
181 | lsaquo,‹,8249
182 | rsaquo,›,8250
183 | oline,‾,8254
184 | frasl,⁄,8260
185 | euro,€,8364
186 | image,ℑ,8465
187 | weierp,℘,8472
188 | real,ℜ,8476
189 | trade,™,8482
190 | alefsym,ℵ,8501
191 | larr,←,8592
192 | uarr,↑,8593
193 | rarr,→,8594
194 | darr,↓,8595
195 | harr,↔,8596
196 | crarr,↵,8629
197 | lArr,⇐,8656
198 | uArr,⇑,8657
199 | rArr,⇒,8658
200 | dArr,⇓,8659
201 | hArr,⇔,8660
202 | forall,∀,8704
203 | part,∂,8706
204 | exist,∃,8707
205 | empty,∅,8709
206 | nabla,∇,8711
207 | isin,∈,8712
208 | notin,∉,8713
209 | ni,∋,8715
210 | prod,∏,8719
211 | sum,∑,8721
212 | minus,−,8722
213 | lowast,∗,8727
214 | radic,√,8730
215 | prop,∝,8733
216 | infin,∞,8734
217 | ang,∠,8736
218 | and,∧,8743
219 | or,∨,8744
220 | cap,∩,8745
221 | cup,∪,8746
222 | int,∫,8747
223 | there4,∴,8756
224 | sim,∼,8764
225 | cong,≅,8773
226 | asymp,≈,8776
227 | ne,≠,8800
228 | equiv,≡,8801
229 | le,≤,8804
230 | ge,≥,8805
231 | sub,⊂,8834
232 | sup,⊃,8835
233 | nsub,⊄,8836
234 | sube,⊆,8838
235 | supe,⊇,8839
236 | oplus,⊕,8853
237 | otimes,⊗,8855
238 | perp,⊥,8869
239 | sdot,⋅,8901
240 | lceil,⌈,8968
241 | rceil,⌉,8969
242 | lfloor,⌊,8970
243 | rfloor,⌋,8971
244 | lang,〈,9001
245 | rang,〉,9002
246 | loz,◊,9674
247 | spades,♠,9824
248 | clubs,♣,9827
249 | hearts,♥,9829
250 | diams,♦,9830
251 |
--------------------------------------------------------------------------------
/lib/address.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Address do
2 | @moduledoc """
3 | You can create an address for a customer only although the structure
4 | is also used for a merchant account.
5 |
6 | For additional reference see:
7 | https://developers.braintreepayments.com/reference/request/address/create/ruby
8 | """
9 |
10 | use Braintree.Construction
11 |
12 | alias Braintree.HTTP
13 |
14 | @type t :: %__MODULE__{
15 | id: String.t(),
16 | company: String.t(),
17 | created_at: String.t(),
18 | updated_at: String.t(),
19 | first_name: String.t(),
20 | last_name: String.t(),
21 | locality: String.t(),
22 | postal_code: String.t(),
23 | region: String.t(),
24 | street_address: String.t(),
25 | country_code_alpha2: String.t(),
26 | country_code_alpha3: String.t(),
27 | country_code_numeric: String.t(),
28 | country_name: String.t(),
29 | customer_id: String.t(),
30 | extended_address: String.t()
31 | }
32 |
33 | defstruct id: nil,
34 | company: nil,
35 | created_at: nil,
36 | updated_at: nil,
37 | first_name: nil,
38 | last_name: nil,
39 | locality: nil,
40 | postal_code: nil,
41 | region: nil,
42 | street_address: nil,
43 | country_code_alpha2: nil,
44 | country_code_alpha3: nil,
45 | country_code_numeric: nil,
46 | country_name: nil,
47 | customer_id: nil,
48 | extended_address: nil
49 |
50 | @doc """
51 | Create an address record, or return an error response after failed validation.
52 |
53 | ## Example
54 |
55 | {:ok, address} = Braintree.Address.create("customer_id", %{
56 | first_name: "Jenna"
57 | })
58 |
59 | address.company # Braintree
60 | """
61 | @spec create(binary, map, Keyword.t()) :: {:ok, t} | HTTP.error()
62 | def create(customer_id, params \\ %{}, opts \\ []) when is_binary(customer_id) do
63 | with {:ok, payload} <-
64 | HTTP.post("customers/#{customer_id}/addresses/", %{address: params}, opts) do
65 | {:ok, new(payload)}
66 | end
67 | end
68 |
69 | @doc """
70 | You can delete an address using its customer ID and address ID.
71 |
72 | ## Example
73 |
74 | :ok = Braintree.Address.delete("customer_id", "address_id")
75 | """
76 | @spec delete(binary, binary, Keyword.t()) :: :ok | HTTP.error()
77 | def delete(customer_id, id, opts \\ []) when is_binary(customer_id) and is_binary(id) do
78 | with {:ok, _reponse} <- HTTP.delete("customers/#{customer_id}/addresses/" <> id, opts) do
79 | :ok
80 | end
81 | end
82 |
83 | @doc """
84 | To update an address, use a customer's ID with an address's ID along with
85 | new attributes. The same validations apply as when creating an address.
86 | Any attribute not passed will remain unchanged.
87 |
88 | ## Example
89 |
90 | {:ok, address} = Braintree.Address.update("customer_id", "address_id", %{
91 | company: "New Company Name"
92 | })
93 |
94 | address.company # "New Company Name"
95 | """
96 | @spec update(binary, binary, map, Keyword.t()) :: {:ok, t} | HTTP.error()
97 | def update(customer_id, id, params, opts \\ []) when is_binary(customer_id) and is_binary(id) do
98 | with {:ok, payload} <-
99 | HTTP.put("customers/#{customer_id}/addresses/" <> id, %{address: params}, opts) do
100 | {:ok, new(payload)}
101 | end
102 | end
103 |
104 | @doc """
105 | If you want to look up a single address for a customer using the customer ID and
106 | the address ID, use the find method.
107 |
108 | ## Example
109 |
110 | address = Braintree.Address.find("customer_id", "address_id")
111 | """
112 | @spec find(binary, binary, Keyword.t()) :: {:ok, t} | HTTP.error()
113 | def find(customer_id, id, opts \\ []) when is_binary(customer_id) and is_binary(id) do
114 | with {:ok, payload} <- HTTP.get("customers/#{customer_id}/addresses/" <> id, opts) do
115 | {:ok, new(payload)}
116 | end
117 | end
118 |
119 | @doc """
120 | Convert a map into a Address struct.
121 |
122 | ## Example
123 |
124 | address = Braintree.Address.new(%{"company" => "Braintree"})
125 | """
126 | def new(%{"address" => map}), do: super(map)
127 | end
128 |
--------------------------------------------------------------------------------
/lib/plan.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Plan do
2 | @moduledoc """
3 | Plans represent recurring billing plans in a Braintree merchant account.
4 | The API for plans is read only.
5 |
6 | For additional reference see:
7 | https://developers.braintreepayments.com/reference/request/plan/all/ruby
8 | """
9 |
10 | use Braintree.Construction
11 |
12 | alias Braintree.HTTP
13 |
14 | @type t :: %__MODULE__{
15 | id: String.t(),
16 | add_ons: [any],
17 | balance: String.t(),
18 | billing_day_of_month: String.t(),
19 | billing_frequency: String.t(),
20 | created_at: String.t(),
21 | currency_iso_code: String.t(),
22 | description: String.t(),
23 | discounts: [any],
24 | name: String.t(),
25 | number_of_billing_cycles: String.t(),
26 | price: String.t(),
27 | trial_duration: String.t(),
28 | trial_duration_unit: String.t(),
29 | trial_period: String.t(),
30 | updated_at: String.t()
31 | }
32 |
33 | defstruct id: nil,
34 | add_ons: [],
35 | balance: nil,
36 | billing_day_of_month: nil,
37 | billing_frequency: nil,
38 | created_at: nil,
39 | currency_iso_code: nil,
40 | description: nil,
41 | discounts: [],
42 | name: nil,
43 | number_of_billing_cycles: nil,
44 | price: nil,
45 | trial_duration: nil,
46 | trial_duration_unit: nil,
47 | trial_period: nil,
48 | updated_at: nil
49 |
50 | @doc """
51 | Get a list of all the plans defined in the merchant account. If there are
52 | no plans an empty list is returned.
53 |
54 | ## Example
55 |
56 | {:ok, plans} = Braintree.Plan.all()
57 | """
58 | @spec all(Keyword.t()) :: {:ok, [t]} | HTTP.error()
59 | def all(opts \\ []) do
60 | with {:ok, %{"plans" => plans}} <- HTTP.get("plans", opts) do
61 | {:ok, new(plans)}
62 | end
63 | end
64 |
65 | @doc """
66 | Create a new plan under a merchant account.
67 |
68 | ## Example
69 |
70 | {:ok, plan} = Braintree.Plan.create(%{
71 | name: "a plan",
72 | billing_frequency: 3,
73 | currency_iso_code: "USD",
74 | price: "10.00"
75 | })
76 | """
77 | @spec create(map, Keyword.t()) :: {:ok, t} | HTTP.error()
78 | def create(params, opts \\ []) do
79 | with {:ok, %{"plan" => plan}} <- HTTP.post("plans", %{plan: params}, opts) do
80 | {:ok, new(plan)}
81 | end
82 | end
83 |
84 | @doc """
85 | Get a specific plan defined in the merchant account by the plan id. If there is
86 | no plan with the specified id, `{:error, :not_found}` is returned.
87 |
88 | ## Example
89 |
90 | {:ok, plan} = Braintree.Plan.find("existing plan_id")
91 |
92 | {:error, :not_found} = Braintree.Plan.find("non-existing plan_id")
93 | """
94 | @spec find(String.t(), Keyword.t()) :: {:ok, t} | HTTP.error()
95 | def find(id, opts \\ []) when is_binary(id) do
96 | with {:ok, %{"plan" => plan}} <- HTTP.get("plans/#{id}", opts) do
97 | {:ok, new(plan)}
98 | end
99 | end
100 |
101 | @doc """
102 | Updates a specific plan defined in the merchant account by the plan id. If there is
103 | no plan with the specified id, `{:error, :not_found}` is returned.
104 |
105 | ## Example
106 |
107 | {:ok, updated_plan} = Braintree.Plan.find("existing plan_id", %{name: "new_name"})
108 |
109 | {:error, :not_found} = Braintree.Plan.find("non-existing plan_id")
110 | """
111 | @spec update(String.t(), map, Keyword.t()) :: {:ok, t} | HTTP.error()
112 | def update(id, params, opts \\ []) when is_binary(id) and is_map(params) do
113 | with {:ok, %{"plan" => plan}} <- HTTP.put("plans/#{id}", %{plan: params}, opts) do
114 | {:ok, new(plan)}
115 | end
116 | end
117 |
118 | @doc """
119 | Delete a plan defined in the merchant account by the plan id.
120 | A plan can't be deleted if it has any former or current subscriptions associated with it.
121 | If there is no plan with the specified id, `{:error, :not_found}` is returned.
122 | """
123 | @spec delete(String.t(), Keyword.t()) :: :ok | HTTP.error()
124 | def delete(id, opts \\ []) when is_binary(id) do
125 | with {:ok, _response} <- HTTP.delete("plans/#{id}", opts) do
126 | :ok
127 | end
128 | end
129 | end
130 |
--------------------------------------------------------------------------------
/lib/payment_method.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.PaymentMethod do
2 | @moduledoc """
3 | Create, update, find and delete payment methods. Payment methods
4 | may be a `CreditCard` or a `PaypalAccount`.
5 | """
6 |
7 | alias Braintree.{
8 | AndroidPayCard,
9 | ApplePayCard,
10 | CreditCard,
11 | HTTP,
12 | PaypalAccount,
13 | UsBankAccount,
14 | VenmoAccount
15 | }
16 |
17 | @doc """
18 | Create a payment method record, or return an error response with after failed
19 | validation.
20 |
21 | ## Example
22 |
23 | {:ok, customer} = Braintree.Customer.create(%{
24 | first_name: "Jen",
25 | last_name: "Smith"
26 | })
27 |
28 | {:ok, credit_card} = Braintree.PaymentMethod.create(%{
29 | customer_id: customer.id,
30 | payment_method_nonce: Braintree.Testing.Nonces.transactable
31 | })
32 |
33 | credit_card.type # "Visa"
34 | """
35 | @spec create(map, Keyword.t()) ::
36 | {:ok, CreditCard.t()}
37 | | {:ok, PaypalAccount.t()}
38 | | {:ok, UsBankAccount.t()}
39 | | {:ok, VenmoAccount.t()}
40 | | HTTP.error()
41 | def create(params \\ %{}, opts \\ []) do
42 | with {:ok, payload} <- HTTP.post("payment_methods", %{payment_method: params}, opts) do
43 | {:ok, new(payload)}
44 | end
45 | end
46 |
47 | @doc """
48 | Update a payment method record, or return an error response with after failed
49 | validation.
50 |
51 | ## Example
52 |
53 | {:ok, customer} = Braintree.Customer.create(%{
54 | first_name: "Jen",
55 | last_name: "Smith"
56 | })
57 |
58 | {:ok, credit_card} = Braintree.PaymentMethod.create(%{
59 | customer_id: customer.id,
60 | cardholder_name: "CH Name",
61 | payment_method_nonce: Braintree.Testing.Nonces.transactable
62 | })
63 |
64 | {:ok, payment_method} = Braintree.PaymentMethod.update(
65 | credit_card.token,
66 | %{cardholder_name: "NEW"}
67 | )
68 |
69 | payment_method.cardholder_name # "NEW"
70 | """
71 | @spec update(String.t(), map, Keyword.t()) ::
72 | {:ok, CreditCard.t()} | {:ok, PaypalAccount.t()} | HTTP.error()
73 | def update(token, params \\ %{}, opts \\ []) do
74 | path = "payment_methods/any/" <> token
75 |
76 | with {:ok, payload} <- HTTP.put(path, %{payment_method: params}, opts) do
77 | {:ok, new(payload)}
78 | end
79 | end
80 |
81 | @doc """
82 | Delete a payment method record, or return an error response if token invalid
83 |
84 | ## Example
85 |
86 | {:ok, "Success"} = Braintree.PaymentMethod.delete(token)
87 | """
88 | @spec delete(String.t(), Keyword.t()) :: :ok | HTTP.error()
89 | def delete(token, opts \\ []) do
90 | path = "payment_methods/any/" <> token
91 |
92 | with {:ok, _response} <- HTTP.delete(path, opts) do
93 | :ok
94 | end
95 | end
96 |
97 | @doc """
98 | Find a payment method record, or return an error response if token invalid
99 |
100 | ## Example
101 |
102 | {:ok, payment_method} = Braintree.PaymentMethod.find(token)
103 |
104 | payment_method.type # CreditCard
105 | """
106 | @spec find(String.t(), Keyword.t()) ::
107 | {:ok, CreditCard.t()}
108 | | {:ok, PaypalAccount.t()}
109 | | {:ok, UsBankAccount.t()}
110 | | HTTP.error()
111 | def find(token, opts \\ []) do
112 | path = "payment_methods/any/" <> token
113 |
114 | with {:ok, payload} <- HTTP.get(path, opts) do
115 | {:ok, new(payload)}
116 | end
117 | end
118 |
119 | @spec new(map) ::
120 | AndroidPayCard.t()
121 | | ApplePayCard.t()
122 | | CreditCard.t()
123 | | PaypalAccount.t()
124 | | UsBankAccount.t()
125 | defp new(%{"android_pay_card" => android_pay_card}) do
126 | AndroidPayCard.new(android_pay_card)
127 | end
128 |
129 | defp new(%{"apple_pay_card" => apple_pay_card}) do
130 | ApplePayCard.new(apple_pay_card)
131 | end
132 |
133 | defp new(%{"credit_card" => credit_card}) do
134 | CreditCard.new(credit_card)
135 | end
136 |
137 | defp new(%{"paypal_account" => paypal_account}) do
138 | PaypalAccount.new(paypal_account)
139 | end
140 |
141 | defp new(%{"us_bank_account" => us_bank_account}) do
142 | UsBankAccount.new(us_bank_account)
143 | end
144 |
145 | defp new(%{"venmo_account" => venmo_account}) do
146 | VenmoAccount.new(venmo_account)
147 | end
148 | end
149 |
--------------------------------------------------------------------------------
/test/integration/address_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Integration.AddressTest do
2 | use ExUnit.Case, async: true
3 |
4 | @moduletag :integration
5 |
6 | alias Braintree.Address
7 |
8 | setup do
9 | {:ok, customer} =
10 | Braintree.Customer.create(%{
11 | first_name: "Test",
12 | last_name: "User"
13 | })
14 |
15 | {:ok, customer: customer}
16 | end
17 |
18 | describe "create/2" do
19 | test "fails without any params", %{customer: customer} do
20 | assert {:error, _address} = Address.create(customer.id)
21 | end
22 |
23 | test "succeeds with valid params", %{customer: customer} do
24 | {:ok, address} =
25 | Address.create(customer.id, %{
26 | first_name: "Jenna",
27 | last_name: "Smith",
28 | company: "Braintree",
29 | street_address: "1 E Main St",
30 | extended_address: "Suite 403",
31 | locality: "Chicago",
32 | region: "Illinois",
33 | postal_code: "60622",
34 | country_code_alpha2: "US"
35 | })
36 |
37 | assert address.customer_id == customer.id
38 | assert address.country_name == "United States of America"
39 | assert address.first_name == "Jenna"
40 | assert address.last_name == "Smith"
41 | assert address.company == "Braintree"
42 | assert address.street_address == "1 E Main St"
43 | assert address.extended_address == "Suite 403"
44 | assert address.locality == "Chicago"
45 | assert address.region == "Illinois"
46 | assert address.postal_code == "60622"
47 | end
48 |
49 | test "with invalid customer id" do
50 | assert {:error, :not_found} =
51 | Address.create("invalid-customer", %{first_name: "Jenna", last_name: "Smith"})
52 | end
53 | end
54 |
55 | describe "delete/2" do
56 | test "deletes an existing address", %{customer: customer} do
57 | {:ok, address} = Address.create(customer.id, %{first_name: "Jenna"})
58 |
59 | assert :ok = Address.delete(customer.id, address.id)
60 | end
61 |
62 | test "returns not found for invalid address id", %{customer: customer} do
63 | assert {:error, :not_found} = Address.delete(customer.id, "invalid-address")
64 | end
65 |
66 | test "returns not found for invalid customer id", %{customer: customer} do
67 | {:ok, address} = Address.create(customer.id, %{first_name: "Jenna"})
68 |
69 | assert {:error, :not_found} = Address.delete("invalid-customer", address.id)
70 | end
71 | end
72 |
73 | describe "update/3" do
74 | test "updates/3 with valid params", %{customer: customer} do
75 | {:ok, address} = Address.create(customer.id, %{first_name: "Jenna"})
76 |
77 | assert {:ok, address} = Address.update(customer.id, address.id, %{last_name: "Smith"})
78 |
79 | assert address.customer_id == customer.id
80 | assert address.first_name == "Jenna"
81 | assert address.last_name == "Smith"
82 | end
83 |
84 | test "updates/3 an address that does not exist", %{customer: customer} do
85 | assert {:error, :not_found} =
86 | Address.update(customer.id, "invalid-address", %{last_name: "Smith"})
87 | end
88 |
89 | test "updates/3 an existing address with invalid params", %{customer: customer} do
90 | {:ok, address} = Address.create(customer.id, %{first_name: "Jenna"})
91 |
92 | assert {:error, error} =
93 | Address.update(customer.id, address.id, %{
94 | country_code_numeric: "invalid country code"
95 | })
96 |
97 | assert error.message == "Country code (numeric) is not an accepted country."
98 | end
99 | end
100 |
101 | describe "find/2" do
102 | test "retrieves an existing address", %{customer: customer} do
103 | {:ok, address} = Address.create(customer.id, %{first_name: "Jenna"})
104 | assert {:ok, address} = Address.find(customer.id, address.id)
105 |
106 | assert address.first_name == "Jenna"
107 | end
108 |
109 | test "returns not found if address does not exist", %{customer: customer} do
110 | assert {:error, :not_found} = Address.find(customer.id, "invalid-address")
111 | end
112 |
113 | test "returns not found if customer does not exist", %{customer: customer} do
114 | {:ok, address} = Address.create(customer.id, %{first_name: "Jenna"})
115 | assert {:error, :not_found} = Address.find("invalid-customer", address.id)
116 | end
117 | end
118 | end
119 |
--------------------------------------------------------------------------------
/test/integration/customer_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Integration.CustomerTest do
2 | use ExUnit.Case, async: true
3 |
4 | @moduletag :integration
5 |
6 | alias Braintree.Customer
7 | alias Braintree.Testing.CreditCardNumbers
8 | alias Braintree.Testing.CreditCardNumbers.FailsSandboxVerification
9 |
10 | describe "create/1" do
11 | test "without any params" do
12 | assert {:ok, _customer} = Customer.create()
13 | end
14 |
15 | test "create/1 with valid params" do
16 | {:ok, customer} =
17 | Customer.create(%{
18 | first_name: "Bill",
19 | last_name: "Gates",
20 | company: "Microsoft",
21 | email: "bill@microsoft.com",
22 | phone: "312.555.1234",
23 | website: "www.microsoft.com"
24 | })
25 |
26 | assert customer.id =~ ~r/^\d+$/
27 | assert customer.first_name == "Bill"
28 | assert customer.last_name == "Gates"
29 | assert customer.company == "Microsoft"
30 | assert customer.email == "bill@microsoft.com"
31 | assert customer.phone == "312.555.1234"
32 | assert customer.website == "www.microsoft.com"
33 | assert customer.created_at
34 | assert customer.updated_at
35 | end
36 |
37 | test "with a credit card" do
38 | {:ok, customer} =
39 | Customer.create(%{
40 | first_name: "Parker",
41 | last_name: "Selbert",
42 | credit_card: %{
43 | number: master_card(),
44 | expiration_date: "01/2016",
45 | cvv: "100"
46 | }
47 | })
48 |
49 | assert customer.first_name == "Parker"
50 | assert customer.last_name == "Selbert"
51 |
52 | [card] = customer.credit_cards
53 |
54 | assert card.bin == String.slice(master_card(), 0..5)
55 | assert card.last_4 == String.slice(master_card(), -4..-1)
56 | assert card.expiration_month == "01"
57 | assert card.expiration_year == "2016"
58 | assert card.unique_number_identifier =~ ~r/\A\w{32}\z/
59 | end
60 |
61 | test "with card verification" do
62 | {:error, error} =
63 | Customer.create(%{
64 | first_name: "Parker",
65 | last_name: "Selbert",
66 | credit_card: %{
67 | number: FailsSandboxVerification.master_card(),
68 | expiration_date: "01/2020",
69 | options: %{verify_card: true}
70 | }
71 | })
72 |
73 | assert error.message =~ ~r/do not honor/i
74 | end
75 | end
76 |
77 | describe "find/1" do
78 | test "retrieves an existing customer" do
79 | {:ok, original} = Customer.create(%{first_name: "Parker"})
80 | {:ok, customer} = Customer.find(original.id)
81 |
82 | assert customer.first_name == "Parker"
83 | end
84 |
85 | test "returns a not found error" do
86 | assert {:error, :not_found} = Customer.find("fakecustomerid")
87 | end
88 | end
89 |
90 | describe "update/2" do
91 | test "updates an existing customer" do
92 | {:ok, original} = Customer.create(%{first_name: "Parker"})
93 | {:ok, customer} = Customer.update(original.id, %{first_name: "Rekrap"})
94 |
95 | assert customer.first_name == "Rekrap"
96 | end
97 |
98 | test "exposes an error when updating fails" do
99 | invalid_company = repeatedly("a", 300)
100 |
101 | {:ok, customer} = Customer.create()
102 | {:error, error} = Customer.update(customer.id, %{company: invalid_company})
103 |
104 | assert error.message =~ ~r/company is too long/i
105 | end
106 | end
107 |
108 | describe "delete/1" do
109 | test "removes an existing customer" do
110 | {:ok, customer} = Customer.create()
111 |
112 | assert :ok = Customer.delete(customer.id)
113 | end
114 | end
115 |
116 | describe "search/1" do
117 | test "with valid params" do
118 | {:ok, _customer} =
119 | Customer.create(%{
120 | first_name: "Jenna",
121 | last_name: "Smith"
122 | })
123 |
124 | search_params = %{
125 | first_name: %{is: "Jenna"},
126 | last_name: %{
127 | starts_with: "Smith",
128 | contains: "ith",
129 | is_not: "Smithsonian"
130 | }
131 | }
132 |
133 | {:ok, [%Customer{} = customer | _]} = Customer.search(search_params)
134 |
135 | assert customer.first_name == "Jenna"
136 | assert customer.last_name == "Smith"
137 | end
138 |
139 | test "returns not found if no result" do
140 | assert {:error, :not_found} = Customer.search(%{first_name: %{is: "Mickael"}})
141 | end
142 |
143 | test "returns server error for invalid search params" do
144 | assert {:error, :server_error} = Customer.search(%{})
145 | end
146 | end
147 |
148 | defp master_card do
149 | CreditCardNumbers.master_cards() |> List.first()
150 | end
151 |
152 | defp repeatedly(string, len) do
153 | fun = fn -> string end
154 |
155 | fun
156 | |> Stream.repeatedly()
157 | |> Enum.take(len)
158 | |> Enum.join()
159 | end
160 | end
161 |
--------------------------------------------------------------------------------
/lib/customer.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Customer do
2 | @moduledoc """
3 | You can create a customer by itself, with a payment method, or with a
4 | credit card with a billing address.
5 |
6 | For additional reference see:
7 | https://developers.braintreepayments.com/reference/request/customer/create/ruby
8 | """
9 |
10 | use Braintree.Construction
11 |
12 | alias Braintree.{
13 | AndroidPayCard,
14 | ApplePayCard,
15 | CreditCard,
16 | HTTP,
17 | PaypalAccount,
18 | Search,
19 | UsBankAccount
20 | }
21 |
22 | @type t :: %__MODULE__{
23 | id: String.t(),
24 | company: String.t(),
25 | email: String.t(),
26 | fax: String.t(),
27 | first_name: String.t(),
28 | last_name: String.t(),
29 | phone: String.t(),
30 | website: String.t(),
31 | created_at: String.t(),
32 | updated_at: String.t(),
33 | custom_fields: map,
34 | addresses: [map],
35 | android_pay_cards: [AndroidPayCard.t()],
36 | apple_pay_cards: [ApplePayCard.t()],
37 | credit_cards: [CreditCard.t()],
38 | paypal_accounts: [PaypalAccount.t()],
39 | coinbase_accounts: [map],
40 | us_bank_accounts: [UsBankAccount.t()]
41 | }
42 |
43 | defstruct id: nil,
44 | company: nil,
45 | email: nil,
46 | fax: nil,
47 | first_name: nil,
48 | last_name: nil,
49 | phone: nil,
50 | website: nil,
51 | created_at: nil,
52 | updated_at: nil,
53 | custom_fields: %{},
54 | addresses: [],
55 | android_pay_cards: [],
56 | apple_pay_cards: [],
57 | credit_cards: [],
58 | coinbase_accounts: [],
59 | paypal_accounts: [],
60 | us_bank_accounts: []
61 |
62 | @doc """
63 | Create a customer record, or return an error response with after failed
64 | validation.
65 |
66 | ## Example
67 |
68 | {:ok, customer} = Braintree.Customer.create(%{
69 | first_name: "Jen",
70 | last_name: "Smith",
71 | company: "Braintree",
72 | email: "jen@example.com",
73 | phone: "312.555.1234",
74 | fax: "614.555.5678",
75 | website: "www.example.com"
76 | })
77 |
78 | customer.company # Braintree
79 | """
80 | @spec create(map, Keyword.t()) :: {:ok, t} | HTTP.error()
81 | def create(params \\ %{}, opts \\ []) do
82 | with {:ok, payload} <- HTTP.post("customers", %{customer: params}, opts) do
83 | {:ok, new(payload)}
84 | end
85 | end
86 |
87 | @doc """
88 | You can delete a customer using its ID. When a customer is deleted, all
89 | associated payment methods are also deleted, and all associated recurring
90 | billing subscriptions are canceled.
91 |
92 | ## Example
93 |
94 | :ok = Braintree.Customer.delete("customer_id")
95 | """
96 | @spec delete(binary, Keyword.t()) :: :ok | HTTP.error()
97 | def delete(id, opts \\ []) when is_binary(id) do
98 | with {:ok, _response} <- HTTP.delete("customers/" <> id, opts) do
99 | :ok
100 | end
101 | end
102 |
103 | @doc """
104 | If you want to look up a single customer using its ID, use the find method.
105 |
106 | ## Example
107 |
108 | customer = Braintree.Customer.find("customer_id")
109 | """
110 | @spec find(binary, Keyword.t()) :: {:ok, t} | HTTP.error()
111 | def find(id, opts \\ []) when is_binary(id) do
112 | with {:ok, payload} <- HTTP.get("customers/" <> id, opts) do
113 | {:ok, new(payload)}
114 | end
115 | end
116 |
117 | @doc """
118 | To update a customer, use its ID along with new attributes. The same
119 | validations apply as when creating a customer. Any attribute not passed will
120 | remain unchanged.
121 |
122 | ## Example
123 |
124 | {:ok, customer} = Braintree.Customer.update("customer_id", %{
125 | company: "New Company Name"
126 | })
127 |
128 | customer.company # "New Company Name"
129 | """
130 | @spec update(binary, map, Keyword.t()) :: {:ok, t} | HTTP.error()
131 | def update(id, params, opts \\ []) when is_binary(id) and is_map(params) do
132 | with {:ok, payload} <- HTTP.put("customers/" <> id, %{customer: params}, opts) do
133 | {:ok, new(payload)}
134 | end
135 | end
136 |
137 | @doc """
138 | To search for customers, pass a map of search parameters.
139 |
140 |
141 | ## Example:
142 |
143 | {:ok, customers} = Braintree.Customer.search(%{first_name: %{is: "Jenna"}})
144 | """
145 | @spec search(map, Keyword.t()) :: {:ok, t} | HTTP.error()
146 | def search(params, opts \\ []) when is_map(params) do
147 | Search.perform(params, "customers", &new/1, opts)
148 | end
149 |
150 | @doc """
151 | Convert a map into a Company struct along with nested payment options. Credit
152 | cards and paypal accounts are converted to a list of structs as well.
153 |
154 | ## Example
155 |
156 | customer = Braintree.Customer.new(%{"company" => "Soren",
157 | "email" => "parker@example.com"})
158 | """
159 | def new(%{"customer" => map}) do
160 | new(map)
161 | end
162 |
163 | def new(map) when is_map(map) do
164 | customer = super(map)
165 |
166 | %{
167 | customer
168 | | android_pay_cards: AndroidPayCard.new(customer.android_pay_cards),
169 | apple_pay_cards: ApplePayCard.new(customer.apple_pay_cards),
170 | credit_cards: CreditCard.new(customer.credit_cards),
171 | paypal_accounts: PaypalAccount.new(customer.paypal_accounts),
172 | us_bank_accounts: UsBankAccount.new(customer.us_bank_accounts)
173 | }
174 | end
175 |
176 | def new(list) when is_list(list) do
177 | Enum.map(list, &new/1)
178 | end
179 | end
180 |
--------------------------------------------------------------------------------
/test/customer_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.CustomerTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Braintree.Customer
5 |
6 | test "all customer attributes are included" do
7 | customer = %Customer{
8 | company: "Soren",
9 | email: "parker@example.com",
10 | first_name: "Parker",
11 | last_name: "Selbert"
12 | }
13 |
14 | assert customer.id == nil
15 | assert customer.company == "Soren"
16 | assert customer.email == "parker@example.com"
17 | assert customer.first_name == "Parker"
18 | assert customer.last_name == "Selbert"
19 |
20 | assert customer.addresses == []
21 | assert customer.credit_cards == []
22 | assert customer.paypal_accounts == []
23 | end
24 |
25 | test "new/1 converts nested payment methods to a list of known structs" do
26 | customer =
27 | Customer.new(%{
28 | "company" => "Soren",
29 | "email" => "parker@example.com",
30 | "android_pay_cards" => [
31 | %{
32 | "bin" => "401288",
33 | "created_at" => "2020-08-13T22:13:55Z",
34 | "customer_id" => "225954129",
35 | "default" => false,
36 | "expiration_month" => "12",
37 | "expiration_year" => "2020",
38 | "google_transaction_id" => "123",
39 | "image_url" =>
40 | "https://assets.braintreegateway.com/payment_method_logo/apple_pay.png?environment=sandbox",
41 | "is_network_tokenized" => false,
42 | "source_card_last_4" => "1881",
43 | "source_card_type" => "Android Pay - Visa",
44 | "source_description" => "Visa 8886",
45 | "subscriptions" => [],
46 | "token" => "mrt2ptb",
47 | "updated_at" => "2020-08-13T22:13:55Z",
48 | "virtual_card_last_4" => nil,
49 | "virtual_card_type" => nil
50 | }
51 | ],
52 | "apple_pay_cards" => [
53 | %{
54 | "billing_address" => %{"postal_code" => "222222"},
55 | "bin" => "401288",
56 | "card_type" => "Apple Pay - Visa",
57 | "cardholder_name" => "Visa Apple Pay Cardholder",
58 | "created_at" => "2020-08-13T22:13:55Z",
59 | "customer_global_id" => "Y3VzdG9tZXJfMjI1OTU0MTI5",
60 | "customer_id" => "225954129",
61 | "default" => false,
62 | "expiration_month" => "12",
63 | "expiration_year" => "2020",
64 | "expired" => false,
65 | "global_id" => "cGF5bWVudG1ldGhvZF9hcHBsZV9tcnQycHRi",
66 | "image_url" =>
67 | "https://assets.braintreegateway.com/payment_method_logo/apple_pay.png?environment=sandbox",
68 | "last_4" => "1881",
69 | "payment_instrument_name" => "Visa 8886",
70 | "source_description" => "Visa 8886",
71 | "subscriptions" => [],
72 | "token" => "mrt2ptb",
73 | "updated_at" => "2020-08-13T22:13:55Z"
74 | }
75 | ],
76 | "credit_cards" => [
77 | %{
78 | "bin" => "12345",
79 | "card_type" => "Visa"
80 | }
81 | ],
82 | "paypal_accounts" => [
83 | %{
84 | "email" => "parker@example.com",
85 | "token" => "t0k3n"
86 | }
87 | ]
88 | })
89 |
90 | assert Enum.any?(customer.android_pay_cards)
91 | assert Enum.any?(customer.apple_pay_cards)
92 | assert Enum.any?(customer.credit_cards)
93 | assert Enum.any?(customer.paypal_accounts)
94 |
95 | [android_pay_card] = customer.android_pay_cards
96 |
97 | assert android_pay_card == %Braintree.AndroidPayCard{
98 | bin: "401288",
99 | created_at: "2020-08-13T22:13:55Z",
100 | customer_id: "225954129",
101 | default: false,
102 | expiration_month: "12",
103 | expiration_year: "2020",
104 | google_transaction_id: "123",
105 | image_url:
106 | "https://assets.braintreegateway.com/payment_method_logo/apple_pay.png?environment=sandbox",
107 | is_network_tokenized: false,
108 | source_card_last_4: "1881",
109 | source_card_type: "Android Pay - Visa",
110 | source_description: "Visa 8886",
111 | subscriptions: [],
112 | token: "mrt2ptb",
113 | updated_at: "2020-08-13T22:13:55Z",
114 | virtual_card_last_4: nil,
115 | virtual_card_type: nil
116 | }
117 |
118 | [apple_pay_card] = customer.apple_pay_cards
119 |
120 | assert apple_pay_card == %Braintree.ApplePayCard{
121 | billing_address: %{postal_code: "222222"},
122 | bin: "401288",
123 | card_type: "Apple Pay - Visa",
124 | cardholder_name: "Visa Apple Pay Cardholder",
125 | created_at: "2020-08-13T22:13:55Z",
126 | customer_id: "225954129",
127 | default: false,
128 | expiration_month: "12",
129 | expiration_year: "2020",
130 | expired: false,
131 | image_url:
132 | "https://assets.braintreegateway.com/payment_method_logo/apple_pay.png?environment=sandbox",
133 | last_4: "1881",
134 | payment_instrument_name: "Visa 8886",
135 | source_description: "Visa 8886",
136 | subscriptions: [],
137 | token: "mrt2ptb",
138 | updated_at: "2020-08-13T22:13:55Z"
139 | }
140 |
141 | [card] = customer.credit_cards
142 |
143 | assert card.bin == "12345"
144 | assert card.card_type == "Visa"
145 |
146 | [paypal] = customer.paypal_accounts
147 |
148 | assert paypal.email == "parker@example.com"
149 | assert paypal.token == "t0k3n"
150 | end
151 | end
152 |
--------------------------------------------------------------------------------
/lib/subscription.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Subscription do
2 | @moduledoc """
3 | Manage customer subscriptions to recurring billing plans.
4 |
5 | For additional reference see:
6 | https://developers.braintreepayments.com/reference/request/subscription/create/ruby
7 | """
8 |
9 | use Braintree.Construction
10 |
11 | alias Braintree.{AddOn, HTTP, Search, Transaction}
12 |
13 | @type t :: %__MODULE__{
14 | id: String.t(),
15 | plan_id: String.t(),
16 | balance: String.t(),
17 | billing_day_of_month: String.t(),
18 | billing_period_end_date: String.t(),
19 | billing_period_start_date: String.t(),
20 | created_at: String.t(),
21 | current_billing_cycle: String.t(),
22 | days_past_due: String.t(),
23 | descriptor: String.t(),
24 | failure_count: String.t(),
25 | first_billing_date: String.t(),
26 | merchant_account_id: String.t(),
27 | never_expires: String.t(),
28 | next_bill_amount: String.t(),
29 | next_billing_date: String.t(),
30 | next_billing_period_amount: String.t(),
31 | number_of_billing_cycles: String.t(),
32 | paid_through_date: String.t(),
33 | payment_method_token: String.t(),
34 | price: String.t(),
35 | status: String.t(),
36 | trial_duration: String.t(),
37 | trial_duration_unit: String.t(),
38 | trial_period: String.t(),
39 | updated_at: String.t(),
40 | add_ons: [AddOn.t()],
41 | discounts: [any],
42 | transactions: [Transaction.t()],
43 | status_history: [any]
44 | }
45 |
46 | defstruct id: nil,
47 | plan_id: nil,
48 | balance: nil,
49 | billing_day_of_month: nil,
50 | billing_period_end_date: nil,
51 | billing_period_start_date: nil,
52 | created_at: nil,
53 | current_billing_cycle: nil,
54 | days_past_due: nil,
55 | descriptor: nil,
56 | failure_count: nil,
57 | first_billing_date: nil,
58 | merchant_account_id: nil,
59 | never_expires: nil,
60 | next_bill_amount: nil,
61 | next_billing_date: nil,
62 | next_billing_period_amount: nil,
63 | number_of_billing_cycles: nil,
64 | paid_through_date: nil,
65 | payment_method_token: nil,
66 | price: nil,
67 | status: nil,
68 | trial_duration: nil,
69 | trial_duration_unit: nil,
70 | trial_period: nil,
71 | updated_at: nil,
72 | add_ons: [],
73 | discounts: [],
74 | transactions: [],
75 | status_history: []
76 |
77 | @doc """
78 | Create a subscription, or return an error response with after failed
79 | validation.
80 |
81 | ## Example
82 |
83 | {:ok, sub} = Braintree.Subscription.create(%{
84 | payment_method_token: card.token,
85 | plan_id: "starter"
86 | })
87 | """
88 | @spec create(map, Keyword.t()) :: {:ok, t} | HTTP.error()
89 | def create(params \\ %{}, opts \\ []) do
90 | with {:ok, payload} <- HTTP.post("subscriptions", %{subscription: params}, opts) do
91 | {:ok, new(payload)}
92 | end
93 | end
94 |
95 | @doc """
96 | Find an existing subscription by `subscription_id`
97 |
98 | ## Example
99 |
100 | {:ok, subscription} = Subscription.find("123")
101 | """
102 | @spec find(String.t(), Keyword.t()) :: {:ok, t} | HTTP.error()
103 | def find(subscription_id, opts \\ []) do
104 | with {:ok, payload} <- HTTP.get("subscriptions/#{subscription_id}", opts) do
105 | {:ok, new(payload)}
106 | end
107 | end
108 |
109 | @doc """
110 | Cancel an existing subscription by `subscription_id`. A cancelled subscription
111 | cannot be reactivated, you would need to create a new one.
112 |
113 | ## Example
114 |
115 | {:ok, subscription} = Subscription.cancel("123")
116 | """
117 | @spec cancel(String.t(), Keyword.t()) :: {:ok, t} | HTTP.error()
118 | def cancel(subscription_id, opts \\ []) do
119 | with {:ok, payload} <- HTTP.put("subscriptions/#{subscription_id}/cancel", opts) do
120 | {:ok, new(payload)}
121 | end
122 | end
123 |
124 | @doc """
125 | You can manually retry charging past due subscriptions.
126 |
127 | By default, we will use the subscription balance when retrying the
128 | transaction. If you would like to use a different amount you can optionally
129 | specify the amount for the transaction.
130 |
131 | A successful manual retry of a past due subscription will **always** reduce
132 | the balance of that subscription to $0, regardless of the amount of the
133 | retry.
134 |
135 | ## Example
136 |
137 | {:ok, transaction} = Braintree.Subscription.retry_charge(sub_id)
138 | {:ok, transaction} = Braintree.Subscription.retry_charge(sub_id, "24.00")
139 | """
140 | @spec retry_charge(String.t()) :: {:ok, Transaction.t()}
141 | @spec retry_charge(String.t(), String.t() | nil, Keyword.t()) ::
142 | {:ok, Transaction.t()} | HTTP.error()
143 | def retry_charge(subscription_id, amount \\ nil, opts \\ []) do
144 | Transaction.sale(%{amount: amount, subscription_id: subscription_id}, opts)
145 | end
146 |
147 | @doc """
148 | To update a subscription, use its ID along with new attributes. The same
149 | validations apply as when creating a subscription. Any attribute not passed will
150 | remain unchanged.
151 |
152 | ## Example
153 |
154 | {:ok, subscription} = Braintree.Subscription.update("subscription_id", %{
155 | plan_id: "new_plan_id"
156 | })
157 | subscription.plan_id # "new_plan_id"
158 | """
159 | @spec update(binary, map, Keyword.t()) :: {:ok, t} | HTTP.error()
160 | def update(id, params, opts \\ []) when is_binary(id) and is_map(params) do
161 | with {:ok, payload} <- HTTP.put("subscriptions/" <> id, %{subscription: params}, opts) do
162 | {:ok, new(payload)}
163 | end
164 | end
165 |
166 | @doc """
167 | To search for subscriptions, pass a map of search parameters.
168 |
169 | ## Example:
170 |
171 | {:ok, subscriptions} = Braintree.Subscription.search(%{plan_id: %{is: "starter"}})
172 | """
173 | @spec search(map, Keyword.t()) :: {:ok, t} | HTTP.error()
174 | def search(params, opts \\ []) when is_map(params) do
175 | Search.perform(params, "subscriptions", &new/1, opts)
176 | end
177 |
178 | @doc """
179 | Convert a map into a Subscription struct. Add_ons and transactions
180 | are converted to a list of structs as well.
181 |
182 | ## Example
183 |
184 | subscripton = Braintree.Subscription.new(%{"plan_id" => "business",
185 | "status" => "Active"})
186 | """
187 | def new(%{"subscription" => map}) do
188 | new(map)
189 | end
190 |
191 | def new(map) when is_map(map) do
192 | subscription = super(map)
193 |
194 | add_ons = AddOn.new(subscription.add_ons)
195 | transactions = Transaction.new(subscription.transactions)
196 |
197 | %{subscription | add_ons: add_ons, transactions: transactions}
198 | end
199 |
200 | def new(list) when is_list(list) do
201 | Enum.map(list, &new/1)
202 | end
203 | end
204 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Braintree
2 |
3 | [](https://travis-ci.org/sorentwo/braintree-elixir)
4 | [](https://hex.pm/packages/braintree)
5 | [](https://hex.pm/packages/braintree)
6 | [](https://inch-ci.org/github/sorentwo/braintree-elixir)
7 |
8 | A native [Braintree][braintree] client library for Elixir.
9 |
10 | [braintree]: https://www.braintreepayments.com
11 |
12 | ## Installation
13 |
14 | Add braintree to your list of dependencies in `mix.exs`:
15 |
16 | ```elixir
17 | def deps do
18 | [{:braintree, "~> 0.13"}]
19 | end
20 | ```
21 |
22 | Once that is configured you are all set. Braintree is a library, not an
23 | application, but it does rely on `hackney`, which must be started. For Elixir
24 | versions < 1.4 you'll need to include it in the list of applications:
25 |
26 | ```elixir
27 | def application do
28 | [applications: [:braintree]]
29 | end
30 | ```
31 |
32 | Within your application you will need to configure the merchant id and
33 | authorization keys. You do *not* want to put this information in your
34 | `config.exs` file! Either put it in a `{prod,dev,test}.secret.exs` file which is
35 | sourced by `config.exs`, or read the values in from the environment in
36 | `runtime.exs`:
37 |
38 | ```elixir
39 | config :braintree,
40 | environment: :sandbox,
41 | master_merchant_id: System.fetch_env!("BRAINTREE_MASTER_MERCHANT_ID"),
42 | merchant_id: System.fetch_env!("BRAINTREE_MERCHANT_ID"),
43 | public_key: System.fetch_env!("BRAINTREE_PUBLIC_KEY"),
44 | private_key: System.fetch_env!("BRAINTREE_PRIVATE_KEY")
45 | ```
46 |
47 | Furthermore, the environment defaults to `:sandbox`, so you'll want to configure
48 | it with `:production` in `prod.exs`.
49 |
50 | Braintree has certificates that will be used for verification during the HTTP
51 | request. This library includes them and will use them by default, but if you
52 | need to override them, you may provide the configuration `:cacertfile` and
53 | `:sandbox_cacertfile`.
54 |
55 | You may optionally pass directly those configuration keys to all functions
56 | performing an API call. In that case, those keys will be used to perform the
57 | call.
58 |
59 | You can optionally configure HTTP request timeouts. The library sets reasonable defaults, but you
60 | can override them by [configuring Hackney options][opts]:
61 |
62 | ```elixir
63 | config :braintree, http_options: [recv_timeout: 30_000, connect_timeout: 10_000]
64 | ```
65 |
66 | Available timeout options:
67 |
68 | - `recv_timeout` - Maximum time to wait when receiving data from the server (default: 30,000ms)
69 | - `connect_timeout` - Maximum time to wait when establishing a connection (default: 10,000ms)
70 |
71 | [opts]: https://hexdocs.pm/hackney/hackney.html#request/5
72 |
73 | ## Usage
74 |
75 | The online [documentation][doc] for Ruby/Java/Python etc. will give you a
76 | general idea of the modules and available functionality. Where possible the
77 | namespacing has been preserved.
78 |
79 | The CRUD functions for each action module break down like this:
80 |
81 | ```elixir
82 | alias Braintree.Customer
83 | alias Braintree.ErrorResponse, as: Error
84 |
85 | case Customer.create(%{company: "Whale Corp"}) do
86 | {:ok, %Customer{} = customer} -> do_stuff_with_customer(customer)
87 | {:error, %Error{} = error} -> do_stuff_with_error(error)
88 | end
89 | ```
90 |
91 | ### Searching
92 |
93 | Search params are constructed with a fairly complex structure of maps. There
94 | isn't a DSL provided, so queries must be constructed by hand. For example, to
95 | search for a customer:
96 |
97 | ```elixir
98 | search_params = %{
99 | first_name: %{is: "Jenna"},
100 | last_name: %{
101 | starts_with: "Smith",
102 | contains: "ith",
103 | is_not: "Smithsonian"
104 | },
105 | email: %{ends_with: "gmail.com"}
106 | }
107 |
108 | {:ok, customers} = Braintree.Customer.search(search_params)
109 | ```
110 |
111 | Or, to search for pending credit card verifications within a particular dollar
112 | amount:
113 |
114 | ```elixir
115 | search_params = %{
116 | amount: %{
117 | min: "10.0",
118 | max: "15.0"
119 | },
120 | status: ["approved", "pending"]
121 | }
122 |
123 | {:ok, verifications} = Braintree.CreditCardVerification.search(search_params)
124 | ```
125 |
126 | [doc]: https://developers.braintreepayments.com/
127 |
128 |
129 | ### Telemetry
130 |
131 | If the `telemetry` application is running, the library will emit telemetry events.
132 |
133 | Immediately before the HTTP request is fired, a start event will be fired with the following shape:
134 |
135 | ```
136 | event name: [:braintree, :request, :start]
137 | measurements: %{system_time: System.system_time()}
138 | meta data: %{method: method, path: path}
139 | ```
140 |
141 | Once the HTTP call completes, a stop event will be fired with the following shape:
142 |
143 | ```
144 | event name: [:braintree, :request, :stop]
145 | measurements: %{duration: duration}
146 | meta data: %{method: method, path: path, http_status: status}
147 | ```
148 |
149 | If Hackney returns an error, an error event will be fired with the following shape:
150 |
151 | ```
152 | event name: [:braintree, :request, :error]
153 | measurements: %{duration: duration}
154 | meta data: %{method: method, path: path, error: error_reason}
155 | ```
156 |
157 | If an exception is raised during the Hackney call, an exception event will be fired with the following shape:
158 |
159 | ```
160 | event name: [:braintree, :request, :exception]
161 | measurements: %{duration: duration}
162 | meta data: %{method: method, path: path, kind: error_type, reason: error_message, stacktrace: stacktrace}
163 | ```
164 |
165 | ## Testing
166 |
167 | You'll need a Braintree sandbox account to run the integration tests. Also, be
168 | sure that your account has [Duplicate Transaction Checking][dtc] disabled.
169 |
170 | ### Merchant Account Features
171 |
172 | In order to test the merchant account features, your sandbox account needs to
173 | have a master merchant account and it needs to be added to your environment
174 | variables (only needed in test).
175 |
176 | Your environment needs to have the following:
177 |
178 | * Add-ons with ids: "bronze", "silver" and "gold"
179 | * Plans with ids: "starter", "business"
180 | * "business" plan needs to include the following add-ons: "bronze" and "silver"
181 |
182 | ### PayPal Account Testing
183 |
184 | PayPal testing uses the mocked API flow, which requires linking a sandbox PayPal
185 | account. You can accomplish that by following the directions for [linked paypal
186 | testing][plp].
187 |
188 | [dtc]: https://articles.braintreepayments.com/control-panel/transactions/duplicate-checking
189 | [plp]: https://developers.braintreepayments.com/guides/paypal/testing-go-live/php#linked-paypal-testing
190 |
191 | ### Testing Using Only `localhost`
192 |
193 | You can optionally configure the sandbox endpoint url to point towards a local url and
194 | port for testing which does not need to call out to the Braintree sandbox API.
195 | For example, in your `config.exs`
196 |
197 | ```elixir
198 | config :braintree, :sandbox_endpoint, "localhost:4001"
199 | ```
200 |
201 | In conjuction with a libary such as [`Bypass`](https://github.com/PSPDFKit-labs/bypass)
202 | you can use this config to define test-specific returns from `Braintree` calls without
203 | hitting the Braintree sandbox API.
204 |
205 | ## License
206 |
207 | MIT License, see [LICENSE.txt](LICENSE.txt) for details.
208 |
--------------------------------------------------------------------------------
/lib/transaction.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.Transaction do
2 | @moduledoc """
3 | Create a new sale.
4 |
5 | To create a transaction, you must include an amount and either a
6 | payment_method_nonce or a payment_method_token.
7 |
8 | https://developers.braintreepayments.com/reference/response/transaction/ruby
9 | """
10 |
11 | use Braintree.Construction
12 |
13 | alias Braintree.{AddOn, HTTP}
14 |
15 | @type t :: %__MODULE__{
16 | add_ons: [AddOn.t()],
17 | additional_processor_response: String.t(),
18 | amount: String.t(),
19 | android_pay_card: map,
20 | apple_pay: map,
21 | avs_error_response_code: String.t(),
22 | avs_postal_code_response_code: String.t(),
23 | avs_street_address_response_code: String.t(),
24 | billing: map,
25 | channel: String.t(),
26 | coinbase_details: String.t(),
27 | created_at: String.t(),
28 | credit_card: map,
29 | currency_iso_code: String.t(),
30 | custom_fields: map,
31 | customer: map,
32 | cvv_response_code: String.t(),
33 | descriptor: map,
34 | disbursement_details: map,
35 | discounts: [any],
36 | disputes: [any],
37 | escrow_status: String.t(),
38 | gateway_rejection_reason: String.t(),
39 | id: String.t(),
40 | merchant_account_id: String.t(),
41 | order_id: String.t(),
42 | payment_instrument_type: String.t(),
43 | paypal: map,
44 | plan_id: String.t(),
45 | processor_authorization_code: String.t(),
46 | processor_response_code: String.t(),
47 | processor_response_text: String.t(),
48 | processor_settlement_response_code: String.t(),
49 | processor_settlement_response_text: String.t(),
50 | purchase_order_number: String.t(),
51 | recurring: String.t(),
52 | refund_ids: String.t(),
53 | refunded_transaction_id: String.t(),
54 | risk_data: String.t(),
55 | service_fee_amount: number,
56 | settlement_batch_id: String.t(),
57 | shipping: map,
58 | status: String.t(),
59 | status_history: String.t(),
60 | subscription_details: map,
61 | subscription_id: String.t(),
62 | tax_amount: number,
63 | tax_exempt: boolean,
64 | type: String.t(),
65 | updated_at: String.t(),
66 | voice_referral_number: String.t()
67 | }
68 |
69 | defstruct add_ons: [],
70 | additional_processor_response: nil,
71 | amount: "0",
72 | android_pay_card: nil,
73 | apple_pay: nil,
74 | avs_error_response_code: nil,
75 | avs_postal_code_response_code: nil,
76 | avs_street_address_response_code: nil,
77 | billing: %{},
78 | channel: nil,
79 | coinbase_details: nil,
80 | created_at: nil,
81 | credit_card: %{},
82 | currency_iso_code: nil,
83 | custom_fields: %{},
84 | customer: %{},
85 | cvv_response_code: nil,
86 | descriptor: %{},
87 | disbursement_details: nil,
88 | discounts: [],
89 | disputes: [],
90 | escrow_status: nil,
91 | gateway_rejection_reason: nil,
92 | id: nil,
93 | merchant_account_id: nil,
94 | order_id: nil,
95 | payment_instrument_type: nil,
96 | paypal: %{},
97 | plan_id: nil,
98 | processor_authorization_code: nil,
99 | processor_response_code: nil,
100 | processor_response_text: nil,
101 | processor_settlement_response_code: nil,
102 | processor_settlement_response_text: nil,
103 | purchase_order_number: nil,
104 | recurring: nil,
105 | refund_ids: nil,
106 | refunded_transaction_id: nil,
107 | risk_data: nil,
108 | service_fee_amount: 0,
109 | settlement_batch_id: nil,
110 | shipping: %{},
111 | status: nil,
112 | status_history: nil,
113 | subscription_details: %{},
114 | subscription_id: nil,
115 | tax_amount: 0,
116 | tax_exempt: false,
117 | type: nil,
118 | updated_at: nil,
119 | voice_referral_number: nil
120 |
121 | @doc """
122 | Use a `payment_method_nonce` or `payment_method_token` to make a one time
123 | charge against a payment method.
124 |
125 | ## Example
126 |
127 | {:ok, transaction} = Transaction.sale(%{
128 | amount: "100.00",
129 | payment_method_nonce: @payment_method_nonce,
130 | options: %{submit_for_settlement: true}
131 | })
132 |
133 | transaction.status # "settling"
134 | """
135 | @spec sale(map, Keyword.t()) :: {:ok, t} | HTTP.error()
136 | def sale(params, opts \\ []) do
137 | sale_params = Map.merge(params, %{type: "sale"})
138 |
139 | with {:ok, payload} <- HTTP.post("transactions", %{transaction: sale_params}, opts) do
140 | {:ok, new(payload)}
141 | end
142 | end
143 |
144 | @doc """
145 | Use a `transaction_id` and optional `amount` to settle the transaction.
146 | Use this if `submit_for_settlement` was false while creating the charge using sale.
147 |
148 | ## Example
149 |
150 | {:ok, transaction} = Transaction.submit_for_settlement("123", %{amount: "100"})
151 | transaction.status # "settling"
152 | """
153 | @spec submit_for_settlement(String.t(), map, Keyword.t()) :: {:ok, t} | HTTP.error()
154 | def submit_for_settlement(transaction_id, params, opts \\ []) do
155 | path = "transactions/#{transaction_id}/submit_for_settlement"
156 |
157 | with {:ok, payload} <- HTTP.put(path, %{transaction: params}, opts) do
158 | {:ok, new(payload)}
159 | end
160 | end
161 |
162 | @doc """
163 | Use a `transaction_id` and optional `amount` to issue a refund
164 | for that transaction
165 |
166 | ## Example
167 |
168 | {:ok, transaction} = Transaction.refund("123", %{amount: "100.00"})
169 |
170 | transaction.status # "refunded"
171 | """
172 | @spec refund(String.t(), map, Keyword.t()) :: {:ok, t} | HTTP.error()
173 | def refund(transaction_id, params, opts \\ []) do
174 | path = "transactions/#{transaction_id}/refund"
175 |
176 | with {:ok, payload} <- HTTP.post(path, %{transaction: params}, opts) do
177 | {:ok, new(payload)}
178 | end
179 | end
180 |
181 | @doc """
182 | Use a `transaction_id` to issue a void for that transaction
183 |
184 | ## Example
185 |
186 | {:ok, transaction} = Transaction.void("123")
187 |
188 | transaction.status # "voided"
189 | """
190 | @spec void(String.t(), Keyword.t()) :: {:ok, t} | HTTP.error()
191 | def void(transaction_id, opts \\ []) do
192 | path = "transactions/#{transaction_id}/void"
193 |
194 | with {:ok, payload} <- HTTP.put(path, opts) do
195 | {:ok, new(payload)}
196 | end
197 | end
198 |
199 | @doc """
200 | Find an existing transaction by `transaction_id`
201 |
202 | ## Example
203 |
204 | {:ok, transaction} = Transaction.find("123")
205 | """
206 | @spec find(String.t(), Keyword.t()) :: {:ok, t} | HTTP.error()
207 | def find(transaction_id, opts \\ []) do
208 | path = "transactions/#{transaction_id}"
209 |
210 | with {:ok, payload} <- HTTP.get(path, opts) do
211 | {:ok, new(payload)}
212 | end
213 | end
214 |
215 | @doc """
216 | Convert a map into a Transaction struct.
217 |
218 | Add_ons are converted to a list of structs as well.
219 |
220 | ## Example
221 |
222 | transaction =
223 | Braintree.Transaction.new(%{
224 | "subscription_id" => "subxid",
225 | "status" => "submitted_for_settlement"
226 | })
227 | """
228 | def new(%{"transaction" => map}) do
229 | new(map)
230 | end
231 |
232 | def new(map) when is_map(map) do
233 | transaction = super(map)
234 |
235 | %{transaction | add_ons: AddOn.new(transaction.add_ons)}
236 | end
237 |
238 | def new(list) when is_list(list) do
239 | Enum.map(list, &new/1)
240 | end
241 | end
242 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
3 | "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
4 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
5 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
6 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
7 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
8 | "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"},
9 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"},
10 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
11 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
12 | "ex_doc": {:hex, :ex_doc, "0.39.2", "da5549bbce34c5fb0811f829f9f6b7a13d5607b222631d9e989447096f295c57", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "62665526a88c207653dbcee2aac66c2c229d7c18a70ca4ffc7f74f9e01324daa"},
13 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
14 | "hackney": {:hex, :hackney, "1.18.2", "d7ff544ddae5e1cb49e9cf7fa4e356d7f41b283989a1c304bfc47a8cc1cf966f", [:rebar3], [{:certifi, "~>2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "af94d5c9f97857db257090a4a10e5426ecb6f4918aa5cc666798566ae14b65fd"},
15 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
16 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"},
17 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
18 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
19 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
20 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
21 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
22 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
23 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
24 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
25 | "plug": {:hex, :plug, "1.15.0", "f40df58e1277fc7189f260daf788d628f03ae3053ce7ac1ca63eaf0423238714", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e434478d1015d968cf98ae2073e78bd63c4a06a94fe328c2df45fcd01df8ae30"},
26 | "plug_cowboy": {:hex, :plug_cowboy, "2.5.1", "7cc96ff645158a94cf3ec9744464414f02287f832d6847079adfe0b58761cbd0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "107d0a5865fa92bcb48e631cc0729ae9ccfa0a9f9a1bd8f01acb513abf1c2d64"},
27 | "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"},
28 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
29 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
30 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
31 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
32 | }
33 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## v0.16.0
2 |
3 | - [Braintree] Avoid a hackney 1.23.0 bug that compares the resolved server's IP address to the
4 | certificate's host.
5 |
6 | Without specifying the server_name_indication ssl option to hackney, it will result in this
7 | error:
8 |
9 | ```
10 | {:error,
11 | {:tls_alert,
12 | {:handshake_failure,
13 | ~c"TLS client: In state hello received SERVER ALERT: Fatal - Handshake Failure\n"}}}
14 | ```
15 |
16 | - [Braintree] Provide an escape hatch for the Braintree cacertfile by providing two new additional
17 | configuration keys: cacertfile and sandbox_cacertfile. This is helpful in case the provided
18 | cacertfile is expired, or for testing purposes.
19 |
20 | - [Braintree] Don't include SSL options for endpoints that aren't the official Braintree url.
21 |
22 | ## v0.15.0
23 |
24 | ### Changed
25 |
26 | - [Braintree] Update `api_braintreegateway_com.ca.crt`
27 |
28 | This updates the client certificate to match the current Ruby SDK. Without
29 | this update, requests won't be requested after June 30th, 2025.
30 |
31 | ## v0.14.0
32 |
33 | ### Added
34 |
35 | - [Braintree.VenmoAccount] Implements support for Venmo accounts
36 |
37 | ### Bug Fixes
38 |
39 | - [Braintree.*] Fix specs for functions returning HTTP errors
40 |
41 | * Fix specs for functions returning HTTP errors
42 | * Fix `Transaction` amount typespec and `ClientToken` compiler warning
43 | * Make default amount same type as true typespec
44 |
45 | - [Braintree.ClientToken] Fix typespec on `ClientToken.generate/2`
46 |
47 | - [Braintree.Customer] Update `Customer.find/2` typespec
48 |
49 | - [Braintree.HTTP] Support common 5XX HTTP error statuses
50 |
51 | - [Braintree.Search] Include `:not_found` in search error typespec
52 |
53 | ## v0.13.0 2022-10-24
54 |
55 | ### Added
56 |
57 | - [Braintree.Webhook] Allow passing options to webhook parser/validator, similar
58 | to other API functions.
59 |
60 | - [Braintree.Plan] Expand plan functionality with `delete`, improved guard
61 | clauses, and better response code formatting.
62 |
63 | - [Braintree.Customer] Support ACH Direct Debit payment methods.
64 |
65 | ### Fixed
66 |
67 | - [Braintree.HTTP] Fix error in response type specification.
68 |
69 | This typespec error wasn't caught given the current dialyzer settings, but
70 | could cause issues for clients expecting things like `{:error, :not_found}`.
71 |
72 | ## v0.12.1 2021-11-18
73 |
74 | ### Fixed
75 |
76 | - Update CA certificate bundle and enable peer verification for all HTTP
77 | requests.
78 |
79 | ## v0.12.0 2021-09-02
80 |
81 | ### Changed
82 |
83 | - Bumped the minium Elixir version from `1.7` to `1.9` (it had been a while!)
84 |
85 | ### Added
86 |
87 | - [Braintree.Webhook] A new module that provides convenience methods for parsing
88 | Braintree webhook payloads.
89 |
90 | - [Braintree] Wrap HTTP calls in telemetry events for instrumentation. All
91 | requests are wrapped in a telemetry events, which emits standard span events:
92 |
93 | `[:braintree, :request, :start | :stop | :exception | :error]`
94 |
95 | - [Braintree.Customer] Support ApplePay and AndroidPay
96 |
97 | ### Fixed
98 |
99 | - Fix atomizing nested maps with mixed keys.
100 |
101 | ## v0.11.0 2020-05-11
102 |
103 | * [Braintree] Allow configuration of sandbox endpoint for testing
104 | * [Braintree.CreditCard] Include `billing_address` with the `CreditCard` struct
105 | * [Braintree.Transaction] Add support for `android_pay_card` and correct field
106 | names to match `android_pay_card` and `apple_pay`
107 | * [Braintree.Transaction] Rename `customer_details` to `customer` to correctly
108 | reflect API results.
109 | * [Braintree.Search] Fix `perform` so that it correctly handles transaction
110 | results
111 | * [Braintree.TestTransaction] Make TestTransaction available in all
112 | environments.
113 |
114 | ## v0.10.0 2019-03-26
115 |
116 | ### Enhancements
117 |
118 | * [Braintree.HTTP] Support both `access_token` and public/private keys usage in configuration
119 |
120 | ## v0.9.0 2018-06-18
121 |
122 | ### Enhancements
123 |
124 | * [Braintree] Use system tuples as the default for application env
125 | * [Braintree] Add dialyxer and fix all typespecs. Typespecs are now validated
126 | during CI builds
127 | * [Braintree.HTTP] Expose `429 Too Many Requests` error with an integer to
128 | status mapping
129 | * [Braintree.TestTransaction] The module is now available in all environments,
130 | not just `test`
131 | * [Braintree.Address] Support added for address features
132 | * [Braintree.MerchantAccounts] Support added for merchant account features
133 | * [Braintree.Search] Support for searching customers, credit cards and
134 | subscriptions
135 | * [Braintree.XML] Support collections when decoding XML responses
136 |
137 | ### Changes
138 |
139 | * [Braintree] Elixir 1.5 is now the minimum supported version
140 | * [Braintree.Transaction] Replace `:billing_details` with the correctly named
141 | `:billing`
142 |
143 | ### Bug Fixes
144 |
145 | * [Braintree.HTTP] Use `Keyword.get_lazy` to avoid exceptions when config keys
146 | used for requests aren't set.
147 | * [Braintree.HTTP] Add explicit handling for `unprocessable_entity` errors
148 | * [Braintree.HTTP] Always coerce the environment to an atom
149 |
150 | ## v0.8.0 2017-08-24
151 |
152 | ### Enhancements
153 |
154 | * [Braintree.ErrorResponse] Include full transaction details in the
155 | `ErrorResponse` struct. This displays the underlying reason a request failed,
156 | helping developers diagnose failing requests.
157 | * [Braintree.HTTP] Ability to optionally pass environment and API keys as
158 | options to all functions doing API calls. The default behaviour of reading
159 | from the global config is kept if those keys are not passed as arguments.
160 | Submitted by @manukall and @nicolasblanco
161 |
162 | ### Changes
163 |
164 | * [Braintree.Construction] Use `new/1` to build structs, rather than the unusual
165 | `construct/1` function.
166 |
167 | ### Bug Fixes
168 |
169 | * [Braintree.HTTP] Catch and return `400 Bad Request` error tuples, rather than
170 | generating a case clause error.
171 |
172 | ## v0.7.0 2016-09-20
173 |
174 | ### Enhancements
175 |
176 | * [Braintree.ClientToken] Set the default client token version to `2`.
177 | * [Braintree.Discount] Add support for discounts
178 | * [Braintree.AddOn] Add support for add-ons
179 | * [Braintree.SettlementBatchSummary] Add support for settlement reports
180 | * [Braintree.Subscription] Support updating with `update/2`
181 | * [Braintree.Subscription] Convert add-on and transaction lists to structs
182 |
183 | ### Changes
184 |
185 | * [Braintree.XML] Strictly accept maps for generation, not keyword lists.
186 |
187 | ### Bug Fixes
188 |
189 | * [Braintree.XML] Correctly handle decoding entities such as `&`, `>`
190 | and `<`.
191 | * [Braintree.XML] Fix encoding XML array values
192 | * [Braintree.XML] Add encoding of binaries
193 |
194 | ## v0.6.0 2016-08-10
195 |
196 | ### Enhancements
197 |
198 | * [Braintree.HTTP] Remove dependency on HTTPoison! Instead Hackney is used
199 | directly.
200 | * [Braintree.HTTP] Configuration options can be provided for Hackney via
201 | `http_options`.
202 | * [Braintree] Support `{:system, VAR}` for configs
203 | * [Braintree.XML] Support for parsing top level arrays. Some endpoints, notably
204 | `plans`, may return an array rather than an object
205 | * [Braintree.Plan] Added module and `all/0` for retrieving billing plans
206 | * [Braintree.Customer] Enhanced with `find/1`
207 | * [Braintree.Subscription] Enhanced with `cancel/1` and `retry_charge/1`
208 |
209 | ### Bug Fixes
210 |
211 | * [Braintree.XML.Entity] XML entities are automatically encoded and decoded.
212 | This prevents errors when values contain quotes, ampersands, or other
213 | characters that must be escaped
214 | * [Braintree.Customer] Return a tagged error tuple for `delete/1`
215 | * [Braintree.Transaction] Use the correct `paypal` field for `Transaction` responses
216 |
217 | ## v0.5.0 2016-06-13
218 |
219 | * Added: Paypal endpoints for use with the vault flow [TylerCain]
220 | * Added: Construct Paypal accounts from customer responses
221 | * Added: Support `submit_for_settlement/2` to Transaction
222 | * Added: Typespec for the Transaction struct
223 | * Fixed: Typespec for the CreditCard struct
224 | * Fixed: Include xmerl in the list of applications to ensure that it is packaged
225 | with `exrm` releases.
226 |
227 | ## v0.4.0 2016-04-20
228 |
229 | * Added: Available only during testing, `TestTransaction`, which can be used to
230 | transition transactions to different states.
231 | * Added: Add `find`, `void`, and `refund` on `Transaction`. [Tyler Cain]
232 | * Added: Add support for `PaymentMethod`, `PaymentMethodNonce`. [Tyler Cain]
233 | * Added: Basic support for subscription management, starting with `create`.
234 | [Ryan Bigg]
235 |
236 | ## v0.3.2 2016-02-26
237 |
238 | * Fixed: Log unprocessable responses rather than inspecting them to STDOUT.
239 | * Fixed: Convert 404 and 401 responses to error tuples, they are common problems
240 | with misconfiguration.
241 |
242 | ## v0.3.1 2016-02-18
243 |
244 | * Fixed: Lookup the certfile path at runtime rather than compile time. This
245 | fixes potential build errors when pre-building releases or packaging on
246 | platforms like Heroku.
247 |
248 | ## v0.3.0 2016-02-17
249 |
250 | * Fixed: Raise helpful errors when missing required config
251 | * Added: Client token module for generating new client tokens [Taylor Briggs]
252 |
253 | ## v0.2.0 2016-02-05
254 |
255 | * Added: Support for updating and deleting customers.
256 | * Added: A `Nonces` module for help testing transactions.
257 | * Changed: Include testing support `Braintree.Testing.CreditCardNumbers` as well as
258 | `Braintree.Testing.Nonces` in `lib/testing`, making it available in packaged
259 | releases.
260 | * Fixed: Trying to call `XML.load` on empty strings returns an empty map.
261 | * Removed: The `__using__` macro has been removed from HTTP because the naming
262 | conflicted with `delete` actions. An equivalent macro will be introduced in
263 | the future.
264 |
265 | ## v0.1.0 2016-02-03
266 |
267 | * Initial release with support for `Customer.create` and `Transaction.sale`.
268 |
--------------------------------------------------------------------------------
/lib/http.ex:
--------------------------------------------------------------------------------
1 | defmodule Braintree.HTTP do
2 | @moduledoc """
3 | Base client for all server interaction, used by all endpoint specific
4 | modules.
5 |
6 | This request wrapper coordinates the remote server, headers, authorization
7 | and SSL options.
8 |
9 | Using `Braintree.HTTP` requires the presence of three config values:
10 |
11 | * `merchant_id` - Braintree merchant id
12 | * `private_key` - Braintree private key
13 | * `public_key` - Braintree public key
14 |
15 | All three values must be set or a `Braintree.ConfigError` will be raised at
16 | runtime. All those config values support the `{:system, "VAR_NAME"}` as a
17 | value - in which case the value will be read from the system environment with
18 | `System.get_env("VAR_NAME")`.
19 | """
20 |
21 | require Logger
22 |
23 | alias Braintree.ErrorResponse, as: Error
24 | alias Braintree.XML.{Decoder, Encoder}
25 |
26 | @type error ::
27 | {:error, atom}
28 | | {:error, Error.t()}
29 | | {:error, binary}
30 |
31 | @type response :: {:ok, map} | error
32 |
33 | @production_endpoint "https://api.braintreegateway.com/"
34 | @cacertfile "/certs/api_braintreegateway_com.ca.crt"
35 | @sandbox_endpoint "https://api.sandbox.braintreegateway.com/"
36 |
37 | @headers [
38 | {"Accept", "application/xml"},
39 | {"User-Agent", "Braintree Elixir/0.1"},
40 | {"Accept-Encoding", "gzip"},
41 | {"X-ApiVersion", "4"},
42 | {"Content-Type", "application/xml"}
43 | ]
44 |
45 | @statuses %{
46 | 400 => :bad_request,
47 | 401 => :unauthorized,
48 | 403 => :forbidden,
49 | 404 => :not_found,
50 | 406 => :not_acceptable,
51 | 422 => :unprocessable_entity,
52 | 426 => :upgrade_required,
53 | 429 => :too_many_requests,
54 | 500 => :server_error,
55 | 501 => :not_implemented,
56 | 502 => :bad_gateway,
57 | 503 => :service_unavailable,
58 | 504 => :connect_timeout
59 | }
60 |
61 | @doc """
62 | Centralized request handling function. All convenience structs use this
63 | function to interact with the Braintree servers. This function can be used
64 | directly to supplement missing functionality.
65 |
66 | ## Example
67 |
68 | defmodule MyApp.Disbursement do
69 | alias Braintree.HTTP
70 |
71 | def disburse(params \\ %{}) do
72 | HTTP.request(:get, "disbursements", params)
73 | end
74 | end
75 | """
76 | @spec request(atom, binary, binary | map, Keyword.t()) :: response
77 | def request(method, path, body \\ %{}, opts \\ []) do
78 | emit_start(method, path)
79 |
80 | start_time = System.monotonic_time()
81 |
82 | try do
83 | url = build_url(path, opts)
84 |
85 | :hackney.request(
86 | method,
87 | url,
88 | build_headers(opts),
89 | encode_body(body),
90 | build_options([{:url, url} | opts])
91 | )
92 | catch
93 | kind, reason ->
94 | duration = System.monotonic_time() - start_time
95 |
96 | emit_exception(duration, method, path, %{
97 | kind: kind,
98 | reason: reason,
99 | stacktrace: __STACKTRACE__
100 | })
101 |
102 | :erlang.raise(kind, reason, __STACKTRACE__)
103 | else
104 | {:ok, code, _headers, body} when code in 200..299 ->
105 | duration = System.monotonic_time() - start_time
106 | emit_stop(duration, method, path, code)
107 | {:ok, decode_body(body)}
108 |
109 | {:ok, code, _headers, _body} when code in 300..399 ->
110 | duration = System.monotonic_time() - start_time
111 | emit_stop(duration, method, path, code)
112 | {:ok, ""}
113 |
114 | {:ok, 422, _headers, body} ->
115 | duration = System.monotonic_time() - start_time
116 | emit_stop(duration, method, path, 422)
117 |
118 | {
119 | :error,
120 | body
121 | |> decode_body()
122 | |> resolve_error_response()
123 | }
124 |
125 | {:ok, code, _headers, _body} when code in 400..504 ->
126 | duration = System.monotonic_time() - start_time
127 | emit_stop(duration, method, path, code)
128 | {:error, code_to_reason(code)}
129 |
130 | {:error, reason} ->
131 | duration = System.monotonic_time() - start_time
132 | emit_error(duration, method, path, reason)
133 | {:error, reason}
134 | end
135 | end
136 |
137 | for method <- ~w(get delete post put)a do
138 | @spec unquote(method)(binary) :: response
139 | @spec unquote(method)(binary, map | list) :: response
140 | @spec unquote(method)(binary, map, list) :: response
141 | def unquote(method)(path) do
142 | request(unquote(method), path, %{}, [])
143 | end
144 |
145 | def unquote(method)(path, payload) when is_map(payload) do
146 | request(unquote(method), path, payload, [])
147 | end
148 |
149 | def unquote(method)(path, opts) when is_list(opts) do
150 | request(unquote(method), path, %{}, opts)
151 | end
152 |
153 | def unquote(method)(path, payload, opts) do
154 | request(unquote(method), path, payload, opts)
155 | end
156 | end
157 |
158 | ## Helper Functions
159 |
160 | @doc false
161 | @spec build_url(binary, Keyword.t()) :: binary
162 | def build_url(path, opts) do
163 | environment = opts |> get_lazy_env(:environment) |> maybe_to_atom()
164 | merchant_id = get_lazy_env(opts, :merchant_id)
165 |
166 | Keyword.fetch!(endpoints(), environment) <> merchant_id <> "/" <> path
167 | end
168 |
169 | defp maybe_to_atom(value) when is_binary(value), do: String.to_existing_atom(value)
170 | defp maybe_to_atom(value) when is_atom(value), do: value
171 |
172 | @doc false
173 | @spec encode_body(binary | map) :: binary
174 | def encode_body(body) when body == "" or body == %{}, do: ""
175 | def encode_body(body), do: Encoder.dump(body)
176 |
177 | @doc false
178 | @spec decode_body(binary) :: map
179 | def decode_body(body) do
180 | body
181 | |> :zlib.gunzip()
182 | |> String.trim()
183 | |> Decoder.load()
184 | rescue
185 | ErlangError -> Logger.error("unprocessable response")
186 | end
187 |
188 | @doc false
189 | @spec build_headers(Keyword.t()) :: [tuple]
190 | def build_headers(opts) do
191 | auth_header =
192 | case get_lazy_env(opts, :access_token, :none) do
193 | token when is_binary(token) ->
194 | "Bearer " <> token
195 |
196 | _ ->
197 | username = get_lazy_env(opts, :public_key)
198 | password = get_lazy_env(opts, :private_key)
199 |
200 | "Basic " <> :base64.encode("#{username}:#{password}")
201 | end
202 |
203 | [{"Authorization", auth_header} | @headers]
204 | end
205 |
206 | defp get_lazy_env(opts, key, default \\ nil) do
207 | Keyword.get_lazy(opts, key, fn -> Braintree.get_env(key, default) end)
208 | end
209 |
210 | @doc false
211 | @spec build_options(Keyword.t()) :: [...]
212 | def build_options(opts) do
213 | http_opts =
214 | :http_options
215 | |> Braintree.get_env([])
216 | |> Keyword.put_new(:recv_timeout, 30_000)
217 | |> Keyword.put_new(:connect_timeout, 10_000)
218 |
219 | [:with_body] ++ ssl_opts(opts) ++ http_opts
220 | end
221 |
222 | defp ssl_opts(opts) do
223 | case opts[:url] do
224 | @production_endpoint <> _ ->
225 | [
226 | ssl_options: [
227 | verify: :verify_peer,
228 | # avoid bug in hackney 1.23.0 that compares SSL hostname to resolved IP
229 | server_name_indication: String.to_charlist("api.braintreegateway.com"),
230 | cacertfile:
231 | get_lazy_env(opts, :cacertfile, fn ->
232 | Path.join(:code.priv_dir(:braintree), @cacertfile)
233 | end)
234 | ]
235 | ]
236 |
237 | @sandbox_endpoint <> _ ->
238 | [
239 | ssl_options: [
240 | verify: :verify_peer,
241 | # avoid bug in hackney 1.23.0 that compares SSL hostname to resolved IP
242 | server_name_indication: String.to_charlist("api.sandbox.braintreegateway.com"),
243 | cacertfile:
244 | get_lazy_env(opts, :sandbox_cacertfile, fn ->
245 | Path.join(:code.priv_dir(:braintree), @cacertfile)
246 | end)
247 | ]
248 | ]
249 |
250 | _ ->
251 | []
252 | end
253 | end
254 |
255 | @doc false
256 | @spec code_to_reason(integer) :: atom
257 | def code_to_reason(integer)
258 |
259 | for {code, status} <- @statuses do
260 | def code_to_reason(unquote(code)), do: unquote(status)
261 | end
262 |
263 | defp resolve_error_response(%{"api_error_response" => api_error_response}) do
264 | Error.new(api_error_response)
265 | end
266 |
267 | defp resolve_error_response(%{"unprocessable_entity" => _}) do
268 | Error.new(%{message: "Unprocessable Entity"})
269 | end
270 |
271 | defp endpoints do
272 | [production: @production_endpoint <> "merchants/", sandbox: sandbox_endpoint()]
273 | end
274 |
275 | defp sandbox_endpoint do
276 | Application.get_env(
277 | :braintree,
278 | :sandbox_endpoint,
279 | @sandbox_endpoint <> "merchants/"
280 | )
281 | end
282 |
283 | defp emit_start(method, path) do
284 | :telemetry.execute(
285 | [:braintree, :request, :start],
286 | %{system_time: System.system_time()},
287 | %{method: method, path: path}
288 | )
289 | end
290 |
291 | defp emit_exception(duration, method, path, error_data) do
292 | :telemetry.execute(
293 | [:braintree, :request, :exception],
294 | %{duration: duration},
295 | %{method: method, path: path, error: error_data}
296 | )
297 | end
298 |
299 | defp emit_error(duration, method, path, error_reason) do
300 | :telemetry.execute(
301 | [:braintree, :request, :error],
302 | %{duration: duration},
303 | %{method: method, path: path, error: error_reason}
304 | )
305 | end
306 |
307 | defp emit_stop(duration, method, path, code) do
308 | :telemetry.execute(
309 | [:braintree, :request, :stop],
310 | %{duration: duration},
311 | %{method: method, path: path, http_status: code}
312 | )
313 | end
314 | end
315 |
--------------------------------------------------------------------------------
/test/http_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Braintree.HTTPTest do
2 | use ExUnit.Case
3 |
4 | import Braintree.Test.Support.ConfigHelper
5 | import ExUnit.CaptureLog
6 |
7 | alias Braintree.{ConfigError, HTTP}
8 |
9 | defmodule Handler do
10 | def attach do
11 | :telemetry.attach_many(
12 | "braintree-testing",
13 | [
14 | [:braintree, :request, :start],
15 | [:braintree, :request, :stop],
16 | [:braintree, :request, :error]
17 | ],
18 | &__MODULE__.echo_event/4,
19 | %{caller: self()}
20 | )
21 | end
22 |
23 | def echo_event(event, measurements, metadata, config) do
24 | send(config.caller, {:event, event, measurements, metadata})
25 | end
26 | end
27 |
28 | test "build_url/2 builds a url from application config without options" do
29 | with_applicaton_config(:merchant_id, "qwertyid", fn ->
30 | assert HTTP.build_url("customer", []) =~
31 | "sandbox.braintreegateway.com/merchants/qwertyid/customer"
32 | end)
33 | end
34 |
35 | test "build_url/2 builds a url from provided options" do
36 | assert HTTP.build_url("customer", environment: "production", merchant_id: "opts_merchant_id") =~
37 | "api.braintreegateway.com/merchants/opts_merchant_id/customer"
38 | end
39 |
40 | test "build_url/2 raises a helpful error message without config" do
41 | assert_config_error(:merchant_id, fn ->
42 | HTTP.build_url("customer", [])
43 | end)
44 | end
45 |
46 | test "encode_body/1 converts the request body to xml" do
47 | params = %{company: "Soren", first_name: "Parker"}
48 |
49 | assert [xml_tag | nodes] = params |> HTTP.encode_body() |> String.split("\n")
50 |
51 | assert xml_tag == ~s||
52 | assert ~s|Soren| in nodes
53 | assert ~s|Parker| in nodes
54 | end
55 |
56 | test "encode_body/1 ignores empty bodies" do
57 | assert HTTP.encode_body("") == ""
58 | assert HTTP.encode_body(%{}) == ""
59 | end
60 |
61 | test "decode_body/1 converts the request back from xml" do
62 | xml =
63 | compress(~s|\nSoren|)
64 |
65 | assert HTTP.decode_body(xml) == %{"company" => %{"name" => "Soren"}}
66 | end
67 |
68 | test "decode_body/1 safely handles empty responses" do
69 | assert HTTP.decode_body(compress("")) == %{}
70 | assert HTTP.decode_body(compress(" ")) == %{}
71 | end
72 |
73 | test "decode_body/1 logs unhandled errors" do
74 | assert capture_log(fn ->
75 | HTTP.decode_body("asdf")
76 | end) =~ "unprocessable response"
77 | end
78 |
79 | test "build_options/0 sets default timeouts" do
80 | options = HTTP.build_options([])
81 |
82 | assert :with_body in options
83 | assert {:recv_timeout, 30_000} in options
84 | assert {:connect_timeout, 10_000} in options
85 | end
86 |
87 | test "build_options/0 allows overriding timeout defaults via config" do
88 | with_applicaton_config(:http_options, [recv_timeout: 15_000, connect_timeout: 5_000], fn ->
89 | options = HTTP.build_options([])
90 |
91 | assert :with_body in options
92 | assert {:recv_timeout, 15_000} in options
93 | assert {:connect_timeout, 5_000} in options
94 | end)
95 | end
96 |
97 | test "build_options/0 merges custom options with defaults" do
98 | with_applicaton_config(:http_options, [recv_timeout: 20_000, custom_option: :value], fn ->
99 | options = HTTP.build_options([])
100 |
101 | assert :with_body in options
102 | assert {:recv_timeout, 20_000} in options
103 | assert {:connect_timeout, 10_000} in options
104 | assert {:custom_option, :value} in options
105 | end)
106 | end
107 |
108 | test "build_options/1 adds the cacertfile for production" do
109 | options = HTTP.build_options(url: "https://api.braintreegateway.com/merchants/123foo/")
110 |
111 | assert {:ssl_options, ssl_options} = :lists.keyfind(:ssl_options, 1, options)
112 | ssl_options = Map.new(ssl_options)
113 | assert %{cacertfile: _} = ssl_options
114 | assert %{server_name_indication: ~c"api.braintreegateway.com"} = ssl_options
115 | assert %{verify: :verify_peer} = ssl_options
116 | end
117 |
118 | test "build_options/1 adds the cacertfile for sandbox" do
119 | options =
120 | HTTP.build_options(url: "https://api.sandbox.braintreegateway.com/merchants/123foo/")
121 |
122 | assert {:ssl_options, ssl_options} = :lists.keyfind(:ssl_options, 1, options)
123 | ssl_options = Map.new(ssl_options)
124 | assert %{cacertfile: _} = ssl_options
125 | assert %{server_name_indication: ~c"api.sandbox.braintreegateway.com"} = ssl_options
126 | assert %{verify: :verify_peer} = ssl_options
127 | end
128 |
129 | test "build_options/1 does not add the cacertfile for other endpoints" do
130 | options = HTTP.build_options(url: "http://localhost:5000/merchants/123foo/")
131 |
132 | refute :lists.keyfind(:ssl_options, 1, options)
133 | end
134 |
135 | describe "request/3" do
136 | test "unauthorized response with an invalid merchant id" do
137 | with_applicaton_config(:merchant_id, "junkmerchantid", fn ->
138 | assert {:error, :unauthorized} = HTTP.request(:get, "customers")
139 | end)
140 | end
141 | end
142 |
143 | describe "telemetry events from request" do
144 | setup do
145 | on_exit(fn -> :telemetry.detach("braintree-testing") end)
146 |
147 | {:ok, bypass: Bypass.open()}
148 | end
149 |
150 | test "emits a start and stop message on a successful request", %{bypass: bypass} do
151 | Enum.each([200, 422, 500], fn code ->
152 | with_applicaton_config(:sandbox_endpoint, "localhost:#{bypass.port}/", fn ->
153 | with_applicaton_config(:merchant_id, "junkmerchantid", fn ->
154 | path = "foo#{code}"
155 |
156 | body =
157 | case code do
158 | 200 ->
159 | ~s|\nSoren|
160 |
161 | _ ->
162 | ~s|\nTest Error|
163 | end
164 |
165 | Bypass.stub(bypass, "POST", "/junkmerchantid/foo#{code}", fn conn ->
166 | Plug.Conn.resp(conn, code, compress(body))
167 | end)
168 |
169 | Handler.attach()
170 |
171 | HTTP.request(:post, path, %{})
172 |
173 | assert_receive {:event, [:braintree, :request, :start], %{system_time: _},
174 | %{method: :post, path: _}}
175 |
176 | assert_receive {:event, [:braintree, :request, :stop], %{duration: _},
177 | %{method: :post, path: _, http_status: _}}
178 | end)
179 | end)
180 | end)
181 | end
182 |
183 | test "emits an error event on exception", %{bypass: bypass} do
184 | with_applicaton_config(:sandbox_endpoint, "localhost:#{bypass.port}/", fn ->
185 | with_applicaton_config(:merchant_id, "junkmerchantid", fn ->
186 | Bypass.down(bypass)
187 |
188 | Handler.attach()
189 |
190 | HTTP.request(:post, "/junkmerchant/foo", %{})
191 |
192 | assert_receive {:event, [:braintree, :request, :start], %{system_time: _},
193 | %{method: :post, path: "/junkmerchant/foo"}}
194 |
195 | assert_receive {:event, [:braintree, :request, :error], %{duration: _},
196 | %{method: :post, path: "/junkmerchant/foo", error: :econnrefused}}
197 | end)
198 | end)
199 | end
200 | end
201 |
202 | describe "build_headers/1" do
203 | test "building an auth header from application config" do
204 | with_applicaton_config(:private_key, "the_private_key", fn ->
205 | with_applicaton_config(:public_key, "the_public_key", fn ->
206 | {_, auth_header} = List.keyfind(HTTP.build_headers([]), "Authorization", 0)
207 |
208 | assert auth_header == "Basic dGhlX3B1YmxpY19rZXk6dGhlX3ByaXZhdGVfa2V5"
209 | end)
210 | end)
211 | end
212 |
213 | test "building an auth header from only an access token" do
214 | with_applicaton_config(:access_token, "special_access_token", fn ->
215 | {_, auth_header} = List.keyfind(HTTP.build_headers([]), "Authorization", 0)
216 |
217 | assert auth_header == "Bearer special_access_token"
218 | end)
219 | end
220 |
221 | test "building an auth header from provided options" do
222 | headers =
223 | HTTP.build_headers(
224 | access_token: nil,
225 | private_key: "dynamic_key",
226 | public_key: "dyn_pub_key"
227 | )
228 |
229 | {_, auth_header} = List.keyfind(headers, "Authorization", 0)
230 |
231 | assert auth_header == "Basic ZHluX3B1Yl9rZXk6ZHluYW1pY19rZXk="
232 | end
233 |
234 | test "build_headers/1 raises a helpful error message without config" do
235 | assert_config_error(:public_key, fn ->
236 | HTTP.build_headers([])
237 | end)
238 | end
239 | end
240 |
241 | describe "code_to_reason/1" do
242 | test "supports common HTTP statuses" do
243 | for {status, reason} <- [
244 | {400, :bad_request},
245 | {401, :unauthorized},
246 | {403, :forbidden},
247 | {404, :not_found},
248 | {406, :not_acceptable},
249 | {422, :unprocessable_entity},
250 | {426, :upgrade_required},
251 | {429, :too_many_requests},
252 | {500, :server_error},
253 | {501, :not_implemented},
254 | {502, :bad_gateway},
255 | {503, :service_unavailable},
256 | {504, :connect_timeout}
257 | ] do
258 | assert HTTP.code_to_reason(status) == reason
259 | end
260 | end
261 | end
262 |
263 | defp compress(string), do: :zlib.gzip(string)
264 |
265 | defp assert_config_error(key, fun) do
266 | value = Braintree.get_env(key)
267 |
268 | try do
269 | Application.delete_env(:braintree, key)
270 | assert_raise ConfigError, "missing config for :#{key}", fun
271 | after
272 | Braintree.put_env(key, value)
273 | end
274 | end
275 | end
276 |
--------------------------------------------------------------------------------