diff --git a/lib/gringotts/gateways/mercadopago.ex b/lib/gringotts/gateways/mercadopago.ex new file mode 100644 index 00000000..5945374e --- /dev/null +++ b/lib/gringotts/gateways/mercadopago.ex @@ -0,0 +1,368 @@ +defmodule Gringotts.Gateways.Mercadopago do + @moduledoc """ + [mercadopago][home] gateway implementation. + + For reference see [mercadopago documentation][docs]. + + The following features of mercadopago are implemented: + + | Action | Method | + | ------ | ------ | + | Pre-authorize | `authorize/3` | + | Capture | `capture/3` | + | Purchase | `purchase/3` | + | Reversal | `void/2` | + | Refund | `refund/3` | + + [home]: https://www.mercadopago.com/ + [docs]: https://www.mercadopago.com.ar/developers/en/api-docs/ + + ## The `opts` argument + + Most `Gringotts` API calls accept an optional `keyword` list `opts` to supply + optional arguments for transactions with the mercadopag. The following keys are supported: + + | Key | Remark | + | ---- | --- | + | `email` | Email of the customer. Type - string | + | `order_id` | Order id issued by the merchant. Type- integer | + | `customer_id` | Unique customer id issued by the gateway. For new customer it must skipped. Type- string| + | `order_type` | `"mercadopago"` or `"mercadolibre"` as per the order. Type- string | + | `installments` | No of installments for payment. Type- integer | + + ## Registering your mercadopago account at `Gringotts` + + After [making an account on mercadopago][credentials], head to the credentials and find + your account "secrets" in the `Checkout Transparent`. + + | Config parameter | MERCADOPAGO secret | + | ------- | ---- | + | `:access_token` | **Access Token** | + | `:public_key` | **Public Key** | + + > Your Application config **must include the `[:public_key, :access_token]` field(s)** and would look + > something like this: + > + > config :gringotts, Gringotts.Gateways.Mercadopago, + > public_key: "your_secret_public_key" + > access_token: "your_secret_access_token" + + [credentials]: https://www.mercadopago.com/mlb/account/credentials?type=basic + + ## Note + + * mercadopago processes money in the subdivided units (like `cents` in case of + US Dollar). + * Also, there is no way to set the currency of the transaction via the API. It + is automatically set from the merchant's account. Hence, if you've + configured your mercadopago account to work in Chilean Peso (`CLP`), make + sure that the `amount` argument is always a `Money.t` struct with the `:CLP` + as currency. + + ## Supported currencies and countries + + mercadopago supports the currencies listed [here][currencies]. + + [currencies]: https://api.mercadopago.com/currencies + + ## Following the examples + + 1. First, set up a sample application and configure it to work with MERCADOPAGO. + - You could do that from scratch by following our [Getting Started][gs] guide. + - To save you time, we recommend [cloning our example + repo][example] that gives you a pre-configured sample app ready-to-go. + + You could use the same config or update it the with your "secrets" + as described [above](#module-registering-your-mercadopago-account-at-gringotts). + + [gs]: https://github.com/aviabird/gringotts/wiki/ + [home]: https://www.mercadopago.com + [example]: https://github.com/aviabird/gringotts_payment + """ + + @base_url "https://api.mercadopago.com" + use Gringotts.Gateways.Base + alias Gringotts.CreditCard + # The Adapter module provides the `validate_config/1` + # Add the keys that must be present in the Application config in the + # `required_config` list + use Gringotts.Adapter, required_config: [:public_key, :access_token] + + alias Gringotts.{CreditCard, Money, Response} + + @doc """ + Performs a (pre) Authorize operation. + + The authorization validates the `card` details with the banking network, + places a hold on the transaction `amount` in the customer’s issuing bank. + + mercadoapgo's `authorize` returns: + * `customer_id`, available in `Response.token` field and + * `authorization_id`, available in the `Response.id` field. + + The `id` can be used to + * `capture/3` _an_ amount. + * `void/2` a pre-authorization. + + ## Note + + For a new customer, `customer_id` field should be `nil`. Otherwise it should + be provided. + + ## Example + + ### Authorization for new customer. + + The following example shows how one would (pre) authorize a payment of 42 + BRL on a sample `card`. + + iex> amount = Money.new(42, :BRL) + iex> card = %Gringotts.CreditCard{first_name: "Lord", last_name: "Voldemort", number: "4509953566233704", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + iex> opts = [email: "tommarvolo@riddle.com", order_id: 123123, payment_method_id: "visa"] + iex> {:ok, auth_result} = Gringotts.authorize(Gringotts.Gateways.Mercadopago, amount, card, opts) + iex> auth_result.id # This is the authorization ID + iex> auth_result.token # This is the customer ID/token + + ### Authorization for old customer. + + The following example shows how one would (pre) authorize a payment of 42 + BRL on a sample `card`. + + iex> amount = Money.new(42, :BRL) + iex> card = %Gringotts.CreditCard{first_name: "Hermione", last_name: "Granger", number: "4509953566233704", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + iex> opts = [email: "hermione@granger.com", order_id: 123125, customer_id: "hermoine's customer id", payment_method_id: "visa"] + iex> {:ok, auth_result} = Gringotts.authorize(Gringotts.Gateways.Mercadopago, amount, card, opts) + iex> auth_result.id # This is the authorization ID + iex> auth_result.token # This is the customer ID/token + """ + @spec authorize(Money.t(), CreditCard.t(), keyword) :: {:ok | :error, Response} + def authorize(amount, %CreditCard{} = card, opts) do + with {:ok, card_token} <- create_token(card, opts) do + {currency, value, exponent} = Money.to_integer(amount) + url_params = [access_token: opts[:config][:access_token]] + + params = [ + authorize_params(value, opts, card_token, false, card) + ] + + body = + params + |> Enum.reduce(&Map.merge/2) + |> Poison.encode!() + + commit(:post, "/v1/payments", body, opts, url_params) + end + end + + @doc """ + Captures a pre-authorized `amount`. + `amount` is transferred to the merchant account by mercadopago used in the + pre-authorization referenced by `payment_id`. + ## Note + mercadopago allows partial captures also. However, you can make a partial capture to a payment only **once**. + > The authorization will be valid for 7 days. If you do not capture it by that time, it will be cancelled. + > The specified amount can not exceed the originally reserved. + > If you do not specify the amount, all the reserved money is captured. + > In Argentina only available for Visa and American Express cards. + ## Example + The following example shows how one would (partially) capture a previously + authorized a payment worth 35 BRL by referencing the obtained authorization `id`. + iex> amount = Money.new(35, :BRL) + iex> {:ok, capture_result} = Gringotts.capture(Gringotts.Gateways.Mercadopago, auth_result.id, amount, opts) + """ + @spec capture(String.t(), Money.t(), keyword) :: {:ok | :error, Response} + def capture(payment_id, amount, opts) do + {currency, value, exponent} = Money.to_integer(amount) + url_params = [access_token: opts[:config][:access_token]] + body = %{capture: true, transaction_amount: value} |> Poison.encode!() + commit(:put, "/v1/payments/#{payment_id}", body, opts, url_params) + end + + @doc """ + Transfers `amount` from the customer to the merchant. + mercadopago attempts to process a purchase on behalf of the customer, by + debiting `amount` from the customer's account by charging the customer's + `card`. + ## Example + The following example shows how one would process a payment worth 42 BRL in + one-shot, without (pre) authorization. + iex> amount = Money.new(42, :BRL) + iex> card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + iex> {:ok, purchase_result} = Gringotts.purchase(Gringotts.Gateways.Mercadopago, amount, card, opts) + iex> purchase_result.token # This is the customer ID/token + """ + @spec purchase(Money.t(), CreditCard.t(), keyword) :: {:ok | :error, Response} + def purchase(amount, %CreditCard{} = card, opts) do + with {:ok, card_token} <- create_token(card, opts) do + {currency, value, exponent} = Money.to_integer(amount) + url_params = [access_token: opts[:config][:access_token]] + + body = value |> authorize_params(opts, card_token, true, card) |> Poison.encode!() + commit(:post, "/v1/payments", body, opts, url_params) + end + end + + @doc """ + Voids the referenced payment. + This method attempts a reversal of a previous transaction referenced by + `payment_id`. + > As a consequence, the customer will never see any booking on his statement. + ## Note + > Only pending or in_process payments can be cancelled. + > Cancelled coupon payments, deposits and/or transfers will be deposited in the buyer’s Mercadopago account. + ## Example + The following example shows how one would void a previous (pre) + authorization. Remember that our `capture/3` example only did a partial + capture. + iex> {:ok, void_result} = Gringotts.void(Gringotts.Gateways.Mercadopago, auth_result.id, opts) + """ + @spec void(String.t(), keyword) :: {:ok | :error, Response} + def void(payment_id, opts) do + # url = "#{@base_url}/v1/payments/#{payment_id}?access_token=#{opts[:config][:access_token]}" + url_params = [access_token: opts[:config][:access_token]] + body = %{status: "cancelled"} |> Poison.encode!() + commit(:put, "/v1/payments/#{payment_id}", body, opts, url_params) + end + + @doc """ + Refunds the `amount` to the customer's account with reference to a prior transfer. + mercadopago processes a full or partial refund worth `amount`, referencing a + previous `purchase/3` or `capture/3`. + ## Note + > You must have enough available money in your account so you can refund the payment amount successfully. Otherwise, you'll get a 400 Bad Request error. + > You can refund a payment within 90 days after it was accredited. + > You can only refund approved payments. + > You can perform up to 20 partial refunds in one payment. + ## Example + The following example shows how one would (completely) refund a previous + purchase (and similarily for captures). + iex> amount = Money.new(35, :BRL) + iex> {:ok, refund_result} = Gringotts.refund(Gringotts.Gateways.Mercadopago, purchase_result.id, amount) + """ + @spec refund(Money.t(), String.t(), keyword) :: {:ok | :error, Response} + def refund(amount, payment_id, opts) do + {currency, value, exponent} = Money.to_integer(amount) + + # url = + # "#{@base_url}/v1/payments/#{payment_id}/refunds?access_token=#{opts[:config][:access_token]}" + url_params = [access_token: opts[:config][:access_token]] + body = %{amount: value} |> Poison.encode!() + commit(:post, "/v1/payments/#{payment_id}/refunds", body, opts, url_params) + end + + ############################################################################### + # PRIVATE METHODS # + ############################################################################### + + # Makes the request to mercadopago's network. + # For consistency with other gateway implementations, make your (final) + # network request in here, and parse it using another private method called + # `respond`. + @spec commit(atom, String.t(), String.t(), keyword, keyword) :: {:ok | :error, Response.t()} + defp commit(method, path, body, opts, url_params) do + headers = [{"content-type", "application/json"}, {"accept", "application/json"}] + url = "#{@base_url}#{path}" + + res = + HTTPoison.request( + method, + url, + body, + headers, + opts ++ [params: [access_token: opts[:config][:access_token]]] + ) + + respond(res, opts) + end + + defp token_params(%CreditCard{} = card) do + %{ + expirationYear: card.year, + expirationMonth: card.month, + cardNumber: card.number, + securityCode: card.verification_code, + cardholder: %{name: CreditCard.full_name(card)} + } + end + + defp create_token(%CreditCard{} = card, opts) do + url_params = [public_key: opts[:config][:public_key]] + + body = + card + |> token_params() + |> Poison.encode!() + + {state, res} = commit(:post, "/v1/card_tokens/#{opts[:customer_id]}", body, opts, url_params) + + case state do + :error -> {state, res} + _ -> {state, res.id} + end + end + + defp authorize_params(value, opts, token_id, capture, %CreditCard{} = card) do + %{ + installments: opts[:installments] || 1, + transaction_amount: value, + payment_method_id: String.downcase(card.brand), + token: token_id, + capture: capture, + payer: %{ + email: opts[:email] + } + } + end + + defp customer_params(%CreditCard{} = card, customer_id, opts) do + %{ + payer: %{ + type: "customer", + id: customer_id, + first_name: card.first_name, + last_name: card.last_name + }, + order: %{ + type: opts[:order_type], + id: opts[:order_id] + } + } + end + + defp success_body(body, status_code, opts) do + %Response{ + success: true, + id: body["id"], + token: opts[:customer_id], + status_code: status_code, + message: body["status"] + } + end + + defp error_body(body, status_code, opts) do + %Response{ + success: false, + token: opts[:customer_id], + status_code: status_code, + message: body["message"] + } + end + + defp respond({:ok, %HTTPoison.Response{body: body, status_code: status_code}}, opts) do + body = body |> Poison.decode!() + + case body["cause"] do + nil -> {:ok, success_body(body, status_code, opts)} + _ -> {:error, error_body(body, status_code, opts)} + end + end + + defp respond({:error, %HTTPoison.Error{} = error}, _) do + { + :error, + Response.error( + reason: "network related failure", + message: "HTTPoison says '#{error.reason}' [ID: #{error.id || "nil"}]" + ) + } + end +end diff --git a/test/gateways/mercadopago_test.exs b/test/gateways/mercadopago_test.exs new file mode 100644 index 00000000..3226712f --- /dev/null +++ b/test/gateways/mercadopago_test.exs @@ -0,0 +1,234 @@ +defmodule Gringotts.Gateways.MercadopagoTest do + use ExUnit.Case, async: false + alias Gringotts.Gateways.MercadopagoMock, as: MockResponse + alias Gringotts.{CreditCard, FakeMoney} + alias Gringotts.Gateways.Mercadopago, as: Merc + + import Mock + + @auth %{name: "64jKa6NA", transaction_key: "4vmE338dQmAN6m7B"} + @card %CreditCard{ + number: "5424000000000015", + month: 12, + year: 2099, + verification_code: 999, + brand: "visa" + } + + @bad_card %CreditCard{ + number: "123", + month: 10, + year: 2010, + verification_code: 123, + brand: "visa" + } + + @amount FakeMoney.new("2.99", :USD) + + @opts [ + config: @auth, + ref_id: "123456", + order: %{invoice_number: "INV-12345", description: "Product Description"}, + lineitems: %{ + item_id: "1", + name: "vase", + description: "Cannes logo", + quantity: 18, + unit_price: FakeMoney.new("53.82", :USD) + }, + tax: %{name: "VAT", amount: FakeMoney.new("0.1", :EUR), description: "Value Added Tax"}, + shipping: %{ + name: "SAME-DAY-DELIVERY", + amount: FakeMoney.new("0.56", :EUR), + description: "Zen Logistics" + }, + duty: %{ + name: "import_duty", + amount: FakeMoney.new("0.25", :EUR), + description: "Upon import of goods" + } + ] + @opts_refund [ + config: @auth, + ref_id: "123456", + payment: %{card: %{number: "5424000000000015", year: 2099, month: 12}} + ] + + @opts_store [ + config: @auth, + profile: %{ + merchant_customer_id: "123456", + description: "Profile description here", + email: "customer-profile-email@here.com" + }, + customer_type: "individual", + validation_mode: "testMode" + ] + @opts_store_without_validation [ + config: @auth, + profile: %{ + merchant_customer_id: "123456", + description: "Profile description here", + email: "customer-profile-email@here.com" + } + ] + + @opts_store_no_profile [ + config: @auth + ] + @opts_refund [ + config: @auth, + ref_id: "123456", + payment: %{card: %{number: "5424000000000015", year: 2099, month: 12}} + ] + @opts_refund_bad_payment [ + config: @auth, + ref_id: "123456", + payment: %{card: %{number: "123", year: 2099, month: 12}} + ] + @opts_store [ + config: @auth, + profile: %{ + merchant_customer_id: "123456", + description: "Profile description here", + email: "customer-profile-email@here.com" + } + ] + @opts_store_no_profile [ + config: @auth + ] + @opts_customer_profile [ + config: @auth, + customer_profile_id: "1814012002", + validation_mode: "testMode", + customer_type: "individual" + ] + @opts_customer_profile_args [ + config: @auth, + customer_profile_id: "1814012002" + ] + + @refund_id "60036752756" + @void_id "60036855217" + @void_invalid_id "60036855211" + @unstore_id "1813991490" + @capture_id "60036752756" + @capture_invalid_id "60036855211" + + @refund_id "60036752756" + @void_id "60036855217" + @unstore_id "1813991490" + + describe "purchase" do + test "successful response with right params" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers, _options -> + MockResponse.successful_purchase_response() + end do + assert {:ok, _response} = Merc.purchase(@amount, @card, @opts) + end + end + + test "with bad card" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers, _options -> + MockResponse.bad_card_purchase_response() + end do + assert {:error, _response} = Merc.purchase(@amount, @bad_card, @opts) + end + end + end + + describe "authorize" do + test "successful response with right params" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers, _options -> + MockResponse.successful_authorize_response() + end do + assert {:ok, _response} = Merc.authorize(@amount, @card, @opts) + end + end + + test "with bad card" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers, _options -> + MockResponse.bad_card_purchase_response() + end do + assert {:error, _response} = Merc.authorize(@amount, @bad_card, @opts) + end + end + end + + describe "capture" do + test "successful response with right params" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers, _options -> + MockResponse.successful_capture_response() + end do + assert {:ok, _response} = Merc.capture(@capture_id, @amount, @opts) + end + end + + test "with bad transaction id" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers, _options -> MockResponse.bad_id_capture() end do + assert {:error, _response} = Merc.capture(@capture_invalid_id, @amount, @opts) + end + end + end + + describe "refund" do + test "successful response with right params" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers, _options -> + MockResponse.successful_refund_response() + end do + assert {:ok, _response} = Merc.refund(@amount, @refund_id, @opts_refund) + end + end + + test "bad payment params" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers, _options -> MockResponse.bad_card_refund() end do + assert {:error, _response} = Merc.refund(@amount, @refund_id, @opts_refund_bad_payment) + end + end + + test "debit less than refund amount" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers, _options -> + MockResponse.debit_less_than_refund() + end do + assert {:error, _response} = Merc.refund(@amount, @refund_id, @opts_refund) + end + end + end + + describe "void" do + test "successful response with right params" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers, _options -> MockResponse.successful_void() end do + assert {:ok, _response} = Merc.void(@void_id, @opts) + end + end + + test "with bad transaction id" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers, _options -> + MockResponse.void_non_existent_id() + end do + assert {:error, _response} = Merc.void(@void_invalid_id, @opts) + end + end + end + + test "network error type non existent domain" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers, _options -> + MockResponse.netwok_error_non_existent_domain() + end do + assert {:error, response} = Merc.purchase(@amount, @card, @opts) + assert response.message == "HTTPoison says 'nxdomain' [ID: nil]" + end + end +end diff --git a/test/integration/gateways/mercadopago_test.exs b/test/integration/gateways/mercadopago_test.exs new file mode 100644 index 00000000..95da249a --- /dev/null +++ b/test/integration/gateways/mercadopago_test.exs @@ -0,0 +1,241 @@ +defmodule Gringotts.Integration.Gateways.MercadopagoTest do + # Integration tests for the Mercadopago + + use ExUnit.Case, async: true + alias Gringotts.Gateways.Mercadopago, as: Gateway + + alias Gringotts.{ + CreditCard, + FakeMoney + } + + @moduletag integration: true + + @amount FakeMoney.new(45, :BRL) + @sub_amount FakeMoney.new(30, :BRL) + @config [ + access_token: "TEST-4543588471539213-040810-f4f850f89480ee1bd56e05a9aa0d6210-543713181", + public_key: "TEST-4508ea76-c56b-436a-9213-57934dfb2d86" + ] + @bad_config [ + access_token: "TEST-4543588471539213-111111-f4f850f89480ee1bd56e05a9aa0d6210-543713181", + public_key: "TEST-4508ea76-c56b-436a-9999-57934dfb2d86" + ] + @good_card %CreditCard{ + first_name: "Hermoine", + last_name: "Grangerr", + number: "4509953566233704", + year: 2030, + month: 07, + verification_code: "123", + brand: "VISA" + } + + @bad_card %CreditCard{ + first_name: "Hermoine", + last_name: "Grangerr", + number: "4509953566233704", + year: 2000, + month: 07, + verification_code: "123", + brand: "VISA" + } + + @good_opts [ + email: "hermoine@granger.com", + order_id: 123_126, + customer_id: "311211654-YrXF6J0QikpIWX", + config: @config, + installments: 1, + order_type: "mercadopago" + ] + @bad_opts [ + email: "hermoine@granger.com", + config: @bad_config + ] + + @new_cutomer_good_opts [ + order_id: 123_126, + config: @config, + installments: 1, + order_type: "mercadopago" + ] + @new_cutomer_bad_opts [ + config: @bad_config, + order_id: 123_127 + ] + + def new_email_opts(good) do + no1 = 100_000 |> :rand.uniform() |> to_string + no2 = 100_000 |> :rand.uniform() |> to_string + no3 = 100_000 |> :rand.uniform() |> to_string + email = "hp" <> no1 <> no2 <> no3 <> "@potter.com" + + case good do + true -> @new_cutomer_good_opts ++ [email: email] + _ -> @new_cutomer_bad_opts ++ [email: email] + end + end + + describe "[authorize] old customer" do + test "old customer with good_opts and good_card" do + assert {:ok, response} = Gateway.authorize(@amount, @good_card, @good_opts) + assert response.success == true + assert response.status_code == 201 + end + + test "old customer with good_opts and bad_card" do + assert {:error, response} = Gateway.authorize(@amount, @bad_card, @good_opts) + assert response.success == false + assert response.status_code == 400 + end + end + + setup do + [opts: new_email_opts(true)] + end + + describe "[authorize] new customer" do + test "new cutomer with good_opts and good_card", %{opts: opts} do + assert {:ok, response} = Gateway.authorize(@amount, @good_card, opts) + assert response.success == true + assert response.status_code == 201 + end + + test "new customer with good_opts and bad_card", %{opts: opts} do + assert {:error, response} = Gateway.authorize(@amount, @bad_card, opts) + assert response.success == false + assert response.status_code == 400 + end + end + + describe "[capture]" do + test "capture success" do + {:ok, response} = Gateway.authorize(@sub_amount, @good_card, @good_opts) + {:ok, response} = Gateway.capture(response.id, @sub_amount, @good_opts) + assert response.success == true + assert response.status_code == 200 + end + + test "capture invalid payment_id" do + {:ok, response} = Gateway.authorize(@sub_amount, @good_card, @good_opts) + id = response.id + 1 + {:error, response} = Gateway.capture(id, @sub_amount, @good_opts) + assert response.success == false + assert response.status_code == 404 + end + + test "extra amount capture" do + {:ok, response} = Gateway.authorize(@sub_amount, @good_card, @good_opts) + {:error, response} = Gateway.capture(response.id, @amount, @good_opts) + assert response.success == false + assert response.status_code == 400 + end + end + + describe "[void]" do + test "void success" do + {:ok, response} = Gateway.authorize(@sub_amount, @good_card, @good_opts) + {:ok, response} = Gateway.void(response.id, @good_opts) + assert response.success == true + assert response.status_code == 200 + end + + test "invalid payment_id" do + {:ok, response} = Gateway.authorize(@sub_amount, @good_card, @good_opts) + id = response.id + 1 + {:error, response} = Gateway.void(id, @good_opts) + assert response.success == false + assert response.status_code == 404 + end + end + + describe "[purchase]" do + test "old customer with good_opts and good_card" do + assert {:ok, response} = Gateway.purchase(@amount, @good_card, @good_opts) + assert response.success == true + assert response.status_code == 201 + end + + test "old customer with good_opts and bad_card" do + assert {:error, response} = Gateway.purchase(@amount, @bad_card, @good_opts) + assert response.success == false + assert response.status_code == 400 + end + + test "old customer with bad_opts and good_card" do + assert {:error, response} = Gateway.purchase(@amount, @good_card, @bad_opts) + assert response.success == false + # We expect 401-Unauthorized when bad access_token is provided. + # But mergadopago API returns 404 with message "invalid token" instead. + # So that is what we check + assert response.status_code == 404 + assert response.message == "invalid_token" + end + + test "old customer with bad_opts and bad_card" do + assert {:error, response} = Gateway.purchase(@amount, @bad_card, @bad_opts) + assert response.success == false + assert response.status_code == 400 + assert response.message == "invalid expiration_year" + end + + test "new cutomer with good_opts and good_card" do + opts = new_email_opts(true) + assert {:ok, response} = Gateway.purchase(@amount, @good_card, opts) + assert response.success == true + assert response.status_code == 201 + end + + test "new customer with good_opts and bad_card" do + opts = new_email_opts(true) + assert {:error, response} = Gateway.purchase(@amount, @bad_card, opts) + assert response.success == false + assert response.status_code == 400 + assert response.message == "invalid expiration_year" + end + + test "new customer with bad_opts and good_card" do + opts = new_email_opts(false) + assert {:error, response} = Gateway.purchase(@amount, @good_card, opts) + assert response.success == false + # We expect 401-Unauthorized when bad access_token is provided. + # But mergadopago API returns 404 with message "invalid token" instead. + # So that is what we check + assert response.status_code == 404 + assert response.message == "invalid_token" + end + + test "new customer with bad_opts and bad_card" do + opts = new_email_opts(false) + assert {:error, response} = Gateway.purchase(@amount, @bad_card, opts) + assert response.success == false + assert response.status_code == 400 + assert response.message == "invalid expiration_year" + end + end + + describe "[refund]" do + test "refund success" do + {:ok, response} = Gateway.purchase(@sub_amount, @good_card, @good_opts) + {:ok, response} = Gateway.refund(@sub_amount, response.id, @good_opts) + assert response.success == true + assert response.status_code == 201 + end + + test "invalid payment_id" do + {:ok, response} = Gateway.purchase(@sub_amount, @good_card, @good_opts) + id = response.id + 1 + {:error, response} = Gateway.refund(@sub_amount, id, @good_opts) + assert response.success == false + assert response.status_code == 404 + end + + test "extra amount refund" do + {:ok, response} = Gateway.purchase(@sub_amount, @good_card, @good_opts) + {:error, response} = Gateway.refund(@amount, response.id, @good_opts) + assert response.success == false + assert response.status_code == 400 + end + end +end diff --git a/test/support/mocks/mercadopago_mock.ex b/test/support/mocks/mercadopago_mock.ex new file mode 100644 index 00000000..f1c18779 --- /dev/null +++ b/test/support/mocks/mercadopago_mock.ex @@ -0,0 +1,226 @@ +defmodule Gringotts.Gateways.MercadopagoMock do + @base_url "https://api.mercadopago.com" + + @moduledoc false + # purchase mock response + def successful_purchase_response do + {:ok, + %HTTPoison.Response{ + # body: {"id": 20359978, "date_created": "2019-07-10T10:47:58.000-04:00", "date_approved": "2019-07-10T10:47:58.000-04:00", "date_last_updated": "2019-07-10T10:47:58.000-04:00", "date_of_expiration": null, "money_release_date": "2019-07-24T10:47:58.000-04:00", "operation_type": "regular_payment", "issuer_id": "25", "payment_method_id": "visa", "payment_type_id": "credit_card", "status": "approved"}, + body: + "{\"id\":24687003,\"date_created\":\"2019-07-10T03:23:38.000-04:00\",\"date_approved\":\"2019-07-10T03:23:38.000-04:00\",\"date_last_updated\":\"2019-07-10T03:23:38.000-04:00\",\"date_of_expiration\":null,\"money_release_date\":\"2020-04-14T03:23:38.000-04:00\",\"operation_type\":\"regular_payment\",\"issuer_id\":\"25\",\"payment_method_id\":\"visa\",\"payment_type_id\":\"credit_card\",\"status\":\"approved\",\"status_detail\":\"accredited\",\"currency_id\":\"BRL\",\"description\":null,\"live_mode\":false,\"sponsor_id\":null,\"authorization_code\":null,\"money_release_schema\":null,\"taxes_amount\":0,\"counter_currency\":null,\"shipping_amount\":0,\"pos_id\":null,\"store_id\":null,\"integrator_id\":null,\"platform_id\":null,\"corporation_id\":null,\"collector_id\":543713181,\"payer\":{\"first_name\":\"Test\",\"last_name\":\"Test\",\"email\":\"test_user_80507629@testuser.com\",\"identification\":{\"number\":\"32659430\",\"type\":\"DNI\"},\"phone\":{\"area_code\":\"01\",\"number\":\"1111-1111\",\"extension\":\"\"},\"type\":\"registered\",\"entity_type\":null,\"id\":\"546439110\"},\"marketplace_owner\":null,\"metadata\":{},\"additional_info\":{\"available_balance\":null,\"nsu_processadora\":null},\"order\":{},\"external_reference\":null,\"transaction_amount\":4500,\"transaction_amount_refunded\":0,\"coupon_amount\":0,\"differential_pricing_id\":null,\"deduction_schema\":null,\"installments\":1,\"transaction_details\":{\"payment_method_reference_id\":null,\"net_received_amount\":4275.45,\"total_paid_amount\":4500,\"overpaid_amount\":0,\"external_resource_url\":null,\"installment_amount\":4500,\"financial_institution\":null,\"payable_deferral_period\":null,\"acquirer_reference\":null},\"fee_details\":[{\"type\":\"mercadopago_fee\",\"amount\":224.55,\"fee_payer\":\"collector\"}],\"captured\":true,\"binary_mode\":false,\"call_for_authorize_id\":null,\"statement_descriptor\":\"ASHISHSINGH\",\"card\":{\"id\":null,\"first_six_digits\":\"450995\",\"last_four_digits\":\"3704\",\"expiration_month\":7,\"expiration_year\":2030,\"date_created\":\"2020-04-14T03:23:38.000-04:00\",\"date_last_updated\":\"2020-04-14T03:23:38.000-04:00\",\"cardholder\":{\"name\":\"Hermoine Grangerr\",\"identification\":{\"number\":null,\"type\":null}}},\"notification_url\":null,\"refunds\":[],\"processing_mode\":\"aggregator\",\"merchant_account_id\":null,\"acquirer\":null,\"merchant_number\":null,\"acquirer_reconciliation\":[]}", + headers: [ + {"Content-Type", "application/json;charset=UTF-8"}, + {"Transfer-Encoding", "chunked"}, + {"Connection", "keep-alive"}, + {"Cache-Control", "max-age=0"}, + {"ETag", "6d6d1b4a79c868769305de14687c4d6d"}, + {"Vary", "Accept,Accept-Encoding,Accept-Encoding"}, + {"X-Caller-Id", "543713181"}, + {"X-Response-Status", "approved/accredited"}, + {"X-Site-Id", "MLB"}, + {"X-Content-Type-Options", "nosniff"}, + {"X-Request-Id", "59257cf5-6cc6-4afc-b310-e36e412ad4fc"}, + {"X-XSS-Protection", "1; mode=block"}, + {"Strict-Transport-Security", "max-age=16070400; includeSubDomains; preload"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Headers", "Content-Type"}, + {"Access-Control-Allow-Methods", "PUT, GET, POST, DELETE, OPTIONS"}, + {"Access-Control-Max-Age", "86400"}, + {"Timing-Allow-Origin", "*"}, + {"Date", "Mon, 25 Dec 2017 14:17:56 GMT"} + ], + request_url: "#{@base_url}/v1/payments", + status_code: 200 + }} + end + + def bad_card_purchase_response do + {:error, + %HTTPoison.Error{ + reason: "Bad Card for Purchase", + id: 1_234_567 + }} + end + + def bad_amount_purchase_response do + {:error, + %HTTPoison.Response{ + body: + "{\"id\": 20359978, \"date_created\": \"2019-07-10T10:47:58.000-04:00\", \"date_approved\": \"2019-07-10T10:47:58.000-04:00\", \"date_last_updated\": \"2019-07-10T10:47:58.000-04:00\", \"date_of_expiration\": null, \"money_release_date\": \"2019-07-24T10:47:58.000-04:00\", \"operation_type\": \"regular_payment\", \"issuer_id\": \"25\", \"payment_method_id\": \"visa\", \"payment_type_id\": \"credit_card\", \"status\": \"approved\"}", + headers: [ + {"Content-Type", "application/json"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Date", "Mon, 25 Dec 2017 14:17:56 GMT"} + ], + request_url: "#{@base_url}/v1/payments", + status_code: 200 + }} + end + + # authorize mock response + def successful_authorize_response do + {:ok, + %HTTPoison.Response{ + body: + "{\"id\": 20359978, \"date_created\": \"2019-07-10T10:47:58.000-04:00\", \"date_approved\": \"2019-07-10T10:47:58.000-04:00\", \"date_last_updated\": \"2019-07-10T10:47:58.000-04:00\", \"date_of_expiration\": null, \"money_release_date\": \"2019-07-24T10:47:58.000-04:00\", \"operation_type\": \"regular_payment\", \"issuer_id\": \"25\", \"payment_method_id\": \"visa\", \"payment_type_id\": \"credit_card\", \"status\": \"approved\"}", + headers: [ + {"Content-Type", "application/json"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Date", "Mon, 25 Dec 2017 14:17:56 GMT"} + ], + request_url: "#{@base_url}/v1/payments", + status_code: 200 + }} + end + + def bad_card_authorize_response do + {:ok, + %HTTPoison.Response{ + body: + "{\"id\": 20359978, \"date_created\": \"2019-07-10T10:47:58.000-04:00\", \"date_approved\": \"2019-07-10T10:47:58.000-04:00\", \"date_last_updated\": \"2019-07-10T10:47:58.000-04:00\", \"date_of_expiration\": null, \"money_release_date\": \"2019-07-24T10:47:58.000-04:00\", \"operation_type\": \"regular_payment\", \"issuer_id\": \"25\", \"payment_method_id\": \"visa\", \"payment_type_id\": \"credit_card\", \"status\": \"approved\"}", + headers: [ + {"Content-Type", "application/json"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Date", "Mon, 25 Dec 2017 14:17:56 GMT"} + ], + request_url: "#{@base_url}/v1/payments", + status_code: 200 + }} + end + + def bad_amount_authorize_response do + {:ok, + %HTTPoison.Response{ + body: + "{\"id\": 20359978, \"date_created\": \"2019-07-10T10:47:58.000-04:00\", \"date_approved\": \"2019-07-10T10:47:58.000-04:00\", \"date_last_updated\": \"2019-07-10T10:47:58.000-04:00\", \"date_of_expiration\": null, \"money_release_date\": \"2019-07-24T10:47:58.000-04:00\", \"operation_type\": \"regular_payment\", \"issuer_id\": \"25\", \"payment_method_id\": \"visa\", \"payment_type_id\": \"credit_card\", \"status\": \"approved\"}", + headers: [ + {"Content-Type", "application/json"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Date", "Mon, 25 Dec 2017 14:17:56 GMT"} + ], + request_url: "#{@base_url}/v1/payments", + status_code: 200 + }} + end + + # capture mock response + + def successful_capture_response do + {:ok, + %HTTPoison.Response{ + body: + "{\"id\": 20359978, \"date_created\": \"2019-07-10T10:47:58.000-04:00\", \"date_approved\": \"2019-07-10T10:47:58.000-04:00\", \"date_last_updated\": \"2019-07-10T10:47:58.000-04:00\", \"date_of_expiration\": null, \"money_release_date\": \"2019-07-24T10:47:58.000-04:00\", \"operation_type\": \"regular_payment\", \"issuer_id\": \"25\", \"payment_method_id\": \"visa\", \"payment_type_id\": \"credit_card\", \"status\": \"approved\"}", + headers: [ + {"Content-Type", "application/json"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Date", "Mon, 25 Dec 2017 14:17:56 GMT"} + ], + request_url: "#{@base_url}/v1/payments", + status_code: 200 + }} + end + + def bad_id_capture do + {:error, + %HTTPoison.Error{ + reason: "Bad ID for Capture", + id: 1_234_567 + }} + end + + # refund mock response + def successful_refund_response do + {:ok, + %HTTPoison.Response{ + body: + "{\"id\": 20359978, \"payment_id\":24686811, \"unique_sequence_number\":null,\"refund_mode\":\"standard\",\"status\":\"approved\", \"source\":{\"id\":\"543713181\",\"name\":\"Developer Testing\",\"type\":\"collector\"} }", + headers: [ + {"Date", "Mon, 25 Dec 2017 14:17:56 GMT"}, + {"Content-Type", "application/json"}, + {"Transfer-Encoding", "chunked"}, + {"Connection", "keep-alive"}, + {"Cache-Control", "max-age=0"}, + {"ETag", "e2894bf98b818a4f49a3bd1065a3d9b8"}, + {"Vary", "Accept,Accept-Encoding,Accept-Encoding"}, + {"X-Content-Type-Options", "nosniff"}, + {"X-Request-Id", "f6d28d4c-ce70-4cf4-ac82-5733f826eef6"}, + {"X-XSS-Protection", "1; mode=block"}, + {"Strict-Transport-Security", "max-age=16070400; includeSubDomains; preload"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Headers", "Content-Type"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Max-Age", "86400"}, + {"Timing-Allow-Origin", "*"} + ], + request_url: "#{@base_url}/v1/payments", + status_code: 200 + }} + end + + def bad_card_refund do + {:error, + %HTTPoison.Error{ + reason: "Bad Card for refund", + id: 1_234_567 + }} + end + + def debit_less_than_refund do + {:error, + %HTTPoison.Error{ + reason: "Debit less than refund", + id: 1_234_567 + }} + end + + # void mock response + def successful_void do + {:ok, + %HTTPoison.Response{ + body: + "{\"id\": 20359978, \"date_created\": \"2019-07-10T10:47:58.000-04:00\", \"date_approved\": \"2019-07-10T10:47:58.000-04:00\", \"date_last_updated\": \"2019-07-10T10:47:58.000-04:00\", \"date_of_expiration\": null, \"money_release_date\": \"2019-07-24T10:47:58.000-04:00\", \"operation_type\": \"regular_payment\", \"issuer_id\": \"25\", \"payment_method_id\": \"visa\", \"payment_type_id\": \"credit_card\", \"status\": \"approved\"}", + headers: [ + {"Content-Type", "application/json"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Date", "Mon, 25 Dec 2017 14:17:56 GMT"} + ], + request_url: "#{@base_url}/v1/payments", + status_code: 200 + }} + end + + def void_non_existent_id do + {:error, + %HTTPoison.Error{ + reason: "Transaction ID does not exist for Void", + id: 1_234_567 + }} + end + + def customer_payment_profile_success_response do + {:ok, + %HTTPoison.Response{ + body: + "{\"id\": 20359978, \"date_created\": \"2019-07-10T10:47:58.000-04:00\", \"date_approved\": \"2019-07-10T10:47:58.000-04:00\", \"date_last_updated\": \"2019-07-10T10:47:58.000-04:00\", \"date_of_expiration\": null, \"money_release_date\": \"2019-07-24T10:47:58.000-04:00\", \"operation_type\": \"regular_payment\", \"issuer_id\": \"25\", \"payment_method_id\": \"visa\", \"payment_type_id\": \"credit_card\", \"status\": \"approved\"}", + headers: [ + {"Content-Type", "application/json"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Date", "Mon, 25 Dec 2017 14:17:56 GMT"} + ], + request_url: "#{@base_url}/v1/payments", + status_code: 200 + }} + end + + def netwok_error_non_existent_domain do + {:error, %HTTPoison.Error{id: nil, reason: :nxdomain}} + end +end