├── .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" 44 | 45 | defp generate({name, value}) when is_list(value), 46 | do: "<#{hyphenate(name)} type=\"array\">\n#{generate(value)}\n" 47 | 48 | defp generate({name, value}), do: "<#{hyphenate(name)}>#{value}" 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 | [![Build Status](https://travis-ci.org/sorentwo/braintree-elixir.svg?branch=master)](https://travis-ci.org/sorentwo/braintree-elixir) 4 | [![Hex version](https://img.shields.io/hexpm/v/braintree.svg "Hex version")](https://hex.pm/packages/braintree) 5 | [![Hex downloads](https://img.shields.io/hexpm/dt/braintree.svg "Hex downloads")](https://hex.pm/packages/braintree) 6 | [![Inline docs](https://inch-ci.org/github/sorentwo/braintree-elixir.svg)](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 | --------------------------------------------------------------------------------