diff --git a/.credo.exs b/.credo.exs index df92ae85..9381d3f7 100644 --- a/.credo.exs +++ b/.credo.exs @@ -77,7 +77,7 @@ {Credo.Check.Readability.FunctionNames}, {Credo.Check.Readability.LargeNumbers}, - {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 80}, + {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 100}, {Credo.Check.Readability.ModuleAttributeNames}, {Credo.Check.Readability.ModuleDoc}, {Credo.Check.Readability.ModuleNames}, diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 00000000..aa758aff --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,7 @@ +[ + inputs: [ + "{lib,config}/**/*.{ex,exs}", # lib and config + "test/**/*.{ex,exs}", # tests + "mix.exs" + ] +] diff --git a/.scripts/inch_report.sh b/.scripts/inch_report.sh new file mode 100644 index 00000000..a352261d --- /dev/null +++ b/.scripts/inch_report.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -e +bold=$(tput bold) +purple='\e[106m' +normal=$(tput sgr0) +allowed_branches="^(master)|(develop)$" + +echo -e "${bold}${purple}" +if [ $TRAVIS_PULL_REQUEST = false ]; then + if [[ $TRAVIS_BRANCH =~ $allowed_branches ]]; then + env MIX_ENV=docs mix deps.get + env MIX_ENV=docs mix inch.report + else + echo "Skipping Inch CI report because this branch does not match on /$allowed_branches/" + fi +else + echo "Skipping Inch CI report because this is a PR build" +fi +echo -e "${normal}" diff --git a/.scripts/post-commit b/.scripts/post-commit new file mode 100755 index 00000000..ada60194 --- /dev/null +++ b/.scripts/post-commit @@ -0,0 +1,12 @@ +#!/bin/sh +# +# Runs credo and the formatter on the staged files, after the commit is made +# This is purely for notification and will not halt/change your commit. + +RED='\033[1;31m' +LGRAY='\033[1;30m' +NC='\033[0m' # No Color + +printf "${RED}Running 'mix credo --strict --format=oneline' on project...${NC}\n" +mix credo --strict --format=oneline +echo diff --git a/.scripts/pre-commit b/.scripts/pre-commit new file mode 100755 index 00000000..1f473c8c --- /dev/null +++ b/.scripts/pre-commit @@ -0,0 +1,50 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=4b825dc642cb6eb9a060e54bf8d69288fbee4904 +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# Also run the mix format task, just check though. +exec mix format --check-formatted + diff --git a/.travis.yml b/.travis.yml index 82c3f5a9..a238ae93 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,28 @@ language: elixir -elixir: - - 1.5.2 + otp_release: - - 20.1 + - 20.2 before_install: - mix local.hex --force - mix local.rebar --force - mix deps.get script: - - mix coveralls.travis --include integration + - set -e + - MIX_ENV=test mix format --check-formatted + - set +e + - mix coveralls.json --include=integration after_script: - - MIX_ENV=docs mix deps.get - - MIX_ENV=docs mix inch.report + - bash <(curl -s https://codecov.io/bash) + - bash .scripts/inch_report.sh + +matrix: + include: + - elixir: "1.5.3" + script: + - mix coveralls.json --include=integration + - elixir: "1.6.2" + +notifications: + email: + recipients: + - ananya95+travis@gmail.com diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..5a1f16ad --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# [`v1.1.0-alpha`][tag-1_1_0_alpha] + +## Added + +* [`ISS`][iss#80] [`PR`][pr#78] +Add a `Mix` task that generates a barebones gateway implementation and test suite. + +## Changed + +* [`ISS`][iss#62] [`PR`][pr#71] [`PR`][pr#86] +Deprecate use of `floats` for money amounts, introduce the `Gringotts.Money` protocol. + +[iss#62]: https://github.com/aviabird/gringotts/issues/62 +[iss#80]: https://github.com/aviabird/gringotts/issues/80 + +[pr#71]: https://github.com/aviabird/gringotts/pulls/71 +[pr#78]:https://github.com/aviabird/gringotts/pulls/78 +[pr#86]:https://github.com/aviabird/gringotts/pulls/86 + +# [`v1.0.2`][tag-1_0_2] + +## Added + +* New Gateway: **Trexle** + +## Changed + +* Reduced arity of public API calls by 1 + - No need to pass the name of the `worker` as argument. + +# [`v1.0.1`][tag-1_0_1] + +## Added + +* Improved documentation - made consistent accross gateways +* Improved test coverage + +# [`v1.0.0`][tag-1_0_0] + +* Initial public API release. +* Single worker architecture, config fetched from `config.exs` +* Supported Gateways: + - Stripe + - MONEI + - Paymill + - WireCard + - CAMS + +[tag-1_1_0_alpha]: https://github.com/aviabird/gringotts/releases/tag/v1.1.0-alpha +[tag-1_0_2]: https://github.com/aviabird/gringotts/releases/tag/v1.0.2 +[tag-1_0_1]: https://github.com/aviabird/gringotts/releases/tag/v1.0.1 +[tag-1_0_0]: https://github.com/aviabird/gringotts/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..f096ca9d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,126 @@ +# Contributing to [`gringotts`][gringotts] + +There are many ways to contribute to `gringotts`, + +1. [Integrate a new Payment Gateway][wiki-new-gateway]. +2. Expanding the feature coverage of (partially) supported gateways. +3. Moving forward on the [roadmap][roadmap] or on tasks being tracked in the + [milestones][milestones]. + +We manage our development using [milestones][milestones] and issues so if you're +a first time contributor, look out for the [`good first issue`][first-issues] +and the [`hotlist: community-help`][ch-issues] labels on the [issues][issues] +page. + +The docs are hosted on [hexdocs.pm][hexdocs] and are updated for each +release. **You must build the docs locally using `mix docs` to get the bleeding +edge developer docs.** + +The article on [Gringott's Architecture][wiki-arch] explains how API calls are +processed. + +:exclamation: ***Please base your work on the `dev` branch.*** + +[roadmap]: https://github.com/aviabird/gringotts/wiki/Roadmap +[wiki-arch]: https://github.com/aviabird/gringotts/wiki/Architecture + +# Style Guidelines + +We follow +[lexmag/elixir-style-guide](https://github.com/lexmag/elixir-style-guide) and +[rrrene/elixir-style-guide](https://github.com/rrrene/elixir-style-guide) (both +overlap a lot), and use the elixir formatter. + +To enforce these, and also to make it easier for new contributors to adhere to +our style, we've provided a collection of handy `git-hooks` under the `.scripts/` +directory. + +* `.scripts/pre-commit` Runs the `format --check-formatted` task. +* `.scripts/post-commit` Runs a customised `credo` check. + +While we do not force you to use these hooks, you could write your +very own by taking inspiration from ours :smile: + +To set the `git-hooks` as provided, go to the repo root, +```sh +cd path/to/gringotts/ +``` +and make these symbolic links: +```sh +ln -s .scripts/pre-commit .git/hooks/pre-commit +ln -s .scripts/post-commit .git/hooks/post-commit +``` + +> Note that our CI will fail your PR if you dont run `mix format` in the project +> root. + +## General Rules + +* Keep line length below 100 characters. +* Complex anonymous functions should be extracted into named functions. +* One line functions, should only take up one line! +* Pipes are great, but don't use them if they are less readable than brackets! + +## Writing documentation + +All our docs are inline and built using [`ExDocs`][exdocs]. Please take a look +at how the docs are structured for the [MONEI gateway][src-monei] for +inspiration. + +[exdocs]: https://github.com/elixir-lang/ex_doc +[src-monei]: https://github.com/aviabird/gringotts/blob/dev/lib/gringotts/gateways/monei.ex + +## Writing test cases + +> This is WIP. + +`gringotts` has mock and integration tests. We have currently used +[`bypass`][bypass] and [`mock`][mock] for mock tests, but we don't recommed +using `mock` as it constrains tests to run serially. Use [`mox`][mox] instead.\ +Take a look at [MONEI's mock tests][src-monei-tests] for inspiration. + +# PR submission checklist + +Each PR should introduce a *focussed set of changes*, and ideally not span over +unrelated modules. + +* [ ] Format the project with the Elixir formatter. + ```sh + cd path/to/gringotts/ + mix format + ``` +* [ ] Run the edited files through [credo][credo] with the `--strict` flag. + ```sh + cd path/to/gringotts/ + mix credo --strict + ``` +* [ ] Check the test coverage by running `mix coveralls`. 100% coverage is not + strictly required. +* [ ] If the PR introduces a new Gateway or just Gateway specific changes, + please format the title like so,\ + `[] ` + +> **Note** +> You can skip the first two steps if you have set up `git-hooks` as we have +> provided! + +[gringotts]: https://github.com/aviabird/gringotts +[milestones]: https://github.com/aviabird/gringotts/milestones +[issues]: https://github.com/aviabird/gringotts/issues +[first-issues]: https://github.com/aviabird/gringotts/issues?q=is%3Aissue+is%3Aopen+label%3A"good+first+issue" +[ch-issues]: https://github.com/aviabird/gringotts/issues?q=is%3Aissue+is%3Aopen+label%3A"hotfix%3A+community-help" +[hexdocs]: https://hexdocs.pm/gringotts +[credo]: https://github.com/rrrene/credo + +-------------------------------------------------------------------------------- + +> **Where to next?** +> Wanna add a new gateway? Head to our [guide][wiki-new-gateway] for that. + +[wiki-new-gateway]: https://github.com/aviabird/gringotts/wiki/Adding-a-new-Gateway +[bypass]: https://github.com/pspdfkit-labs/bypass +[mock]: https://github.com/jjh42/mock +[mox]: https://github.com/plataformatec/mox +[src-monei-tests]: https://github.com/aviabird/gringotts/blob/dev/test/gateways/monei_test.exs +[gringotts]: https://github.com/aviabird/gringotts +[docs]: https://hexdocs.pm/gringotts/Gringotts.html diff --git a/README.md b/README.md index fb508bd4..ce98c205 100644 --- a/README.md +++ b/README.md @@ -5,105 +5,131 @@

- Gringotts is a payment processing library in Elixir integrating various payment gateways, this draws motivation for shopify's activemerchant gem. + Gringotts is a payment processing library in Elixir integrating various payment gateways, drawing motivation for Shopify's activemerchant gem. Checkout the Demo here.

Build Status Coverage Status Docs coverage + Help Contribute to Open Source

-A simple and unified API to access dozens of different payment -gateways with very different internal APIs is what Gringotts has to offer you. +Gringotts offers a **simple and unified API** to access dozens of different payment +gateways with very different APIs, response schemas, documentation and jargon. ## Installation -### From hex.pm +### From [`hex.pm`][hexpm] -Make the following changes to the `mix.exs` file. - -Add gringotts to the list of dependencies. +Add `gringotts` to the list of dependencies of your application. ```elixir -def deps do - [ - {:gringotts, "~> 1.0"} - ] -end -``` +# your mix.exs -Add gringotts to the list of applications to be started. -```elixir -def application do +def deps do [ - extra_applications: [:gringotts] + {:gringotts, "~> 1.0"}, + # ex_money provides an excellent Money library, and integrates + # out-of-the-box with Gringotts + {:ex_money, "~> 1.1.0"} ] end ``` ## Usage -This simple example demonstrates how a purchase can be made using a person's credit card details. +This simple example demonstrates how a `purchase` can be made using a sample +credit card using the [MONEI][monei] gateway. -Add configs in `config/config.exs` file. +One must "register" their account with `gringotts` ie, put all the +authentication details in the Application config. Usually via +`config/config.exs` ```elixir -config :gringotts, Gringotts.Gateways.Stripe, - adapter: Gringotts.Gateways.Stripe, - secret_key: "YOUR_KEY", - default_currency: "USD" +# config/config.exs +config :gringotts, Gringotts.Gateways.Monei, + userId: "your_secret_user_id", + password: "your_secret_password", + entityId: "your_secret_channel_id" ``` -Copy and paste this code in your module +Copy and paste this code in a module or an `IEx` session ```elixir -alias Gringotts.Gateways.Stripe -alias Gringotts.{CreditCard, Address, Worker, Gateways} +alias Gringotts.Gateways.Monei +alias Gringotts.{CreditCard} -card = %CreditCard{ - first_name: "John", - last_name: "Smith", - number: "4242424242424242", - year: "2017", - month: "12", - verification_code: "123" -} +# a fake sample card that will work now because the Gateway is by default +# in "test" mode. -address = %Address{ - street1: "123 Main", - city: "New York", - region: "NY", - country: "US", - postal_code: "11111" +card = %CreditCard{ + first_name: "Harry", + last_name: "Potter", + number: "4200000000000000", + year: 2099, month: 12, + verification_code: "123", + brand: "VISA" } -opts = [address: address, currency: "eur"] +# a sum of $42 +amount = Money.new(42, :USD) -case Gringotts.purchase(Stripe, 10, card, opts) do - {:ok, %{authorization: authorization}} -> - IO.puts("Payment authorized #{authorization}") +case Gringotts.purchase(Monei, amount, card) do + {:ok, %{id: id}} -> + IO.puts("Payment authorized, reference token: '#{id}'") - {:error, %{code: :declined, reason: reason}} -> - IO.puts("Payment declined #{reason}") - - {:error, %{code: error}} -> - IO.puts("Payment error #{error}") + {:error, %{status_code: error, raw: raw_response}} -> + IO.puts("Error: #{error}\nRaw:\n#{raw_response}") end ``` +[hexpm]: https://hex.pm/packages/gringotts +[monei]: http://www.monei.net + ## Supported Gateways -* [Authorize.Net](http://www.authorize.net/) - AD, AT, AU, BE, BG, CA, CH, CY, CZ, DE, DK, ES, FI, FR, GB, GB, GI, GR, HU, IE, IT, LI, LU, MC, MT, NL, NO, PL, PT, RO, SE, SI, SK, SM, TR, US, VA -* [CAMS: Central Account Management System](https://www.centralams.com/) - AU, US -* [MONEI](http://www.monei.net/) - AD, AT, BE, BG, CA, CH, CY, CZ, DE, DK, EE, ES, FI, FO, FR, GB, GI, GR, HU, IE, IL, IS, IT, LI, LT, LU, LV, MT, NL, NO, PL, PT, RO, SE, SI, SK, TR, US, VA -* [PAYMILL](https://paymill.com) - AD, AT, BE, BG, CH, CY, CZ, DE, DK, EE, ES, FI, FO, FR, GB, GI, GR, HU, IE, IL, IS, IT, LI, LT, LU, LV, MT, NL, NO, PL, PT, RO, SE, SI, SK, TR, VA -* [Stripe](https://stripe.com/) - AT, AU, BE, CA, CH, DE, DK, ES, FI, FR, GB, IE, IN, IT, LU, NL, NO, SE, SG, US -* [TREXLE](https://docs.trexle.com/) - AD, AE, AT, AU, BD, BE, BG, BN, CA, CH, CY, CZ, DE, DK, EE, EG, ES, FI, FR, GB, GI, GR, HK, HU, ID, IE, IL, IM, IN, IS, IT, JO, KW, LB, LI, LK, LT, LU, LV, MC, MT, MU, MV, MX, MY, NL, NO, NZ, OM, PH, PL, PT, QA, RO, SA, SE, SG, SI, SK, SM, TR, TT, UM, US, VA, VN, ZA -* [Wirecard](http://www.wirecard.com) - AD, CY, GI, IM, MT, RO, CH, AT, DK, GR, IT, MC, SM, TR, BE, EE, HU, LV, NL, SK, GB, BG, FI, IS, LI, NO, SI, VA, FR, IL, LT, PL, ES, CZ, DE, IE, LU, PT, SE +| Gateway | Supported countries | +| ------ | ----- | +| [Authorize.Net][anet] | AD, AT, AU, BE, BG, CA, CH, CY, CZ, DE, DK, ES, FI, FR, GB, GB, GI, GR, HU, IE, IT, LI, LU, MC, MT, NL, NO, PL, PT, RO, SE, SI, SK, SM, TR, US, VA | +| [CAMS][cams] | AU, US | +| [MONEI][monei] | DE, EE, ES, FR, IT, US | +| [PAYMILL][paymill] | AD, AT, BE, BG, CH, CY, CZ, DE, DK, EE, ES, FI, FO, FR, GB, GI, GR, HU, IE, IL, IS, IT, LI, LT, LU, LV, MT, NL, NO, PL, PT, RO, SE, SI, SK, TR, VA | +| [Stripe][stripe] | AT, AU, BE, CA, CH, DE, DK, ES, FI, FR, GB, IE, IN, IT, LU, NL, NO, SE, SG, US | +| [TREXLE][trexle] | AD, AE, AT, AU, BD, BE, BG, BN, CA, CH, CY, CZ, DE, DK, EE, EG, ES, FI, FR, GB, GI, GR, HK, HU, ID, IE, IL, IM, IN, IS, IT, JO, KW, LB, LI, LK, LT, LU, LV, MC, MT, MU, MV, MX, MY, NL, NO, NZ, OM, PH, PL, PT, QA, RO, SA, SE, SG, SI, SK, SM, TR, TT, UM, US, VA, VN, ZA | +| [Wirecard][wirecard] | AD, AT, BE, BG, CH, CY, CZ, DE, DK, EE, ES, FI, FR, GB, GI, GR, HU, IE, IL, IM, IS, IT, LI, LT, LU, LV, MC, MT, NL, NO, PL, PT, RO, SE, SI, SK, SM, TR, VA | + +[anet]: http://www.authorize.net/ +[cams]: https://www.centralams.com/ +[monei]: http://www.monei.net/ +[paymill]: https://www.paymill.com +[stripe]: https://www.stripe.com/ +[trexle]: https://www.trexle.com/ +[wirecard]: http://www.wirecard.com +[demo]: https://gringottspay.herokuapp.com/ ## Road Map -- Support more gateways on an on-going basis. -- Each gateway request is hosted in a worker process and supervised. +Apart from supporting more and more gateways, we also keep a somewhat detailed +plan for the future on our [wiki][roadmap]. + +## FAQ + +#### 1. What's with the name? "Gringotts"? + +Gringotts has a nice ring to it. Also [this][reason]. + +#### 2. What is the worker doing in the middle? + +We wanted to "supervise" our payments, and power utilities to process recurring +payments, subscriptions with it. But yes, as of now, it is a bottle neck and +unnecessary. + +It's slated to be removed in [`v2.0.0`][milestone-2_0_0_alpha] and any supervised / async / +parallel work can be explicitly managed via native elixir constructs. + +[milestone-2_0_0_alpha]: https://github.com/aviabird/gringotts/milestone/3 +[reason]: http://harrypotter.wikia.com/wiki/Gringotts ## License MIT + +[roadmap]: https://github.com/aviabird/gringotts/wiki/Roadmap diff --git a/images/lg.png b/images/lg.png new file mode 100644 index 00000000..5c15b7a2 Binary files /dev/null and b/images/lg.png differ diff --git a/lib/gringotts.ex b/lib/gringotts.ex index d1440490..003c6aba 100644 --- a/lib/gringotts.ex +++ b/lib/gringotts.ex @@ -1,283 +1,305 @@ defmodule Gringotts do - @moduledoc ~S""" - Gringotts is a payment gateway integration library supporting many gateway integrations. - - ## Configuration - - The configuration for `Gringotts` must be in your application environment, - usually defined in your `config/config.exs` and is **mandatory**: + @moduledoc """ + Gringotts is a payment gateway integration library for merchants - **Global Configuration** - - The global configuration sets the library level configurations to interact with the gateway. - If the mode is not set then by 'default' the sandbox account is selected. + Gringotts provides a unified interface for multiple Payment Gateways to make it + easy for merchants to use multiple gateways. + All gateways must conform to the API as described in this module, but can also + support more gateway features than those required by Gringotts. - To integrate with the sandbox account set. - config :gringotts, :global_config, - mode: :test - To integrate with the live account set. - config :gringotts, :global_config, - mode: :prod + ## Standard API arguments + + All requests to Gringotts are served by a supervised worker, this might be + made optional in future releases. + + ### `gateway` (Module) Name + + The `gateway` to which this request is made. This is required in all API calls + because Gringotts supports multiple Gateways. + + #### Example + If you've configured Gringotts to work with Stripe, you'll do this + to make an `authorization` request: + + Gringotts.authorize(Gingotts.Gateways.Stripe, other args ...) - **Gateway Configuration** + ### `amount` _and currency_ - The gateway level configurations are for fields related to a specific gateway. - config :Gringotts, Gringotts.Gateways.Stripe, - adapter: Gringotts.Gateways.Stripe, - api_key: "sk_test_vIX41hC0sdfBKrPWQerLuOMld", - default_currency: "USD" + This argument represents the "amount", annotated with the currency unit for + the transaction. `amount` is polymorphic thanks to the `Gringotts.Money` + protocol which can even be implemented by your very own custom Money type! - `Key` for the configuration and the adapter value should be the same, we could have - chosen to pick adapter and used it as the key but we have chosen to be explicit rather - than implicit. + #### Note - ## Standard Arguments + Gringotts supports [`ex_money`][ex_money] out of the box, just drop `ex_money` + types in this argument and everything will work as expected. - The public API is designed in such a way that library users end up passing mostly a - standard params for almost all requests. + > Support for [`monetized`][monetized] and [`money`][money] is on the + > way, track it [here][iss-money-lib-support]! - ### Gateway Name - eg: Gringotts.Gateways.Stripe + Otherwise, just wrap your `amount` with the `currency` together in a `Map` like so, + money = %{value: Decimal.new("100.50"), currency: "USD"} - This option specifies which payment gateway this request should be called for. - Since `Gringotts` supports multiple payment gateway integrations at the same time - so this information get's critical. + > When this highly precise `amount` is serialized into the network request, we + > use a potentially lossy `Gringotts.Money.to_string/1` or + > `Gringotts.Money.to_integer/1` to perform rounding (if required) using the + > [`half-even`][wiki-half-even] strategy. + > + > **Hence, to ensure transparency, protect sanity and save _real_ money, we + > STRONGLY RECOMMEND that merchants perform any required rounding and handle + > remainders in their application logic -- before passing the `amount` to + > Gringotts's API.** - ### Amount - eg: 5000 + #### Example - Amount is the money an application wants to deduct in cents on the card. + If you use `ex_money` in your project, and want to make an authorization for + $2.99 to the `XYZ` Gateway, you'll do the following: - ### Card Info - eg: - %CreditCard { - name: "John Doe", + # the money lib is aliased as "MoneyLib" + + amount = MoneyLib.new("2.99", :USD) + Gringotts.authorize(Gringotts.Gateways.XYZ, amount, some_card, extra_options) + + [ex_money]: https://hexdocs.pm/ex_money/readme.html + [monetized]: https://hexdocs.pm/monetized/ + [money]: https://hexdocs.pm/money/Money.html + [iss-money-lib-support]: https://github.com/aviabird/gringotts/projects/3#card-6801146 + [wiki-half-even]: https://en.wikipedia.org/wiki/Rounding#Round_half_to_even + + ### `card`, a payment source + + Gringotts provides a `Gringotts.CreditCard` type to hold card parameters + which merchants fetch from their clients. The same type can also hold Debit + card details. + + #### Note + + Gringotts only supports payment by debit or credit card, even though the + gateways might support payment via other instruments such as e-wallets, + vouchers, bitcoins or banks. Support for these instruments is planned in + future releases. + + %CreditCard { + first_name: "Harry", + last_name: "Potter", number: "4242424242424242", - expiration: {2018, 12}, - cvc: "123", - street1: "123 Main", - street2: "Suite 100", - city: "New York", - region: "NY", - country: "US", - postal_code: "11111" - } + month: 12, + year: 2099, + verification_code: "123", + brand: "VISA"} - This stores all the credit card info of the customer along with some address info etc. + ### `opts` for optional params - ### Other options - eg: [currency: "usd"] + `opts` is a `keyword` list of other options/information accepted by the + gateway. The format, use and structure is gateway specific and documented in + the Gateway's docs. - This is a keyword list of all the other options/information which the payment gateway - needs apart from the above mentioned options. + ## Configuration - > This is passed as is to the gateway and not modified, usually it comes back in the - response object intact. + Merchants must provide Gateway specific configuration in their application + config in the usual elixir style. The required and optional fields are + documented in every Gateway. + + > The required config keys are validated at runtime, as they include + > authentication information. See `Gringotts.Adapter.validate_config/2`. + + ### Global config + + This is set using the `:global_config` key once in your application. + + #### `:mode` + + Gateways usually provide sandboxed environments to test applications and the + merchant can use the `:mode` switch to choose between the sandbox or live + environment. + + **Available Options:** + + * `:test` -- for sandbox environment, all requests will be routed to the + gateway's sandbox/test API endpoints. Use this in your `:dev` and `:test` + environments. + * `:prod` -- for live environment, all requests will reach the financial and + banking networks. Switch to this in your application's `:prod` environment. + + **Example** + + config :gringotts, :global_config, + # for live environment + mode: :prod + + ### Gateway specific config + + The gateway level config is documented in their docs. They must be of the + following format: + + config :gringotts, Gringotts.Gateways.XYZ, + # some_documented_key: associated_value + # some_other_key: another_value """ - - import GenServer, only: [call: 2] @doc """ - This is the bare minimum API for a gateway to support, and consists of a single call: - - @payment %{ - name: "John Doe", - number: "4242424242424242", - expiration: {2018, 12}, - cvc: "123", - street1: "123 Main", - street2: "Suite 100", - city: "New York", - region: "NY", - country: "US", - postal_code: "11111" - } - - @options [currency: "usd"] - - Gringotts.purchase(Gringotts.Gateways.Stripe, 5, @payment, @options) - - This method is expected to authorize payment and transparently trigger eventual - settlement. Preferably it is implemented as a single call to the gateway, - but it can also be implemented as chained `authorize` and `capture` calls. + 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 and + may also trigger risk management. Funds are not transferred, until the + authorization is `capture/3`d. + + > `capture/3` must also be implemented alongwith this. + + ## Example + + To (pre) authorize a payment of $4.20 on a sample `card` with the `XYZ` + gateway, + + amount = Money.new("4.2", :USD) + # IF YOU DON'T USE ex_money + # amount = %{value: Decimal.new("4.2"), currency: "EUR"} + card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + {:ok, auth_result} = Gringotts.authorize(Gringotts.Gateways.XYZ, amount, card, opts) """ - def purchase(gateway, amount, card, opts \\ []) do - validate_config(gateway) - call(:payment_worker, {:purchase, gateway, amount, card, opts}) + def authorize(gateway, amount, card, opts \\ []) do + config = get_and_validate_config(gateway) + gateway.authorize(amount, card, [{:config, config} | opts]) end @doc """ - Authorize should authorize funds on a payment instrument that will - not be settled without a following call to `capture` within some finite - period of time. When implementing this API, authorize and capture are - both required. - - @payment %{ - name: "John Doe", - number: "4242424242424242", - expiration: {2018, 12}, - cvc: "123", - street1: "123 Main", - street2: "Suite 100", - city: "New York", - region: "NY", - country: "US", - postal_code: "11111" - } - - @options [currency: "usd"] - - Gringotts.authorize(Gringotts.Gateways.Stripe, 5, @payment, @options) + Captures a pre-authorized `amount`. + + `amount` is transferred to the merchant account. The gateway might support, + * partial captures, + * multiple captures, per authorization + + ## Example + + To capture $4.20 on a previously authorized payment worth $4.20 by referencing + the obtained authorization `id` with the `XYZ` gateway, + + amount = Money.new("4.2", :USD) + # IF YOU DON'T USE ex_money + # amount = %{value: Decimal.new("4.2"), currency: "EUR"} + card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + Gringotts.capture(Gringotts.Gateways.XYZ, amount, auth_result.id, opts) """ - def authorize(gateway, amount, card, opts \\ []) do - validate_config(gateway) - call(:payment_worker, {:authorize, gateway, amount, card, opts}) + def capture(gateway, id, amount, opts \\ []) do + config = get_and_validate_config(gateway) + gateway.capture(id, amount, [{:config, config} | opts]) end @doc """ - Captures deducts an amount from the card, this happens once the card is authorised. - - Partial captures, if supported by the gateway, are achieved by passing an amount. - Not passing an amount to capture should always cause the full amount of the initial - authorization to be captured. - - If the gateway does not support partial captures, calling `capture` with an amount - other than nil should raise an error indicating partial capture is not supported. - - @payment %{ - name: "John Doe", - number: "4242424242424242", - expiration: {2018, 12}, - cvc: "123", - street1: "123 Main", - street2: "Suite 100", - city: "New York", - region: "NY", - country: "US", - postal_code: "11111" - } - - @options [currency: "usd"] - - id = "ch_1BYvGkBImdnrXiZwet3aKkQE" - - Gringotts.capture(Gringotts.Gateways.Stripe, id, 5) + Transfers `amount` from the customer to the merchant. + + Gateway attempts to process a purchase on behalf of the customer, by debiting + `amount` from the customer's account by charging the customer's `card`. + + This method _can_ be implemented as a chained call to `authorize/3` and + `capture/3`. But it must be implemented as a single call to the Gateway if it + provides a specific endpoint or action for this. + + > ***Note!** + > All gateways must implement (atleast) this method. + + ## Example + + To process a purchase worth $4.2, with the `XYZ` gateway, + + amount = Money.new("4.2", :USD) + # IF YOU DON'T USE ex_money + # amount = %{value: Decimal.new("4.2"), currency: "EUR"} + card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + Gringotts.purchase(Gringotts.Gateways.XYZ, amount, card, opts) """ - def capture(gateway, id, amount, opts \\ []) do - validate_config(gateway) - call(:payment_worker, {:capture, gateway, id, amount, opts}) + def purchase(gateway, amount, card, opts \\ []) do + config = get_and_validate_config(gateway) + gateway.purchase(amount, card, [{:config, config} | opts]) end @doc """ - Void is an optional (but highly recommended) supplement to `authorise` & `capture` - API that should immediately cancel an authorized charge, clearing it off of the - underlying payment instrument without waiting for expiration. + Refunds the `amount` to the customer's account with reference to a prior transfer. - @payment %{ - name: "John Doe", - number: "4242424242424242", - expiration: {2018, 12}, - cvc: "123", - street1: "123 Main", - street2: "Suite 100", - city: "New York", - region: "NY", - country: "US", - postal_code: "11111" - } + The end customer will usually see two bookings/records on his statement. - @options [currency: "usd"] + ## Example - id = "ch_1BYvGkBImdnrXiZwet3aKkQE" - - Gringotts.void(Gringotts.Gateways.Stripe, id) + To refund a previous purchase worth $4.20 referenced by `id`, with the `XYZ` + gateway, + amount = Money.new("4.2", :USD) + # IF YOU DON'T USE ex_money + # amount = %{value: Decimal.new("4.2"), currency: "EUR"} + Gringotts.purchase(Gringotts.Gateways.XYZ, amount, id, opts) """ - def void(gateway, id, opts \\ []) do - validate_config(gateway) - call(:payment_worker, {:void, gateway, id, opts}) + def refund(gateway, amount, id, opts \\ []) do + config = get_and_validate_config(gateway) + gateway.refund(amount, id, [{:config, config} | opts]) end @doc """ - Cancels settlement or returns funds as appropriate for a referenced prior - `purchase` or `capture`. - - @payment %{ - name: "John Doe", - number: "4242424242424242", - expiration: {2018, 12}, - cvc: "123", - street1: "123 Main", - street2: "Suite 100", - city: "New York", - region: "NY", - country: "US", - postal_code: "11111" - } - - id = "ch_1BYvGkBImdnrXiZwet3aKkQE" - - Gringotts.refund(Gringotts.Gateways.Stripe, 5, id) + Stores the payment-source data for later use, returns a `token`. + + > The token must be returned in the `Response.authorization` field. + + ## Note + + This usually enables _One-Click_ and _Recurring_ payments. + + ## Example + + To store a card (a payment-source) for future use, with the `XYZ` gateway, + + card = %Gringotts.CreditCard{first_name: "Jo", last_name: "Doe", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + Gringotts.store(Gringotts.Gateways.XYZ, card, opts) """ - def refund(gateway, amount, id, opts \\ []) do - validate_config(gateway) - call(:payment_worker, {:refund, gateway, amount, id, opts}) + def store(gateway, card, opts \\ []) do + config = get_and_validate_config(gateway) + gateway.store(card, [{:config, config} | opts]) end @doc """ - Tokenizes a supported payment method in the gateway's vault. If the gateway - conflates tokenization with customer management, `Gringotts` should hide all - customer management and any customer identifier(s) within the token returned. - It's certainly legitimate to have a library that interacts with all the features - in a gateway's vault, but `Gringotts` is not the right place for it. - - It's critical that `store` returns a token that can be used against `purchase` - and `authorize`. Currently the standard is to return the token in the - `%Response{...}` `authorization` field. - - @payment %{ - name: "John Doe", - number: "4242424242424242", - expiration: {2018, 12}, - cvc: "123", - street1: "123 Main", - street2: "Suite 100", - city: "New York", - region: "NY", - country: "US", - postal_code: "11111" - } - - id = "ch_1BYvGkBImdnrXiZwet3aKkQE" - - Gringotts.store(Gringotts.Gateways.Stripe, @payment) + Removes a previously `token` from the gateway + + Once `unstore/3`d, the `token` must becom invalid, though some gateways might + not support this feature. + + ## Example + + To unstore with the `XYZ` gateway, + + token = "some_privileged_customer" + Gringotts.unstore(Gringotts.Gateways.XYZ, token) """ - def store(gateway, card, opts \\ []) do - validate_config(gateway) - call(:payment_worker, {:store, gateway, card, opts}) + def unstore(gateway, token, opts \\ []) do + config = get_and_validate_config(gateway) + gateway.unstore(token, [{:config, config} | opts]) end @doc """ - Removes the token from the payment gateway, once `unstore` request is fired the - token which could enable `authorise` & `capture` would not work with this token. + Voids the referenced payment. + + This method attempts a reversal/immediate cancellation of the a previous + transaction referenced by `id`. + + As a consequence, the customer usually **won't** see any booking on his + statement. - This should be done once the payment capture is done and you don't wish to make any - further deductions for the same card. + ## Example - customer_id = "random_customer" + To void a previous (pre) authorization with the `XYZ` gateway, - Gringotts.unstore(Gringotts.Gateways.Stripe, customer_id) + id = "some_previously_obtained_token" + Gringotts.void(Gringotts.Gateways.XYZ, id, opts) """ - def unstore(gateway, customer_id, opts \\ []) do - validate_config(gateway) - call(:payment_worker, {:unstore, gateway, customer_id, opts}) + def void(gateway, id, opts \\ []) do + config = get_and_validate_config(gateway) + gateway.void(id, [{:config, config} | opts]) end - # TODO: This is runtime error reporting fix this so that it does compile - # time error reporting. - defp validate_config(gateway) do + defp get_and_validate_config(gateway) do # Keep the key name and adapter the same in the config in application config = Application.get_env(:gringotts, gateway) + # The following call to validate_config might raise an error gateway.validate_config(config) + global_config = Application.get_env(:gringotts, :global_config) || [mode: :test] + Keyword.merge(global_config, config) end end diff --git a/lib/gringotts/adapter.ex b/lib/gringotts/adapter.ex index d6e250b6..5edd06f2 100644 --- a/lib/gringotts/adapter.ex +++ b/lib/gringotts/adapter.ex @@ -1,12 +1,42 @@ defmodule Gringotts.Adapter do - @moduledoc ~S""" - Adapter module is currently holding the validation part. + @moduledoc """ + Validates the "required" configuration. - This modules is being `used` by all the payment gateways and raises a run-time - error for the missing configurations which are passed by the gateways to - `validate_config` method. + All gateway modules must `use` this module, which provides a run-time + configuration validator. - Raises an exception `ArgumentError` if the config is not as per the `@required_config` + Gringotts picks up the merchant's Gateway authentication secrets from the + Application config. The configuration validator can be customized by providing + a list of `required_config` keys. The validator will check if these keys are + available at run-time, before each call to the Gateway. + + ## Example + + Say a merchant must provide his `secret_user_name` and `secret_password` to + some Gateway `XYZ`. Then, `Gringotts` expects that the `GatewayXYZ` module + would use `Adapter` in the following manner: + + ``` + defmodule Gringotts.Gateways.GatewayXYZ do + + use Gringotts.Adapter, required_config: [:secret_user_name, :secret_password] + use Gringotts.Gateways.Base + + # the rest of the implentation + end + ``` + + And, the merchant woud provide these secrets in the Application config, + possibly via `config/config.exs` like so, + ``` + # config/config.exs + + config :gringotts, Gringotts.Gateways.GatewayXYZ, + adapter: Gringotts.Gateways.GatewayXYZ, + secret_user_name: "some_really_secret_user_name", + secret_password: "some_really_secret_password" + + ``` """ defmacro __using__(opts) do @@ -14,21 +44,27 @@ defmodule Gringotts.Adapter do @required_config opts[:required_config] || [] @doc """ - Validates the config dynamically depending on what is the value of `required_config` + Catches gateway configuration errors. + + Raises a run-time `ArgumentError` if any of the `required_config` values + is not available or missing from the Application config. """ def validate_config(config) do - missing_keys = Enum.reduce(@required_config, [], fn(key, missing_keys) -> - if config[key] in [nil, ""], do: [key | missing_keys], else: missing_keys - end) + missing_keys = + Enum.reduce(@required_config, [], fn key, missing_keys -> + if config[key] in [nil, ""], do: [key | missing_keys], else: missing_keys + end) + raise_on_missing_config(missing_keys, config) end defp raise_on_missing_config([], _config), do: :ok + defp raise_on_missing_config(key, config) do raise ArgumentError, """ - expected #{inspect key} to be set, got: #{inspect config} + expected #{inspect(key)} to be set, got: #{inspect(config)} """ end end end -end \ No newline at end of file +end diff --git a/lib/gringotts/address.ex b/lib/gringotts/address.ex index e1c50c95..57c8dd5c 100644 --- a/lib/gringotts/address.ex +++ b/lib/gringotts/address.ex @@ -1,3 +1,5 @@ defmodule Gringotts.Address do + @moduledoc false + defstruct [:street1, :street2, :city, :region, :country, :postal_code, :phone] end diff --git a/lib/gringotts/application.ex b/lib/gringotts/application.ex deleted file mode 100644 index f852de3b..00000000 --- a/lib/gringotts/application.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule Gringotts.Application do - @moduledoc ~S""" - Has the supervision tree which monitors all the workers - that are handling the payments. - """ - use Application - - def start(_type, _args) do - import Supervisor.Spec, warn: false - app_config = Application.get_all_env(:gringotts) - adapters = Enum.filter(app_config, fn({_, klist}) -> klist != [] end) - |> Enum.map(fn({_, klist}) -> Keyword.get(klist, :adapter) end) - - children = [ - # Define workers and child supervisors to be supervised - # worker(Gringotts.Worker, [arg1, arg2, arg3]) - worker( - Gringotts.Worker, - [ - adapters, # gateways - app_config, # options(config from application) - # Since we just have one worker handling all the incoming - # requests so this name remains fixed - [name: :payment_worker] - ]) - ] - - # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html - # for other strategies and supported options - opts = [strategy: :one_for_one, name: Gringotts.Supervisor] - Supervisor.start_link(children, opts) - end -end diff --git a/lib/gringotts/credit_card.ex b/lib/gringotts/credit_card.ex index c2f53c8d..e98481a0 100644 --- a/lib/gringotts/credit_card.ex +++ b/lib/gringotts/credit_card.ex @@ -1,9 +1,10 @@ defmodule Gringotts.CreditCard do @moduledoc """ - Defines a `Struct` for (credit) cards and some utilities. + Defines a `struct` for (credit) cards and some utilities. """ defstruct [:number, :month, :year, :first_name, :last_name, :verification_code, :brand] + @typedoc """ Represents a Credit Card. @@ -29,19 +30,24 @@ defmodule Gringotts.CreditCard do [mo]: http://www.maestrocard.com/gateway/index.html [dc]: http://www.dinersclub.com/ """ - @type t :: %__MODULE__{number: String.t, - month: 1..12, - year: non_neg_integer, - first_name: String.t, - last_name: String.t, - verification_code: String.t, - brand: String.t} + @type t :: %__MODULE__{ + number: String.t(), + month: 1..12, + year: non_neg_integer, + first_name: String.t(), + last_name: String.t(), + verification_code: String.t(), + brand: String.t() + } @doc """ Returns the full name of the card holder. Joins `first_name` and `last_name` with a space in between. """ - @spec full_name(t) :: String.t - def full_name(card), do: "#{card.first_name} #{card.last_name}" + @spec full_name(t) :: String.t() + def full_name(card) do + name = "#{card.first_name} #{card.last_name}" + String.trim(name) + end end diff --git a/lib/gringotts/gateways/authorize_net.ex b/lib/gringotts/gateways/authorize_net.ex index 2f87243a..2612bb7a 100644 --- a/lib/gringotts/gateways/authorize_net.ex +++ b/lib/gringotts/gateways/authorize_net.ex @@ -1,94 +1,116 @@ defmodule Gringotts.Gateways.AuthorizeNet do - @moduledoc """ - A module for working with the Authorize.net payment gateway. - - The module provides a set of functions to perform transactions via this gateway for a merchant. + A module for working with the Authorize.Net payment gateway. - [AuthorizeNet API reference](https://developer.authorize.net/api/reference/index.html) + Refer the official Authorize.Net [API docs][docs]. - The following set of functions for Authorize.Net have been provided: + The following set of functions for Authorize.Net have been implemented: | Action | Method | | ------ | ------ | | Authorize a Credit Card | `authorize/3` | - | Capture a Previously Authorized Amount | `capture/3` | + | Capture a previously authorized amount | `capture/3` | | Charge a Credit Card | `purchase/3` | | Refund a transaction | `refund/3` | - | Void a Transaction | `void/2` | + | Void a transaction | `void/2` | | Create Customer Profile | `store/2` | | Create Customer Payment Profile | `store/2` | | Delete Customer Profile | `unstore/2` | Most `Gringotts` API calls accept an optional `Keyword` list `opts` to supply - optional arguments for transactions with the Authorize.Net gateway. The following keys - are supported: - - | Key | Remark | Status | - | ---- | --- | ---- | - | `currency` | | not Implemented | - | `customer` | | implemented | - | `invoice` | | implemented | - | `bill_to` | | implemented | - | `ship_to` | | implemented | - | `customer_ip` | | implemented | - | `order` | | implemented | - | `lineitems` | | implemented | - | `ref_id` | | implemented | - | `tax` | | implemented | - | `duty` | | implemented | - | `shipping` | | implemented | - | `po_number` | | implemented | - | `customer_type` | | implemented | - | `customer_profile_id`| | implemented | - | `profile` | | implemented | - - To know more about these keywords visit [Request](https://developer.authorize.net/api/reference/index.html#payment-transactions) - and [Response](https://developer.authorize.net/api/reference/index.html#payment-transactions) key sections for each function. - - To use this module you need to create an account with the [Authorize.Net - gateway](https://www.authorize.net/solutions/merchantsolutions/onlinemerchantaccount/) - which will provide you with a `name` and a `transactionKey`. + optional arguments for transactions with the Authorize.Net gateway. The + following keys are supported: + + | Key | Remarks | + | ---- | ------- | + | `customer` | | + | `invoice` | | + | `bill_to` | | + | `ship_to` | | + | `customer_ip` | | + | `order` | | + | `lineitems` | | + | `ref_id` | | + | `tax` | | + | `duty` | | + | `shipping` | | + | `po_number` | | + | `customer_type` | | + | `customer_profile_id` | | + | `profile` | | + + To know more about these keywords visit [Request and Response][req-resp] tabs for each + API method. + + [docs]: https://developer.authorize.net/api/reference/index.html + [req-resp]: https://developer.authorize.net/api/reference/index.html#payment-transactions + + ## Notes + + 1. Though Authorize.Net supports [multiple currencies][currencies] however, + multiple currencies in one account are not supported in _this_ module. A + merchant would need multiple Authorize.Net accounts, one for each chosen + currency. + 2. The responses of this module include a non-standard field: `:cavv_result`. + - `:cavv_result` is the "cardholder authentication verification response + code". In case of Mastercard transactions, this field will always be + `nil`. Please refer the "Response Format" section in the [docs][docs] for + more details. + + [currencies]: https://community.developer.authorize.net/t5/The-Authorize-Net-Developer-Blog/Authorize-Net-UK-Europe-Update/ba-p/35957 ## Configuring your AuthorizeNet account at `Gringotts` + To use this module you need to [create an account][dashboard] with the + Authorize.Net gateway and obtain your login secrets: `name` and + `transactionKey`. + Your Application config **must include the `name` and `transaction_key` fields** and would look something like this: - + config :gringotts, Gringotts.Gateways.AuthorizeNet, - adapter: Gringotts.Gateways.AuthorizeNet, name: "name_provided_by_authorize_net", transaction_key: "transactionKey_provided_by_authorize_net" - - ## Scope of this module, and _quirks_ - * Although Authorize.Net supports payments from [various - sources](https://www.authorize.net/solutions/merchantsolutions/onlinemerchantaccount/), - this library currently accepts payments by (supported) credit cards only. + + ## Scope of this module + + Although Authorize.Net supports payments from various sources (check your + [dashboard][dashboard]), this library currently accepts payments by + (supported) credit cards only. + + [dashboard]: https://www.authorize.net/solutions/merchantsolutions/onlinemerchantaccount/ ## Following the examples + 1. First, set up a sample application and configure it to work with Authorize.Net. - - You could do that from scratch by following our [Getting Started](#) guide. - - To save you time, we recommend [cloning our example - repo](https://github.com/aviabird/gringotts_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" - [above](#Configuring your AuthorizeNet account at `Gringotts`). - 2. Run an `iex` session with `iex -S mix` and add some variable bindings and - aliases to it (to save some time): - ``` - iex> alias Gringotts.{Response, CreditCard, Gateways.AuthorizeNet} - ``` + - You could do that from scratch by following our [Getting Started][gs] + guide. + - To save you time, we recommend [cloning our example repo][example-repo] + that gives you a pre-configured sample app ready-to-go. + + You could use the same config or update it the with your "secrets" + [above](#module-configuring-your-authorizenet-account-at-gringotts). + + 2. To save a lot of time, create a [`.iex.exs`][iex-docs] file as shown in + [this gist][authorize_net.iex.exs] to introduce a set of handy bindings and + aliases. + + We'll be using these bindings in the examples below. + + [example-repo]: https://github.com/aviabird/gringotts_example + [iex-docs]: https://hexdocs.pm/iex/IEx.html#module-the-iex-exs-file + [authorize_net.iex.exs]: https://gist.github.com/oyeb/b1030058bda1fa9a3d81f1cf30723695 + [gs]: https://github.com/aviabird/gringotts/wiki """ import XmlBuilder - import XmlToMap use Gringotts.Gateways.Base use Gringotts.Adapter, required_config: [:name, :transaction_key] + alias Gringotts.Gateways.AuthorizeNet.ResponseHandler @test_url "https://apitest.authorize.net/xml/v1/request.api" @production_url "https://api.authorize.net/xml/v1/request.api" - @header [{"Content-Type", "text/xml"}] + @headers [{"Content-Type", "text/xml"}] @transaction_type %{ purchase: "authCaptureTransaction", @@ -98,53 +120,45 @@ defmodule Gringotts.Gateways.AuthorizeNet do void: "voidTransaction" } - @response_type %{ - auth_response: "authenticateTestResponse", - transaction_response: "createTransactionResponse", - error_response: "ErrorResponse", - customer_profile_response: "createCustomerProfileResponse", - customer_payment_profile_response: "createCustomerPaymentProfileResponse", - delete_customer_profile: "deleteCustomerProfileResponse" - } - @aut_net_namespace "AnetApi/xml/v1/schema/AnetApiSchema.xsd" alias Gringotts.{ CreditCard, - Address, - Response + Response, + Money } - # ---------------Interface functions to be used by developer for - #----------------making requests to gateway - @doc """ - Charge a credit card. + Transfers `amount` from the customer to the merchant. + + Charges a credit `card` for the specified `amount`. It performs `authorize` + and `capture` at the [same time][auth-cap-same-time]. + + Authorize.Net returns `transId` (available in the `Response.id` field) which + can be used to: - Function to charge a user credit card for the specified `amount`. It performs `authorize` - and `capture` at the [same time](https://developer.authorize.net/api/reference/index.html#payment-transactions-charge-a-credit-card). - For this transaction Authorize.Net returns `transId` which can be used to: - * `refund/3` a settled transaction. * `void/2` a transaction. + [auth-cap-same-time]: https://developer.authorize.net/api/reference/index.html#payment-transactions-charge-a-credit-card + ## Optional Fields opts = [ order: %{invoice_number: String, description: String}, ref_id: String, lineitems: %{ item_id: String, name: String, description: String, - quantity: Integer, unit_price: Float + quantity: Integer, unit_price: Gringotts.Money.t() }, - tax: %{amount: Float, name: String, description: String}, - duty: %{amount: String, name: String, description: String}, - shipping: %{amount: String, name: String, description: String}, + tax: %{amount: Gringotts.Money.t(), name: String, description: String}, + duty: %{amount: Gringotts.Money.t(), name: String, description: String}, + shipping: %{amount: Gringotts.Money.t(), name: String, description: String}, po_number: String, customer: %{id: String}, bill_to: %{ first_name: String, last_name: String, company: String, - address: String, city: String, state: String, zip: String, - country: String + address: String, city: String, state: String, zip: String, + country: String }, ship_to: %{ first_name: String, last_name: String, company: String, address: String, @@ -154,29 +168,36 @@ defmodule Gringotts.Gateways.AuthorizeNet do ] ## Example + iex> amount = Money.new(20, :USD) iex> opts = [ ref_id: "123456", - order: %{invoice_number: "INV-12345", description: "Product Description"}, - lineitems: %{itemId: "1", name: "vase", description: "Cannes logo", quantity: "18", unit_price: "45.00"} + order: %{invoice_number: "INV-12345", description: "Product Description"}, + lineitems: %{item_id: "1", name: "vase", description: "Cannes logo", quantity: 1, unit_price: amount}, + tax: %{name: "VAT", amount: Money.new("0.1", :EUR), description: "Value Added Tax"}, + shipping: %{name: "SAME-DAY-DELIVERY", amount: Money.new("0.56", :EUR), description: "Zen Logistics"}, + duty: %{name: "import_duty", amount: Money.new("0.25", :EUR), description: "Upon import of goods"} ] - iex> card = %CreditCard{number: "5424000000000015", year: 2020, month: 12, verification_code: "999"} - iex> amount = 5 + iex> card = %CreditCard{number: "5424000000000015", year: 2099, month: 12, verification_code: "999"} iex> result = Gringotts.purchase(Gringotts.Gateways.AuthorizeNet, amount, card, opts) """ - @spec purchase(Float, CreditCard.t, Keyword.t) :: tuple + @spec purchase(Money.t(), CreditCard.t(), Keyword.t()) :: {:ok | :error, Response.t()} def purchase(amount, payment, opts) do request_data = add_auth_purchase(amount, payment, opts, @transaction_type[:purchase]) - response_data = commit(:post, request_data, opts) - respond(response_data) + commit(request_data, opts) end @doc """ Authorize a credit card transaction. - Function to authorize a transaction for the specified amount. It needs to be - followed up with a `capture/3` transaction to transfer the funds to merchant account. - - For this transaction Authorize.Net returns a `transId` which can be use for: + The authorization validates the `card` details with the banking network, + places a hold on the transaction `amount` in the customer’s issuing bank and + also triggers risk management. Funds are not transferred. + + To transfer the funds to merchant's account follow this up with a `capture/3`. + + Authorize.Net returns a `transId` (available in the `Response.id` field) which + can be used for: + * `capture/3` an authorized transaction. * `void/2` a transaction. @@ -186,17 +207,17 @@ defmodule Gringotts.Gateways.AuthorizeNet do ref_id: String, lineitems: %{ item_id: String, name: String, description: String, - quantity: Integer, unit_price: Float + quantity: Integer, unit_price: Gringotts.Money.t() }, - tax: %{amount: Float, name: String, description: String}, - duty: %{amount: String, name: String, description: String}, - shipping: %{amount: String, name: String, description: String}, + tax: %{amount: Gringotts.Money.t(), name: String, description: String}, + duty: %{amount: Gringotts.Money.t(), name: String, description: String}, + shipping: %{amount: Gringotts.Money.t(), name: String, description: String}, po_number: String, customer: %{id: String}, - bill_to: %{ + bill_to: %{ first_name: String, last_name: String, company: String, - address: String, city: String, state: String, zip: String, - country: String + address: String, city: String, state: String, zip: String, + country: String }, ship_to: %{ first_name: String, last_name: String, company: String, address: String, @@ -205,68 +226,74 @@ defmodule Gringotts.Gateways.AuthorizeNet do customer_ip: String ] - ## Example + iex> amount = Money.new(20, :USD) iex> opts = [ ref_id: "123456", - order: %{invoice_number: "INV-12345", description: "Product Description"}, - lineitems: %{itemId: "1", name: "vase", description: "Cannes logo", quantity: "18", unit_price: "45.00"} + order: %{invoice_number: "INV-12345", description: "Product Description"}, + lineitems: %{itemId: "1", name: "vase", description: "Cannes logo", quantity: 1, unit_price: amount}, + tax: %{name: "VAT", amount: Money.new("0.1", :EUR), description: "Value Added Tax"}, + shipping: %{name: "SAME-DAY-DELIVERY", amount: Money.new("0.56", :EUR), description: "Zen Logistics"}, + duty: %{name: "import_duty", amount: Money.new("0.25", :EUR), description: "Upon import of goods"} ] - iex> card = %CreditCard{number: "5424000000000015", year: 2020, month: 12, verification_code: "999"} - iex> amount = 5 + iex> card = %CreditCard{number: "5424000000000015", year: 2099, month: 12, verification_code: "999"} iex> result = Gringotts.authorize(Gringotts.Gateways.AuthorizeNet, amount, card, opts) """ - @spec authorize(Float, CreditCard.t, Keyword.t) :: tuple + @spec authorize(Money.t(), CreditCard.t(), Keyword.t()) :: {:ok | :error, Response.t()} def authorize(amount, payment, opts) do request_data = add_auth_purchase(amount, payment, opts, @transaction_type[:authorize]) - response_data = commit(:post, request_data, opts) - respond(response_data) + commit(request_data, opts) end @doc """ - Capture a transaction. - - Function to capture an `amount` for an authorized transaction. - - For this transaction Authorize.Net returns a `transId` which can be use to: - * `refund/3` a settled transaction. - * `void/2` a transaction. - - ## Quirks - * If a `capture` transaction needs to `void` then it should be done before it is settled. For AuthorieNet - all the transactions are settled after 24 hours. - - * AuthorizeNet supports partical capture of the `authorized amount`. But it is advisable to use one - `authorization code` only [once](https://support.authorize.net/authkb/index?page=content&id=A1720&actp=LIST). + Captures a pre-authorized `amount`. + + `amount` is transferred to the merchant account by Authorize.Net when it is smaller or + equal to the amount used in the pre-authorization referenced by `id`. + + Authorize.Net returns a `transId` (available in the `Response.id` field) which + can be used to: + + * `refund/3` a settled transaction. + * `void/2` a transaction. + + ## Notes + + * Authorize.Net automatically settles authorized transactions after 24 + hours. Hence, unnecessary authorizations must be `void/2`ed within this + period! + * Though Authorize.Net supports partial capture of the authorized `amount`, it + is [advised][sound-advice] not to do so. + + [sound-advice]: https://support.authorize.net/authkb/index?page=content&id=A1720&actp=LIST ## Optional Fields opts = [ order: %{invoice_number: String, description: String}, ref_id: String ] - + ## Example iex> opts = [ ref_id: "123456" ] - iex> amount = 5 + iex> amount = Money.new(20, :USD) iex> id = "123456" iex> result = Gringotts.capture(Gringotts.Gateways.AuthorizeNet, id, amount, opts) """ - @spec capture(String.t, Float, Keyword.t) :: tuple + @spec capture(String.t(), Money.t(), Keyword.t()) :: {:ok | :error, Response.t()} def capture(id, amount, opts) do request_data = normal_capture(amount, id, opts, @transaction_type[:capture]) - response_data = commit(:post, request_data, opts) - respond(response_data) + commit(request_data, opts) end @doc """ Refund `amount` for a settled transaction referenced by `id`. - Use this method to refund a customer for a transaction that was already settled, requires - transId of the transaction. The `payment` field in the `opts` is used to set the mode of payment. - The `card` field inside `payment` needs the information of the credit card to be passed in the specified fields - so as to `refund` to that particular card. + The `payment` field in the `opts` is used to set the instrument/mode of + payment, which could be different from the original one. Currently, we + support only refunds to cards, so put the `card` details in the `payment`. + ## Required fields opts = [ payment: %{card: %{number: String, year: Integer, month: Integer}} @@ -276,26 +303,27 @@ defmodule Gringotts.Gateways.AuthorizeNet do ## Example iex> opts = [ - payment: %{card: %{number: "5424000000000015", year: 2020, month: 12}} + payment: %{card: %{number: "5424000000000015", year: 2099, month: 12}} ref_id: "123456" ] iex> id = "123456" - iex> amount = 5 + iex> amount = Money.new(20, :USD) iex> result = Gringotts.refund(Gringotts.Gateways.AuthorizeNet, amount, id, opts) """ - @spec refund(Float, String.t, Keyword.t) :: tuple + @spec refund(Money.t(), String.t(), Keyword.t()) :: {:ok | :error, Response.t()} def refund(amount, id, opts) do request_data = normal_refund(amount, id, opts, @transaction_type[:refund]) - response_data = commit(:post, request_data, opts) - respond(response_data) + commit(request_data, opts) end @doc """ - To void a transaction + Voids the referenced payment. - Use this method to cancel either an original transaction that is not settled or - an entire order composed of more than one transaction. It can be submitted against 'purchase', `authorize` - and `capture`. Requires the `transId` of a transaction. + This method attempts a reversal of the either a previous `purchase/3` or + `authorize/3` referenced by `id`. + + It can cancel either an original transaction that may not be settled or an + entire order composed of more than one transaction. ## Optional fields opts = [ref_id: String] @@ -307,26 +335,36 @@ defmodule Gringotts.Gateways.AuthorizeNet do iex> id = "123456" iex> result = Gringotts.void(Gringotts.Gateways.AuthorizeNet, id, opts) """ - @spec void(String.t, Keyword.t) :: tuple + @spec void(String.t(), Keyword.t()) :: {:ok | :error, Response.t()} def void(id, opts) do request_data = normal_void(id, opts, @transaction_type[:void]) - response_data = commit(:post, request_data, opts) - respond(response_data) + commit(request_data, opts) end @doc """ - Store a customer payment profile. - - Use this function to store the customer card information by creating a [customer profile](https://developer.authorize.net/api/reference/index.html#customer-profiles-create-customer-profile) which also - creates a `payment profile` if `card` inofrmation is provided, and in case the `customer profile` exists without a payment profile, the merchant - can create customer payment profile by passing the `customer_profile_id` in the `opts`. - The gateway also provide a provision for a `validation mode`, there are two modes `liveMode` - and `testMode`, to know more about modes [see](https://developer.authorize.net/api/reference/index.html#customer-profiles-create-customer-profile). - - ## Quirks - * The current version of this library supports only `credit card` as the payment profile. - * If a customer profile is created without the card info, then to create a payment profile - `card` info needs to be passed alongwith `cutomer_profile_id` to create it. + Store a customer's profile and optionally associate it with a payment profile. + + Authorize.Net separates a [customer's profile][cust-profile] from their payment + profile. Thus a customer can have multiple payment profiles. + + ## Create both profiles + + Add `:customer` details in `opts` and also provide `card` details. The response + will contain a `:customer_profile_id`. + + ## Associate payment profile with existing customer profile + + Simply pass the `:customer_profile_id` in the `opts`. This will add the `card` + details to the profile referenced by the supplied `:customer_profile_id`. + + ## Notes + + * Currently only supports `credit card` in the payment profile. + * The supplied `card` details can be validated by supplying a + [`:validation_mode`][cust-profile], available options are `testMode` and + `liveMode`, the deafult is `testMode`. + + [cust-profile]: https://developer.authorize.net/api/reference/index.html#customer-profiles-create-customer-profile ## Required Fields opts = [ @@ -335,7 +373,7 @@ defmodule Gringotts.Gateways.AuthorizeNet do ## Optional Fields opts = [ validation_mode: String, - billTo: %{ + bill_to: %{ first_name: String, last_name: String, company: String, address: String, city: String, state: String, zip: String, country: String }, @@ -347,17 +385,19 @@ defmodule Gringotts.Gateways.AuthorizeNet do profile: %{merchant_customer_id: 123456, description: "test store", email: "test@gmail.com"}, validation_mode: "testMode" ] - iex> card = %CreditCard{number: "5424000000000015", year: 2020, month: 12, verification_code: "999"} + iex> card = %CreditCard{number: "5424000000000015", year: 2099, month: 12, verification_code: "999"} iex> result = Gringotts.store(Gringotts.Gateways.AuthorizeNet, card, opts) """ - @spec store(CreditCard.t, Keyword.t) :: tuple + @spec store(CreditCard.t(), Keyword.t()) :: {:ok | :error, Response.t()} def store(card, opts) do - request_data = cond do - opts[:customer_profile_id] -> create_customer_payment_profile(card, opts) |> generate - true -> create_customer_profile(card, opts) |> generate - end - response_data = commit(:post, request_data, opts) - respond(response_data) + request_data = + if opts[:customer_profile_id] do + card |> create_customer_payment_profile(opts) |> generate(format: :none) + else + card |> create_customer_profile(opts) |> generate(format: :none) + end + + commit(request_data, opts) end @doc """ @@ -365,104 +405,83 @@ defmodule Gringotts.Gateways.AuthorizeNet do Use this function to unstore the customer card information by deleting the customer profile present. Requires the customer profile id. - + ## Example iex> id = "123456" - iex> opts = [] - iex> result = Gringotts.store(Gringotts.Gateways.AuthorizeNet, id, opts) + iex> result = Gringotts.store(Gringotts.Gateways.AuthorizeNet, id) """ - - @spec unstore(String.t, Keyword.t) :: tuple + + @spec unstore(String.t(), Keyword.t()) :: {:ok | :error, Response.t()} def unstore(customer_profile_id, opts) do - request_data = delete_customer_profile(customer_profile_id, opts) |> generate - response_data = commit(:post, request_data, opts) - respond(response_data) - end - - # method to make the api request with params - defp commit(method, payload, opts) do - path = base_url(opts) - headers = @header - HTTPoison.request(method, path, payload, headers) - end - - # Function to return a response - defp respond({:ok, %{body: body, status_code: 200}}) do - raw_response = naive_map(body) - cond do - raw_response[@response_type[:auth_response]] -> - response_check(raw_response[@response_type[:auth_response]], raw_response) - raw_response[@response_type[:transaction_response]] -> - response_check(raw_response[@response_type[:transaction_response]], raw_response) - raw_response[@response_type[:error_response]] -> - response_check(raw_response[@response_type[:error_response]], raw_response) - raw_response[@response_type[:customer_profile_response]] -> - response_check(raw_response[@response_type[:customer_profile_response]], raw_response) - raw_response[@response_type[:customer_payment_profile_response]] -> - response_check(raw_response[@response_type[:customer_payment_profile_response]], raw_response) - raw_response[@response_type[:delete_customer_profile]] -> - response_check(raw_response[@response_type[:delete_customer_profile]], raw_response) - end + request_data = customer_profile_id |> delete_customer_profile(opts) |> generate(format: :none) + commit(request_data, opts) end - defp respond({:error, %{body: body, status_code: code}}) do - {:error, Response.error(raw: body, code: code)} + # method to make the API request with params + defp commit(payload, opts) do + opts + |> base_url() + |> HTTPoison.post(payload, @headers) + |> respond() end - - # Functions to send successful and error responses depending on message received - # from gateway. - defp response_check( %{"messages" => %{"resultCode" => "Ok"}}, raw_response) do - {:ok, Response.success(raw: raw_response)} + defp respond({:ok, %{body: body, status_code: 200}}), do: ResponseHandler.respond(body) + + defp respond({:ok, %{body: body, status_code: code}}) do + {:error, %Response{raw: body, status_code: code}} end - defp response_check( %{"messages" => %{"resultCode" => "Error"}}, raw_response) do - {:error, Response.error(raw: raw_response)} + defp respond({:error, %HTTPoison.Error{} = error}) do + { + :error, + %Response{ + reason: "network related failure", + message: "HTTPoison says '#{error.reason}' [ID: #{error.id || "nil"}]" + } + } end - #------------------- Helper functions for the interface functions------------------- + ############################################################################## + # HELPER METHODS # + ############################################################################## # function for formatting the request as an xml for purchase and authorize method defp add_auth_purchase(amount, payment, opts, transaction_type) do - element(:createTransactionRequest, %{xmlns: @aut_net_namespace}, [ + :createTransactionRequest + |> element(%{xmlns: @aut_net_namespace}, [ add_merchant_auth(opts[:config]), add_order_id(opts), - add_purchase_transaction_request(amount, transaction_type, payment, opts), + add_purchase_transaction_request(amount, transaction_type, payment, opts) ]) - |> generate + |> generate(format: :none) end - + # function for formatting the request for normal capture defp normal_capture(amount, id, opts, transaction_type) do - element(:createTransactionRequest, %{xmlns: @aut_net_namespace}, [ + :createTransactionRequest + |> element(%{xmlns: @aut_net_namespace}, [ add_merchant_auth(opts[:config]), add_order_id(opts), - add_capture_transaction_request(amount, id, transaction_type, opts), + add_capture_transaction_request(amount, id, transaction_type) ]) - |> generate + |> generate(format: :none) end - # function to format the request as an xml for the authenticate method - defp add_auth_request(opts) do - element(:authenticateTestRequest, %{xmlns: @aut_net_namespace}, [ - add_merchant_auth(opts[:config]) - ]) - |> generate - end - - #function to format the request for normal refund + # function to format the request for normal refund defp normal_refund(amount, id, opts, transaction_type) do - element(:createTransactionRequest, %{xmlns: @aut_net_namespace}, [ + :createTransactionRequest + |> element(%{xmlns: @aut_net_namespace}, [ add_merchant_auth(opts[:config]), add_order_id(opts), - add_refund_transaction_request(amount, id, opts, transaction_type), + add_refund_transaction_request(amount, id, opts, transaction_type) ]) - |> generate + |> generate(format: :none) end - #function to format the request for normal void operation + # function to format the request for normal void operation defp normal_void(id, opts, transaction_type) do - element(:createTransactionRequest, %{xmlns: @aut_net_namespace}, [ + :createTransactionRequest + |> element(%{xmlns: @aut_net_namespace}, [ add_merchant_auth(opts[:config]), add_order_id(opts), element(:transactionRequest, [ @@ -470,7 +489,7 @@ defmodule Gringotts.Gateways.AuthorizeNet do add_ref_trans_id(id) ]) ]) - |> generate + |> generate(format: :none) end defp create_customer_payment_profile(card, opts) do @@ -481,7 +500,10 @@ defmodule Gringotts.Gateways.AuthorizeNet do add_billing_info(opts), add_payment_source(card) ]), - element(:validationMode, opts[:validation_mode]) + element( + :validationMode, + if(opts[:validation_mode], do: opts[:validation_mode], else: "testMode") + ) ]) end @@ -493,22 +515,32 @@ defmodule Gringotts.Gateways.AuthorizeNet do element(:description, opts[:profile][:description]), element(:email, opts[:profile][:description]), element(:paymentProfiles, [ - element(:customerType, (if opts[:customer_type], do: opts[:customer_type], else: "individual")), + element( + :customerType, + if(opts[:customer_type], do: opts[:customer_type], else: "individual") + ), + add_billing_info(opts), add_payment_source(card) ]) - ]) + ]), + element( + :validationMode, + if(opts[:validation_mode], do: opts[:validation_mode], else: "testMode") + ) ]) end defp delete_customer_profile(id, opts) do - element(:deleteCustomerProfileRequest, %{xmlns: @aut_net_namespace},[ + element(:deleteCustomerProfileRequest, %{xmlns: @aut_net_namespace}, [ add_merchant_auth(opts[:config]), element(:customerProfileId, id) ]) end - #--------------- XMl Builder functions for helper functions to assist - #---------------in attaching different tags for request + ############################################################################## + # HELPERS TO ASSIST IN BUILDING AND # + # COMPOSING DIFFERENT XmlBuilder TAGS # + ############################################################################## defp add_merchant_auth(opts) do element(:merchantAuthentication, [ @@ -526,7 +558,7 @@ defmodule Gringotts.Gateways.AuthorizeNet do add_transaction_type(transaction_type), add_amount(amount), add_payment_source(payment), - add_invoice(transaction_type, opts), + add_invoice(opts), add_tax_fields(opts), add_duty_fields(opts), add_shipping_fields(opts), @@ -535,7 +567,7 @@ defmodule Gringotts.Gateways.AuthorizeNet do ]) end - defp add_capture_transaction_request(amount, id, transaction_type, opts) do + defp add_capture_transaction_request(amount, id, transaction_type) do element(:transactionRequest, [ add_transaction_type(transaction_type), add_amount(amount), @@ -550,7 +582,10 @@ defmodule Gringotts.Gateways.AuthorizeNet do element(:payment, [ element(:creditCard, [ element(:cardNumber, opts[:payment][:card][:number]), - element(:expirationDate, join_string([opts[:payment][:card][:year], opts[:payment][:card][:month]], "-")) + element( + :expirationDate, + join_string([opts[:payment][:card][:year], opts[:payment][:card][:month]], "-") + ) ]) ]), add_ref_trans_id(id) @@ -566,10 +601,9 @@ defmodule Gringotts.Gateways.AuthorizeNet do end defp add_amount(amount) do - cond do - is_integer(amount) -> element(:amount, amount) - is_float(amount) -> element(:amount, amount) - true -> element(:amount, 0) + if amount do + {_, value} = amount |> Money.to_string() + element(:amount, value) end end @@ -589,11 +623,11 @@ defmodule Gringotts.Gateways.AuthorizeNet do ]) end - defp add_invoice(transactionType, opts) do - element( - [element(:order, [ + defp add_invoice(opts) do + element([ + element(:order, [ element(:invoiceNumber, opts[:order][:invoice_number]), - element(:description, opts[:order][:description]), + element(:description, opts[:order][:description]) ]), element(:lineItems, [ element(:lineItem, [ @@ -601,7 +635,10 @@ defmodule Gringotts.Gateways.AuthorizeNet do element(:name, opts[:lineitems][:name]), element(:description, opts[:lineitems][:description]), element(:quantity, opts[:lineitems][:quantity]), - element(:unitPrice, opts[:lineitems][:unit_price]) + element( + :unitPrice, + opts[:lineitems][:unit_price] |> Money.value() |> Decimal.to_float() + ) ]) ]) ]) @@ -611,7 +648,7 @@ defmodule Gringotts.Gateways.AuthorizeNet do element(:tax, [ add_amount(opts[:tax][:amount]), element(:name, opts[:tax][:name]), - element(:description, opts[:tax][:description]), + element(:description, opts[:tax][:description]) ]) end @@ -619,7 +656,7 @@ defmodule Gringotts.Gateways.AuthorizeNet do element(:duty, [ add_amount(opts[:duty][:amount]), element(:name, opts[:duty][:name]), - element(:description, opts[:duty][:description]), + element(:description, opts[:duty][:description]) ]) end @@ -627,7 +664,7 @@ defmodule Gringotts.Gateways.AuthorizeNet do element(:shipping, [ add_amount(opts[:shipping][:amount]), element(:name, opts[:shipping][:name]), - element(:description, opts[:shipping][:description]), + element(:description, opts[:shipping][:description]) ]) end @@ -672,7 +709,7 @@ defmodule Gringotts.Gateways.AuthorizeNet do element(:city, opts[:ship_to][:city]), element(:state, opts[:ship_to][:state]), element(:zip, opts[:ship_to][:zip]), - element(:country, opts[:ship_to][:country]) + element(:country, opts[:ship_to][:country]) ]) end @@ -683,13 +720,185 @@ defmodule Gringotts.Gateways.AuthorizeNet do defp join_string(list, symbol) do Enum.join(list, symbol) end - + defp base_url(opts) do - cond do - opts[:config][:mode] == :prod -> @production_url - opts[:config][:mode] == :test -> @test_url - true -> @test_url - end + if opts[:config][:mode] == :prod do + @production_url + else + @test_url + end end + ################################################################################## + # RESPONSE_HANDLER MODULE # + # # + ################################################################################## + + defmodule ResponseHandler do + @moduledoc false + alias Gringotts.Response + + @supported_response_types [ + "authenticateTestResponse", + "createTransactionResponse", + "ErrorResponse", + "createCustomerProfileResponse", + "createCustomerPaymentProfileResponse", + "deleteCustomerProfileResponse" + ] + + @avs_code_translator %{ + # The street address matched, but the postal code did not. + "A" => {"pass", "fail"}, + # No address information was provided. + "B" => {nil, nil}, + # The AVS check returned an error. + "E" => {"fail", nil}, + # The card was issued by a bank outside the U.S. and does not support AVS. + "G" => {nil, nil}, + # Neither the street address nor postal code matched. + "N" => {"fail", "fail"}, + # AVS is not applicable for this transaction. + "P" => {nil, nil}, + # Retry — AVS was unavailable or timed out. + "R" => {nil, nil}, + # AVS is not supported by card issuer. + "S" => {nil, nil}, + # Address information is unavailable. + "U" => {nil, nil}, + # The US ZIP+4 code matches, but the street address does not. + "W" => {"fail", "pass"}, + # Both the street address and the US ZIP+4 code matched. + "X" => {"pass", "pass"}, + # The street address and postal code matched. + "Y" => {"pass", "pass"}, + # The postal code matched, but the street address did not. + "Z" => {"fail", "pass"}, + # fallback in-case of absence + "" => {nil, nil}, + # fallback in-case of absence + nil => {nil, nil} + } + + @cvc_code_translator %{ + "M" => "CVV matched.", + "N" => "CVV did not match.", + "P" => "CVV was not processed.", + "S" => "CVV should have been present but was not indicated.", + "U" => "The issuer was unable to process the CVV check.", + # fallback in-case of absence + nil => nil + } + + @cavv_code_translator %{ + "" => "CAVV not validated.", + "0" => "CAVV was not validated because erroneous data was submitted.", + "1" => "CAVV failed validation.", + "2" => "CAVV passed validation.", + "3" => "CAVV validation could not be performed; issuer attempt incomplete.", + "4" => "CAVV validation could not be performed; issuer system error.", + "5" => "Reserved for future use.", + "6" => "Reserved for future use.", + "7" => + "CAVV failed validation, but the issuer is available. Valid for U.S.-issued card submitted to non-U.S acquirer.", + "8" => + "CAVV passed validation and the issuer is available. Valid for U.S.-issued card submitted to non-U.S. acquirer.", + "9" => + "CAVV failed validation and the issuer is unavailable. Valid for U.S.-issued card submitted to non-U.S acquirer.", + "A" => + "CAVV passed validation but the issuer unavailable. Valid for U.S.-issued card submitted to non-U.S acquirer.", + "B" => "CAVV passed validation, information only, no liability shift.", + # fallback in-case of absence + nil => nil + } + + def respond(body) do + response_map = XmlToMap.naive_map(body) + + case extract_gateway_response(response_map) do + :undefined_response -> + { + :error, + %Response{ + reason: "Undefined response from AunthorizeNet", + raw: body, + message: "You might wish to open an issue with Gringotts." + } + } + + result -> + build_response(result, %Response{raw: body, status_code: 200}) + end + end + + def extract_gateway_response(response_map) do + # The type of the response should be supported + # Find the first non-nil from the above, if all are `nil`... + # We are in trouble! + @supported_response_types + |> Stream.map(&Map.get(response_map, &1, nil)) + |> Enum.find(:undefined_response, & &1) + end + + defp build_response(%{"messages" => %{"resultCode" => "Ok"}} = result, base_response) do + {:ok, ResponseHandler.parse_gateway_success(result, base_response)} + end + + defp build_response(%{"messages" => %{"resultCode" => "Error"}} = result, base_response) do + {:error, ResponseHandler.parse_gateway_error(result, base_response)} + end + + def parse_gateway_success(result, base_response) do + id = result["transactionResponse"]["transId"] + message = result["messages"]["message"]["text"] + avs_result = result["transactionResponse"]["avsResultCode"] + cvc_result = result["transactionResponse"]["cvvResultCode"] + cavv_result = result["transactionResponse"]["cavvResultCode"] + gateway_code = result["messages"]["message"]["code"] + + base_response + |> set_id(id) + |> set_message(message) + |> set_gateway_code(gateway_code) + |> set_avs_result(avs_result) + |> set_cvc_result(cvc_result) + |> set_cavv_result(cavv_result) + end + + def parse_gateway_error(result, base_response) do + message = result["messages"]["message"]["text"] + gateway_code = result["messages"]["message"]["code"] + + error_text = result["transactionResponse"]["errors"]["error"]["errorText"] + error_code = result["transactionResponse"]["errors"]["error"]["errorCode"] + reason = "#{error_text} [Error code (#{error_code})]" + + base_response + |> set_message(message) + |> set_gateway_code(gateway_code) + |> set_reason(reason) + end + + ############################################################################ + # HELPERS # + ############################################################################ + + defp set_id(response, id), do: %{response | id: id} + defp set_message(response, message), do: %{response | message: message} + defp set_gateway_code(response, code), do: %{response | gateway_code: code} + defp set_reason(response, body), do: %{response | reason: body} + + defp set_avs_result(response, avs_code) do + {street, zip_code} = @avs_code_translator[avs_code] + %{response | avs_result: %{street: street, zip_code: zip_code}} + end + + defp set_cvc_result(response, cvv_code) do + %{response | cvc_result: @cvc_code_translator[cvv_code]} + end + + defp set_cavv_result(response, cavv_code) do + Map.put(response, :cavv_result, @cavv_code_translator[cavv_code]) + end + end end diff --git a/lib/gringotts/gateways/base.ex b/lib/gringotts/gateways/base.ex index 92f8cfd2..145b4b7a 100644 --- a/lib/gringotts/gateways/base.ex +++ b/lib/gringotts/gateways/base.ex @@ -1,15 +1,29 @@ defmodule Gringotts.Gateways.Base do + @moduledoc """ + Dummy implementation of the Gringotts API + + All gateway implementations must `use` this module as it provides (pseudo) + implementations for the all methods of the Gringotts API. + + In case `GatewayXYZ` does not implement `unstore`, the following call would + not raise an error: + ``` + Gringotts.unstore(GatewayXYZ, "some_registration_id") + ``` + because this module provides an implementation. + """ + alias Gringotts.Response defmacro __using__(_) do quote location: :keep do @doc false - def purchase(_amount, _card_or_id, _opts) do + def purchase(_amount, _card_or_id, _opts) do not_implemented() end @doc false - def authorize(_amount, _card_or_id, _opts) do + def authorize(_amount, _card_or_id, _opts) do not_implemented() end @@ -38,33 +52,18 @@ defmodule Gringotts.Gateways.Base do not_implemented() end - defp http(method, path, params \\ [], opts \\ []) do - credentials = Keyword.get(opts, :credentials) - headers = [{"Content-Type", "application/x-www-form-urlencoded"}] - data = params_to_string(params) - - HTTPoison.request(method, path, data, headers, [hackney: [basic_auth: credentials]]) - end - - defp money_to_cents(amount) when is_float(amount) do - trunc(amount * 100) - end - - defp money_to_cents(amount) do - amount * 100 - end - - defp params_to_string(params) do - params |> Enum.filter(fn {_k, v} -> v != nil end) - |> URI.encode_query - end - @doc false defp not_implemented do {:error, Response.error(code: :not_implemented)} end - defoverridable [purchase: 3, authorize: 3, capture: 3, void: 2, refund: 3, store: 2, unstore: 2] + defoverridable purchase: 3, + authorize: 3, + capture: 3, + void: 2, + refund: 3, + store: 2, + unstore: 2 end end end diff --git a/lib/gringotts/gateways/bogus.ex b/lib/gringotts/gateways/bogus.ex index f77178f5..ada7575d 100644 --- a/lib/gringotts/gateways/bogus.ex +++ b/lib/gringotts/gateways/bogus.ex @@ -1,4 +1,6 @@ defmodule Gringotts.Gateways.Bogus do + @moduledoc false + use Gringotts.Gateways.Base alias Gringotts.{ @@ -6,36 +8,21 @@ defmodule Gringotts.Gateways.Bogus do Response } - def authorize(_amount, _card_or_id, _opts), - do: success() - - def purchase(_amount, _card_or_id, _opts), - do: success() - - def capture(id, amount, _opts), - do: success(id) + @some_authorization_id "14a62fff80f24a25f775eeb33624bbb3" - def void(id, _opts), - do: success(id) + def authorize(_amount, _card_or_id, _opts), do: success() - def refund(_amount, id, _opts), - do: success(id) + def purchase(_amount, _card_or_id, _opts), do: success() - def store(_card=%CreditCard{}, _opts), - do: success() + def capture(_id, _amount, _opts), do: success() - def unstore(customer_id, _opts), - do: success(customer_id) + def void(_id, _opts), do: success() - defp success, - do: {:ok, Response.success(authorization: random_string())} + def refund(_amount, _id, _opts), do: success() - defp success(id), - do: {:ok, Response.success(authorization: id)} + def store(%CreditCard{} = _card, _opts), do: success() - defp random_string(length \\ 10), - do: 1..length |> Enum.map(&random_char/1) |> Enum.join + def unstore(_customer_id, _opts), do: success() - defp random_char(_), - do: to_string(:rand.uniform(9)) + defp success, do: {:ok, Response.success(id: @some_authorization_id)} end diff --git a/lib/gringotts/gateways/cams.ex b/lib/gringotts/gateways/cams.ex index eeb70227..2f1d5805 100644 --- a/lib/gringotts/gateways/cams.ex +++ b/lib/gringotts/gateways/cams.ex @@ -1,332 +1,471 @@ defmodule Gringotts.Gateways.Cams do - @moduledoc ~S""" - A module for working with the Cams payment gateway. - - You can test gateway operations in [CAMS API TEST MODE](https://secure.centralams.com). - Test it using these crediantials **username:** `testintegrationc`, **password:** `password9`, - as well as you can find api docs in this test account under **integration** link. - - The following features of CAMS are implemented: - - | Action | Method | - | ------ | ------ | - | Authorize | `authorize/3` | - | Capture | `capture/3` | - | Purchase | `purchase/3` | - | Refund | `refund/3` | - | Cancel | `void/2` | + @moduledoc """ + [CAMS][home] gateway implementation. + + CAMS provides a [sandbox account][dashboard] with documentation under the + [`integration` tab][docs]. The login credentials are: + + | Key | Credentials | + | ------ | -------- | + | username | `testintegrationc` | + | password | `password9` | + + The [video tutorials][videos] (on vimeo) are excellent. + + The following features of CAMS are implemented: + + | Action | Method | + | ------ | ------ | + | Authorize | `authorize/3` | + | Capture | `capture/3` | + | Purchase | `purchase/3` | + | Refund | `refund/3` | + | Cancel | `void/2` | ## The `opts` argument - Most `Gringotts` API calls accept an optional `Keyword` list `opts` to supply - optional arguments for transactions with the Cams gateway. The following keys - are supported: - - | Key | Remark | Status | - | ---- | --- | ---- | - | `billing_address` | | Not implemented | - | `address` | | Not implemented | - | `currency` | | **Implemented** | - | `order_id` | | Not implemented | - | `description` | | Not implemented | - - All these keys are being implemented, track progress in - [issue #42](https://github.com/aviabird/gringotts/issues/42)! - - ## Configuration parameters for Cams: - - | Config parameter | Cams secret | - | ------- | ---- | - | `:username` | **Username** | - | `:password` | **Password** | - + Most `Gringotts` API calls accept an optional `keyword` list `opts` to supply + optional arguments for transactions with the CAMS gateway. The following keys + are supported: + + | Key | Type | Remark | + | ---- | ---- | --- | + | `billing_address` | `map` | The address of the customer | + | `order_id` | `String.t` | Merchant provided identifier | + | `description` | `String.t` | Merchant provided description of the transaction | + + > CAMS supports more optional keys and you can raise an [issue][issues] if + this is important to you. + + [issues]: https://github.com/aviabird/gringotts/issues/new + + ### Schema + + * `billing_address` is a `map` from `atoms` to `String.t`, and can include any + of the keys from: + `:name, :address1, :address2, :company, :city, :state, :zip, :country, :phone, :fax]` + + ## Registering your CAMS account at `Gringotts` + + | Config parameter | CAMS secret | + | ------- | ---- | + | `:username` | **Username** | + | `:password` | **Password** | + > Your Application config **must include the `:username`, `:password` - > fields** and would look something like this: - + > fields** and would look something like this: + config :gringotts, Gringotts.Gateways.Cams, - adapter: Gringotts.Gateways.Cams, - username: "your_secret_user_name", - password: "your_secret_password", - + username: "your_secret_user_name", + password: "your_secret_password", + + ## Scope of this module - ## Scope of this module, and _quirks_ + * CAMS **does not** process money in cents. + * Although CAMS supports payments from electronic check & various cards this module only + accepts payments via `VISA`, `MASTER`, `AMERICAN EXPRESS` and `DISCOVER`. - * Cams process money in cents. - * Although Cams supports payments from electronic check & various cards this library only - accepts payments by cards like *visa*, *master*, *american_express* and *discover*. + ## Supported countries + **citation-needed** + + ## Supported currencies + **citation-needed** ## Following the examples - 1. First, set up a sample application and configure it to work with Cams. - - You could do that from scratch by following our [Getting Started](#) guide. - - To save you time, we recommend [cloning our example - repo](https://github.com/aviabird/gringotts_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" - that you get after registering with Cams. - - 2. Run an `iex` session with `iex -S mix` and add some variable bindings and - aliases to it (to save some time): - ``` - iex> alias Gringotts.{Response, CreditCard, Gateways.Cams} - iex> opts = [currency: "USD"] # The default currency is USD, and this is just for an example. - iex> payment = %CreditCard{number: "4111111111111111", month: 11, year: 2018, - first_name: "Longbob", last_name: "Longsen", - verification_code: "123", brand: "visa"} - ``` - We'll be using these in the examples below. + 1. First, set up a sample application and configure it to work with CAMS. + - You could do that from scratch by following our [Getting Started][gs] guide. + - To save you time, we recommend [cloning our example][example-repo] that + gives you a pre-configured sample app ready-to-go. + + You could use the same config or update it the with your "secrets" that + you get after [registering with + CAMS](#module-registering-your-cams-account-at-gringotts). + + 2. To save a lot of time, create a [`.iex.exs`][iex-docs] file as shown in + [this gist][cams.iex.exs] to introduce a set of handy bindings and + aliases. + + We'll be using these bindings in the examples below. + + [example-repo]: https://github.com/aviabird/gringotts_example + [iex-docs]: https://hexdocs.pm/iex/IEx.html#module-the-iex-exs-file + [cams.iex.exs]: https://gist.github.com/oyeb/9a299df95cc13a87324e321faca5c9b8 + + ## Integrating with phoenix + + Refer the [GringottsPay][gpay-heroku-cams] website for an example of how to + integrate CAMS with phoenix. The source is available [here][gpay-repo]. + + [gpay-repo]: https://github.com/aviabird/gringotts_payment + [gpay-heroku-cams]: http://gringottspay.herokuapp.com/cams ## TODO - * Credit Card Operations + * Operations using Credit Card - Credit - * Electronic Check + * Operations using electronic checks - Sale - Void - Refund + + [home]: http://www.centralams.com/ + [docs]: https://secure.centralams.com/merchants/resources/integration/integration_portal.php?tid=d669ab54bb17e34c5ff2cfe504f033e7 + [dashboard]: https://secure.centralams.com + [videos]: https://secure.centralams.com/merchants/video.php?tid=d669ab54bb17e34c5ff2cfe504f033e7 + [gs]: # + [example-repo]: https://github.com/aviabird/gringotts_example """ - @live_url "https://secure.centralams.com/gw/api/transact.php" - @default_currency "USD" - @headers [{"Content-Type", "application/x-www-form-urlencoded"}] + use Gringotts.Gateways.Base - use Gringotts.Adapter, - required_config: [:username, :password, :default_currency] - alias Gringotts.{CreditCard, Response} + use Gringotts.Adapter, required_config: [:username, :password] + + alias Gringotts.{CreditCard, Response, Money} alias Gringotts.Gateways.Cams.ResponseHandler, as: ResponseParser - import Poison, only: [decode!: 1] + @live_url "https://secure.centralams.com/gw/api/transact.php" + @headers [{"Content-Type", "application/x-www-form-urlencoded"}] + @doc """ - Transfers `amount` from the customer to the merchant. + 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 and + also triggers risk management. Funds are not transferred. + + When followed up with a `capture/3` transaction, funds will be transferred to + the merchant's account upon settlement. - Function to charge a user credit card for the specified amount. It performs authorize - and capture at the same time.Purchase transaction are submitted and immediately sent for settlement. - - After successful purchase it returns an `authorization` which can be used later to: - * `refund/3` an amount. - * `void/2` a transaction(*if Not settled*). + CAMS returns a **Transaction ID** (available in the `Response.authorization` + field) which can be used later to: + * `capture/3` an amount. + * `void/2` an authorized transaction. + + ## Optional Fields + options[ + order_id: String, + description: String + ] ## Examples - payment = %CreditCard{ - number: "4111111111111111", month: 11, year: 2018, - first_name: "Longbob", last_name: "Longsen", - verification_code: "123", brand: "visa" - } - options = [currency: "USD"] - money = 100 - - iex> Gringotts.purchase(Gringotts.Gateways.Cams, money, payment, options) + The following example shows how one would (pre) authorize a payment of $20 on + a sample `card`. + ``` + iex> card = %CreditCard{first_name: "Harry", + last_name: "Potter", + number: "4111111111111111", + year: 2099, + month: 12, + verification_code: "999", + brand: "VISA"} + iex> money = Money.new(20, :USD) + iex> {:ok, auth_result} = Gringotts.authorize(Gringotts.Gateways.Cams, money, card) + ``` """ - @spec purchase(number, CreditCard.t, Keyword) :: Response - def purchase(money, payment, options) do - post = [] - |> add_invoice(money, options) - |> add_payment(payment) - |> add_address(payment, options) - commit("sale", post, options) + @spec authorize(Money.t(), CreditCard.t(), keyword) :: {:ok | :error, Response.t()} + def authorize(money, %CreditCard{} = card, options) do + params = + [] + |> add_invoice(money) + |> add_payment(card) + |> add_address(card, options) + + commit("auth", params, options) end @doc """ - Authorize a credit card transaction. + Captures a pre-authorized amount. + + Captures can be submitted for an `amount` equal to or less than the originally + authorized `amount` in an `authorize/3`ation referenced by `transaction_id`. + + Partial captures are allowed, and the remaining amount is released back to + the payment source [(video)][auth-and-capture]. - The authorization validates the `card` details with the banking network, places a hold on the - transaction amount in the customer’s issuing bank and also triggers risk management. - Funds are not transferred.It needs to be followed up with a capture transaction to transfer the funds - to merchant account.After successful capture, transaction will be sent for settlement. - - Cams returns an `authorization` which can be used later to: - * `capture/3` an amount. - * `void/2` a authorized transaction. + > Multiple, partial captures on the same `authorization` token are **not supported**. + CAMS returns a **Transaction ID** (available in the `Response.authorization` + field) which can be used later to: + * `refund/3` + * `void/2` *(only before settlements!)* + + [auth-and-capture]: https://vimeo.com/200903640 ## Examples - payment = %{ - number: "4111111111111111", month: 11, year: 2018, - first_name: "Longbob", last_name: "Longsen", - verification_code: "123", brand: "visa" - } - options = [currency: "USD"] - money = 100 - - iex> Gringotts.authorize(Gringotts.Gateways.Cams, money, payment, options) + The following example shows how one would (partially) capture a previously + authorized a payment worth $10 by referencing the obtained authorization `id`. + ``` + iex> card = %CreditCard{first_name: "Harry", + last_name: "Potter", + number: "4111111111111111", + year: 2099, + month: 12, + verification_code: "999", + brand: "VISA"} + iex> money = Money.new(10, :USD) + iex> authorization = auth_result.authorization + # authorization = "some_authorization_transaction_id" + iex> {:ok, capture_result} = Gringotts.capture(Gringotts.Gateways.Cams, money, authorization) + ``` """ - @spec authorize(number, CreditCard.t, Keyword) :: Response - def authorize(money, payment, options) do - post = [] - |> add_invoice(money, options) - |> add_payment(payment) - |> add_address(payment, options) - commit("auth", post, options) + @spec capture(Money.t(), String.t(), keyword) :: {:ok | :error, Response.t()} + def capture(money, transaction_id, options) do + params = + [transactionid: transaction_id] + |> add_invoice(money) + + commit("capture", params, options) end @doc """ - Captures a pre-authorized amount. + Transfers `amount` from the customer to the merchant. - It captures existing authorizations for settlement.Only authorizations can be captured. - Captures can be submitted for an amount equal to or less than the original authorization. - It allows partial captures like many other gateways and release the remaining amount back to - the payment source **[citation-needed]**.Multiple captures can not be done using same `authorization`. + CAMS attempts to process a purchase on behalf of the customer, by debiting + `amount` from the customer's account by charging the customer's `card`. + + Returns a **Transaction ID** (available in the `Response.authorization` + field) which can be used later to: + * `refund/3` + * `void/2` *(only before settlements!)* ## Examples - authorization = "3904093075" - options = [currency: "USD"] - money = 100 - - iex> Gringotts.capture(Gringotts.Gateways.Cams, money, authorization, options) + The following example shows how one would process a payment worth $20 in + one-shot, without (pre) authorization. + ``` + iex> card = %CreditCard{first_name: "Harry", + last_name: "Potter", + number: "4111111111111111", + year: 2099, + month: 12, + verification_code: "999", + brand: "VISA"} + iex> money = Money.new(20, :USD) + iex> Gringotts.purchase(Gringotts.Gateways.Cams, money, card) + ``` """ - @spec capture(number, String.t, Keyword) :: Response - def capture(money, authorization, options) do - post = [transactionid: authorization] - add_invoice(post, money, options) - commit("capture", post, options) + @spec purchase(Money.t(), CreditCard.t(), keyword) :: {:ok | :error, Response.t()} + def purchase(money, %CreditCard{} = card, options) do + params = + [] + |> add_invoice(money) + |> add_payment(card) + |> add_address(card, options) + + commit("sale", params, options) end @doc """ - Refunds the `amount` to the customer's account with reference to a prior transfer. + Refunds the `amount` to the customer's account with reference to a prior transfer. - It will reverse a previously settled or pending settlement transaction. - If the transaction has not been settled, a transaction `void/2` can also reverse it. - It processes a full or partial refund worth `amount`, referencing a previous `purchase/3` or `capture/3`. - Authorized transaction can not be reversed. + It's better to `void/2` a transaction if it has not been settled yet! Refunds + lead to to two entries on the customer's bank statement, one for the original + `purchase/3` or `capture/3` and another for the `refund/3`. - `authorization` can be used to perform multiple refund, till: - * all the pre-authorized amount is captured or, - * the remaining amount is explicitly "reversed" via `void/2`. **[citation-needed]** + Multiple, partial refunds on the same **Transaction ID** are allowed till all + the captured amount is refunded. ## Examples - authorization = "3904093078" - options = [currency: "USD"] - money = 100 - - iex> Gringotts.refund(Gringotts.Gateways.Cams, money, authorization, options) + The following example shows how one would completely refund a previous capture + (and similarily for purchases). + ``` + iex> capture_id = capture_result.authorization + # capture_id = "some_capture_transaction_id" + iex> money = Money.new(20, :USD) + iex> Gringotts.refund(Gringotts.Gateways.Cams, money, capture_id) + ``` """ - @spec refund(number, String.t, Keyword) :: Response - def refund(money, authorization, options) do - post = [transactionid: authorization] - add_invoice(post, money, options) - commit("refund", post, options) + @spec refund(Money.t(), String.t(), keyword) :: {:ok | :error, Response.t()} + def refund(money, transaction_id, options) do + params = + [transactionid: transaction_id] + |> add_invoice(money) + + commit("refund", params, options) end @doc """ - Voids the referenced payment. - - Transaction voids will cancel an existing sale or captured authorization. - In addition, non-captured authorizations can be voided to prevent any future capture. - Voids can only occur if the transaction has not been settled. + Voids the referenced payment. + + Cancel a transaction referenced by `transaction_id` that is not settled + yet. This will erase any entries from the customer's bank statement. + + > `authorize/3` can be `void/2`ed to prevent captures. ## Examples - authorization = "3904093075" - options = [] - - iex> Gringotts.void(Gringotts.Gateways.Cams, authorization, options) + The following example shows how one would void a previous (pre) + authorization. + ``` + iex> auth_id = auth_result.id + # auth_id = "aome_authorisation_transaction_id" + iex> Gringotts.void(Gringotts.Gateways.Cams, auth_id) + ``` """ - @spec void(String.t, Keyword) :: Response - def void(authorization , options) do - post = [transactionid: authorization] - commit("void", post, options) + @spec void(String.t(), keyword) :: {:ok | :error, Response.t()} + def void(transaction_id, options) do + params = [transactionid: transaction_id] + commit("void", params, options) end - # private methods + @doc """ + Validates the `card` + + Verifies the credit `card` without authorizing any amount. - defp add_invoice(post, money, options) do - post - |> Keyword.put(:amount, money) - |> Keyword.put(:currency, (options[:config][:currency]) || @default_currency) + ## Examples + ``` + iex> card = %CreditCard{first_name: "Harry", + last_name: "Potter", + number: "4111111111111111", + year: 2099, + month: 12, + verification_code: "999", + brand: "VISA"} + iex> Gringotts.validate(Gringotts.Gateways.Cams, card) + ``` + """ + @spec validate(CreditCard.t(), keyword) :: {:ok | :error, Response.t()} + def validate(card, options) do + params = + [] + |> add_invoice(%{value: Decimal.new(0), currency: "USD"}) + |> add_payment(card) + |> add_address(card, options) + + commit("verify", params, options) end - defp add_payment(post, payment) do - exp_month = join_month(payment) - exp_year = payment.year - |> to_string() - |> String.slice(-2..-1) - - post - |> Keyword.put(:ccnumber, payment.number) - |> Keyword.put(:ccexp, "#{exp_month}#{exp_year}") - |> Keyword.put(:cvv, payment.verification_code) + # private methods + + defp add_invoice(params, money) do + {currency, value} = Money.to_string(money) + [amount: value, currency: currency] ++ params end - defp add_address(post, payment, options) do - post = post - |> Keyword.put(:firstname, payment.first_name) - |> Keyword.put(:lastname, payment.last_name) - - if options[:billing_address] do - address = options[:billing_address] - post = post - |> Keyword.put(:address1 , address[:address1]) - |> Keyword.put(:address2, address[:address2]) - |> Keyword.put(:city, address[:city]) - |> Keyword.put(:state, address[:state]) - |> Keyword.put(:zip, address[:zip]) - |> Keyword.put(:country, address[:country]) - |> Keyword.put(:phone, address[:phone]) - end + defp add_payment(params, %CreditCard{} = card) do + exp_month = card.month |> to_string |> String.pad_leading(2, "0") + exp_year = card.year |> to_string |> String.slice(-2..-1) + + [ccnumber: card.number, ccexp: "#{exp_month}#{exp_year}", cvv: card.verification_code] ++ + params end - defp join_month(payment) do - payment.month - |> to_string - |> String.pad_leading(2, "0") + defp add_address(params, card, options) do + params ++ + [firstname: card.first_name, lastname: card.last_name] ++ + if options[:billing_address] != nil, do: Enum.into(options[:billing_address], []), else: [] end defp commit(action, params, options) do url = @live_url - params = params - |> Keyword.put(:type, action) - |> Keyword.put(:password, options[:config][:password]) - |> Keyword.put(:username, options[:config][:username]) - |> params_to_string - + + auth = [ + type: action, + password: options[:config][:password], + username: options[:config][:username] + ] + url - |> HTTPoison.post(params, @headers) - |> ResponseParser.parse + |> HTTPoison.post({:form, auth ++ params}, @headers) + |> ResponseParser.parse() end defmodule ResponseHandler do @moduledoc false alias Gringotts.Response + # Fetched from CAMS POST API docs. + @avs_code_translator %{ + "X" => {nil, "pass: 9-character numeric ZIP"}, + "Y" => {nil, "pass: 5-character numeric ZIP"}, + "D" => {nil, "pass: 5-character numeric ZIP"}, + "M" => {nil, "pass: 5-character numeric ZIP"}, + "2" => {"pass: customer name", "pass: 5-character numeric ZIP"}, + "6" => {"pass: customer name", "pass: 5-character numeric ZIP"}, + "A" => {"pass: only address", "fail"}, + "B" => {"pass: only address", "fail"}, + "3" => {"pass: address, customer name", "fail"}, + "7" => {"pass: address, customer name", "fail"}, + "W" => {"fail", "pass: 9-character numeric ZIP match"}, + "Z" => {"fail", "pass: 5-character ZIP match"}, + "P" => {"fail", "pass: 5-character ZIP match"}, + "L" => {"fail", "pass: 5-character ZIP match"}, + "1" => {"pass: only customer name", "pass: 5-character ZIP"}, + "5" => {"pass: only customer name", "pass: 5-character ZIP"}, + "N" => {"fail", "fail"}, + "C" => {"fail", "fail"}, + "4" => {"fail", "fail"}, + "8" => {"fail", "fail"}, + "U" => {nil, nil}, + "G" => {nil, nil}, + "I" => {nil, nil}, + "R" => {nil, nil}, + "E" => {nil, nil}, + "S" => {nil, nil}, + "0" => {nil, nil}, + "O" => {nil, nil}, + "" => {nil, nil} + } + + # Fetched from CAMS POST API docs. + @cvc_code_translator %{ + "M" => "pass", + "N" => "fail", + "P" => "not_processed", + "S" => "Merchant indicated that CVV2/CVC2 is not present on card", + "U" => "Issuer is not certified and/or has not provided Visa encryption key" + } + @doc false def parse({:ok, %HTTPoison.Response{body: body, status_code: 200}}) do - body = URI.decode_query(body) - [] - |> set_authorization(body) - |> set_success(body) - |> set_message(body) - |> set_params(body) - |> set_error_code(body) - |> handle_opts() - end + decoded_body = URI.decode_query(body) + {street, zip_code} = @avs_code_translator[decoded_body["avsresponse"]] + gateway_code = decoded_body["response_code"] + message = decoded_body["responsetext"] + + response = %Response{ + status_code: 200, + id: decoded_body["transactionid"], + gateway_code: gateway_code, + avs_result: %{street: street, zip_code: zip_code}, + cvc_result: @cvc_code_translator[decoded_body["cvvresponse"]], + message: decoded_body["responsetext"], + raw: body + } - defp set_authorization(opts, %{"transactionid" => id}) do - opts ++ [authorization: id] + if successful?(gateway_code) do + {:ok, response} + else + {:error, %{response | reason: message}} + end end - defp set_message(opts, %{"responsetext" => message}) do - opts ++ [message: message] - end + def parse({:ok, %HTTPoison.Response{body: body, status_code: code}}) do + response = %Response{ + status_code: code, + raw: body + } - defp set_params(opts, body) do - opts ++ [params: body] + {:error, response} end - defp set_error_code(opts, %{"response_code" => response_code}) do - opts ++ [error_code: response_code] + def parse({:error, %HTTPoison.Error{} = error}) do + { + :error, + %Response{ + reason: "network related failure", + message: "HTTPoison says '#{error.reason}' [ID: #{error.id || "nil"}]", + success: false + } + } end - defp set_success(opts, %{"response_code" => response_code}) do - opts ++ [success: response_code == "100"] + defp successful?(gateway_code) do + gateway_code == "100" end - - defp handle_opts(opts) do - {:ok, Response.success(opts)} - end - end end diff --git a/lib/gringotts/gateways/global_collect.ex b/lib/gringotts/gateways/global_collect.ex new file mode 100644 index 00000000..6075451d --- /dev/null +++ b/lib/gringotts/gateways/global_collect.ex @@ -0,0 +1,475 @@ +defmodule Gringotts.Gateways.GlobalCollect do + @moduledoc """ + [GlobalCollect][home] gateway implementation. + + For further details, please refer [GlobalCollect API documentation][docs]. + + Following are the features that have been implemented for GlobalCollect: + + | Action | Method | + | ------ | ------ | + | Authorize | `authorize/3` | + | Purchase | `purchase/3` | + | Capture | `capture/3` | + | Refund | `refund/3` | + | Void | `void/2` | + + ## Optional parameters + + Most `Gringotts` API calls accept an optional `Keyword` list `opts` to supply + optional arguments for transactions with the gateway. + + | Key | Remark | + | ---- | --- | + | `merchantCustomerId` | Identifier for the consumer that can be used as a search criteria in the Global Collect Payment Console | + | `description` | Descriptive text that is used towards to consumer, either during an online checkout at a third party and/or on the statement of the consumer | + | `dob` | The date of birth of the consumer Format: YYYYMMDD | + | `company` | Name of company, as a consumer | + | `email` | Email address of the consumer | + | `phone` | Phone number of the consumer | + | `invoice` | Object containing additional invoice data | + | `billingAddress` | Object containing billing address details | + | `shippingAddress` | Object containing shipping address details | + | `name` | Object containing the name details of the consumer | + | `skipAuthentication` | 3D Secure Authentication will be skipped for this transaction if set to true | + + For more details of the required keys refer [this.][options] + ## Registering your GlobalCollect account at `Gringotts` + + After creating your account successfully on [GlobalCollect][home] open the + [dashboard][dashboard] to fetch the `secret_api_key`, `api_key_id` and + `merchant_id` from the menu. + + Here's how the secrets map to the required configuration parameters for + GlobalCollect: + + | Config parameter | GlobalCollect secret | + | ------- | ---- | + | `:secret_api_key`| **SecretApiKey** | + | `:api_key_id` | **ApiKeyId** | + | `:merchant_id` | **MerchantId** | + + Your Application config **must include the `:secret_api_key`, `:api_key_id`, + `:merchant_id` field(s)** and would look something like this: + + config :gringotts, Gringotts.Gateways.GlobalCollect, + secret_api_key: "your_secret_secret_api_key" + api_key_id: "your_secret_api_key_id" + merchant_id: "your_secret_merchant_id" + + ## Scope of this module + + * [All amount fields in globalCollect are in cents with each amount having 2 decimals.][amountReference] + + ## Supported currencies and countries + + The GlobalCollect platform supports payments in [over 150 currencies][currencies]. + + ## Following the examples + + 1. First, set up a sample application and configure it to work with GlobalCollect. + - 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-globalcollect-account-at-gringotts). + + 2. Run an `iex` session with `iex -S mix` and add some variable bindings and + aliases to it (to save some time): + ``` + iex> alias Gringotts.{Response, CreditCard, Gateways.GlobalCollect} + iex> shippingAddress = %{ + street: "Desertroad", + houseNumber: "1", + additionalInfo: "Suite II", + zip: "84536", + city: "Monument Valley", + state: "Utah", + countryCode: "US" + } + + iex> billingAddress = %{ + street: "Desertroad", + houseNumber: "13", + additionalInfo: "b", + zip: "84536", + city: "Monument Valley", + state: "Utah", + countryCode: "US" + } + + iex> invoice = %{ + invoiceNumber: "000000123", + invoiceDate: "20140306191500" + } + + iex> name = %{ + title: "Miss", + firstName: "Road", + surname: "Runner" + } + + iex> card = %CreditCard{ + number: "4567350000427977", + month: 12, + year: 43, + first_name: "John", + last_name: "Doe", + verification_code: "123", + brand: "VISA" + } + + iex> opts = [ + description: "Store Purchase 1437598192", + merchantCustomerId: "234", dob: "19490917", + company: "asma", email: "johndoe@gmail.com", + phone: "7765746563", invoice: invoice, + billingAddress: billingAddress, + shippingAddress: shippingAddress, + name: name, skipAuthentication: "true" + ] + ``` + + We'll be using these in the examples below. + + [home]: http://www.globalcollect.com/ + [docs]: https://epayments-api.developer-ingenico.com/s2sapi/v1/en_US/index.html + [dashboard]: https://sandbox.account.ingenico.com/#/dashboard + [gs]: # + [options]: https://epayments-api.developer-ingenico.com/s2sapi/v1/en_US/java/payments/create.html#payments-create-payload + [currencies]: https://epayments.developer-ingenico.com/best-practices/services/currency-conversion + [example]: https://github.com/aviabird/gringotts_example + [amountReference]: https://epayments-api.developer-ingenico.com/c2sapi/v1/en_US/swift/services/convertAmount.html + """ + @base_url "https://api-sandbox.globalcollect.com/v1/" + + use Gringotts.Gateways.Base + + # 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: [:secret_api_key, :api_key_id, :merchant_id] + + import Poison, only: [decode: 1] + + alias Gringotts.{Money, CreditCard, Response} + + @brand_map %{ + VISA: "1", + AMERICAN_EXPRESS: "2", + MASTER: "3", + DISCOVER: "128", + JCB: "125", + DINERS_CLUB: "132" + } + + @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 and + also triggers risk management. Funds are not transferred. + + GlobalCollect returns a payment id which can be further used to: + * `capture/3` an amount. + * `refund/3` an amount + * `void/2` a pre_authorization + + ## Example + + The following example shows how one would (pre) authorize a payment of $100 on + a sample `card`. + ``` + iex> card = %CreditCard{ + number: "4567350000427977", + month: 12, + year: 43, + first_name: "John", + last_name: "Doe", + verification_code: "123", + brand: "VISA" + } + + iex> amount = Money.new(100, :USD) + + iex> {:ok, auth_result} = Gringotts.authorize(Gringotts.Gateways.GlobalCollect, amount, card, opts) + ``` + """ + @spec authorize(Money.t(), CreditCard.t(), keyword) :: {:ok | :error, Response} + def authorize(amount, %CreditCard{} = card, opts) do + params = %{ + order: add_order(amount, opts), + cardPaymentMethodSpecificInput: add_card(card, opts) + } + + commit(:post, "payments", params, opts) + end + + @doc """ + Captures a pre-authorized `amount`. + + `amount` used in the pre-authorization referenced by `payment_id` is + transferred to the merchant account by GlobalCollect. + + ## Note + + Authorized payment with PENDING_APPROVAL status only allow a single capture whereas + the one with PENDING_CAPTURE status is used for payments that allow multiple captures. + + ## Example + + The following example shows how one would (partially) capture a previously + authorized a payment worth $100 by referencing the obtained authorization `id`. + + ``` + iex> amount = Money.new(100, :USD) + + iex> {:ok, capture_result} = Gringotts.capture(Gringotts.Gateways.GlobalCollect, auth_result.authorization, amount, opts) + + ``` + + """ + @spec capture(String.t(), Money.t(), keyword) :: {:ok | :error, Response} + def capture(payment_id, amount, opts) do + params = %{ + order: add_order(amount, opts) + } + + commit(:post, "payments/#{payment_id}/approve", params, opts) + end + + @doc """ + Transfers `amount` from the customer to the merchant. + + GlobalCollect 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 in one-shot, + without (pre) authorization. + + ``` + iex> card = %CreditCard{ + number: "4567350000427977", + month: 12, + year: 43, + first_name: "John", + last_name: "Doe", + verification_code: "123", + brand: "VISA" + } + + iex> amount = Money.new(100, :USD) + + iex> {:ok, purchase_result} = Gringotts.purchase(Gringotts.Gateways.GlobalCollect, amount, card, opts) + + ``` + """ + @spec purchase(Money.t(), CreditCard.t(), keyword) :: {:ok | :error, Response} + def purchase(amount, %CreditCard{} = card, opts) do + case authorize(amount, card, opts) do + {:ok, results} -> + payment_id = results.raw["payment"]["id"] + capture(payment_id, amount, opts) + + {:error, results} -> + {:error, results} + end + end + + @doc """ + Voids the referenced payment. + + This makes it impossible to process the payment any further and will also try + to reverse an authorization on a card. + Reversing an authorization that you will not be utilizing will prevent you + from having to [pay a fee/penalty][void] for unused authorization requests. + + [void]: https://epayments-api.developer-ingenico.com/s2sapi/v1/en_US/java/payments/cancel.html#payments-cancel-request + ## Example + + The following example shows how one would void a previous (pre) + authorization. Remember that our `capture/3` example only did a complete + capture. + + ``` + iex> {:ok, void_result} = Gringotts.void(Gringotts.Gateways.GlobalCollect, auth_result.authorization, opts) + + ``` + """ + @spec void(String.t(), keyword) :: {:ok | :error, Response} + def void(payment_id, opts) do + commit(:post, "payments/#{payment_id}/cancel", [], opts) + end + + @doc """ + Refunds the `amount` to the customer's account with reference to a prior transfer. + + You always have the option to refund just a portion of the payment amount. + It is also possible to submit multiple refund requests on one payment as long + as the total amount to be refunded does not exceed the total amount that was paid. + + ## Example + + The following example shows how one would refund a previous purchase (and + similarily for captures). + + ``` + iex> amount = Money.new(100, :USD) + + iex> {:ok, refund_result} = Gringotts.refund(Gringotts.Gateways.GlobalCollect, auth_result.authorization, amount) + ``` + """ + @spec refund(Money.t(), String.t(), keyword) :: {:ok | :error, Response} + def refund(amount, payment_id, opts) do + params = %{ + amountOfMoney: add_money(amount), + customer: add_customer(opts) + } + + commit(:post, "payments/#{payment_id}/refund", params, opts) + end + + ############################################################################### + # PRIVATE METHODS # + ############################################################################### + + defp add_order(money, options) do + %{ + amountOfMoney: add_money(money), + customer: add_customer(options), + references: %{ + descriptor: options[:description], + invoiceData: options[:invoice] + } + } + end + + defp add_money(amount) do + {currency, amount, _} = Money.to_integer(amount) + + %{ + amount: amount, + currencyCode: currency + } + end + + defp add_customer(options) do + %{ + merchantCustomerId: options[:merchantCustomerId], + personalInformation: %{ + name: options[:name] + }, + dateOfBirth: options[:dob], + companyInformation: %{ + name: options[:company] + }, + billingAddress: options[:billingAddress], + shippingAddress: options[:shippingAddress], + contactDetails: %{ + emailAddress: options[:email], + phoneNumber: options[:phone] + } + } + end + + defp add_card(card, opts) do + %{ + paymentProductId: Map.fetch!(@brand_map, String.to_atom(card.brand)), + skipAuthentication: opts[:skipAuthentication], + card: %{ + cvv: card.verification_code, + cardNumber: card.number, + expiryDate: "#{card.month}#{card.year}", + cardholderName: CreditCard.full_name(card) + } + } + end + + defp commit(method, path, params, opts) do + headers = create_headers(path, opts) + data = Poison.encode!(params) + merchant_id = opts[:config][:merchant_id] + url = "#{@base_url}#{merchant_id}/#{path}" + + gateway_response = HTTPoison.request(method, url, data, headers) + gateway_response |> respond + end + + defp create_headers(path, opts) do + datetime = Timex.now() |> Timex.local() + + date_string = + "#{Timex.format!(datetime, "%a, %d %b %Y %H:%M:%S", :strftime)} #{datetime.zone_abbr}" + + api_key_id = opts[:config][:api_key_id] + + sha_signature = auth_digest(path, date_string, opts) + + auth_token = "GCS v1HMAC:#{api_key_id}:#{Base.encode64(sha_signature)}" + [{"Content-Type", "application/json"}, {"Authorization", auth_token}, {"Date", date_string}] + end + + defp auth_digest(path, date_string, opts) do + secret_api_key = opts[:config][:secret_api_key] + merchant_id = opts[:config][:merchant_id] + + data = "POST\napplication/json\n#{date_string}\n/v1/#{merchant_id}/#{path}\n" + :crypto.hmac(:sha256, secret_api_key, data) + end + + # Parses GlobalCollect's response and returns a `Gringotts.Response` struct + # in a `:ok`, `:error` tuple. + @spec respond(term) :: {:ok | :error, Response} + defp respond(global_collect_response) + + defp respond({:ok, %{status_code: code, body: body}}) when code in [200, 201] do + case decode(body) do + {:ok, results} -> + { + :ok, + Response.success( + authorization: results["payment"]["id"], + raw: results, + status_code: code, + avs_result: + results["payment"]["paymentOutput"]["cardPaymentMethodSpecificOutput"][ + "fraudResults" + ]["avsResult"], + cvc_result: + results["payment"]["paymentOutput"]["cardPaymentMethodSpecificOutput"][ + "fraudResults" + ]["cvcResult"], + message: results["payment"]["status"], + fraud_review: + results["payment"]["paymentOutput"]["cardPaymentMethodSpecificOutput"][ + "fraudResults" + ]["fraudServiceResult"] + ) + } + + {:error, _} -> + {:error, Response.error(raw: body, message: "undefined response from GlobalCollect")} + end + end + + defp respond({:ok, %{status_code: status_code, body: body}}) do + {:ok, results} = decode(body) + message = Enum.map(results["errors"], fn x -> x["message"] end) + detail = List.to_string(message) + {:error, Response.error(status_code: status_code, message: detail, raw: results)} + end + + defp respond({:error, %HTTPoison.Error{} = error}) do + { + :error, + Response.error( + code: error.id, + reason: :network_fail?, + description: "HTTPoison says '#{error.reason}'" + ) + } + end +end diff --git a/lib/gringotts/gateways/mercadopago.ex b/lib/gringotts/gateways/mercadopago.ex new file mode 100644 index 00000000..8b9ea2f2 --- /dev/null +++ b/lib/gringotts/gateways/mercadopago.ex @@ -0,0 +1,242 @@ +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 mercadopago + gateway. The following keys are supported: + + | Key | Remark | + | ---- | --- | + | `email` | Email of the customer. Type - string . | + | `order_id` | Order id issued by the merchant. Type- integer. | + | `payment_method_id` | Payment network operators, eg. `visa`, `mastercard`. Type- string. | + | `customer_id` | Unique customer id issued by the gateway. For new customer it must be nil. Type- string | + + ## 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 with upto two decimal places. + + ## 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). + + 2. Run an `iex` session with `iex -S mix` and add some variable bindings : + ``` + iex> card = %CreditCard{first_name: "John", last_name: "Doe", number: "4509953566233704", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + ``` + + We'll be using these in the examples below. + + [gs]: https://github.com/aviabird/gringotts/wiki/ + [home]: https://www.mercadopago.com + [example]: https://github.com/aviabird/gringotts_example + """ + + # The Base module has the (abstract) public API, and some utility + # implementations. + @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] + + import Poison, only: [decode: 1] + + alias Gringotts.{CreditCard, Response} + + @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, customer_id} <- create_customer(opts), + {:ok, card_token} <- create_token(card, opts) do + {_, value, _, _} = Money.to_integer_exp(amount) + url_params = [access_token: opts[:config][:access_token]] + + body = + authorize_params(value, card, opts, card_token, customer_id, false) |> Poison.encode!() + + commit(:post, "/v1/payments", body, opts, params: url_params) + end + 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"}] + HTTPoison.request(method, "#{@base_url}#{path}", body, headers, url_params) |> respond(opts) + end + + # Parses mercadopago's response and returns a `Gringotts.Response` struct + # in a `:ok`, `:error` tuple. + + defp create_customer(opts) do + if Keyword.has_key?(opts, :customer_id) do + {:ok, opts[:customer_id]} + else + url_params = [access_token: opts[:config][:access_token]] + body = %{email: opts[:email]} |> Poison.encode!() + {state, res} = commit(:post, "/v1/customers", body, opts, params: url_params) + + if state == :error do + {state, res} + else + {state, res.id} + end + end + 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 = token_params(card) |> Poison.encode!() + + {state, res} = + commit(:post, "/v1/card_tokens/#{opts[:customer_id]}", body, opts, params: url_params) + + case state do + :error -> {state, res} + _ -> {state, res.id} + end + end + + defp authorize_params(value, %CreditCard{} = card, opts, token_id, customer_id, capture) do + %{ + payer: %{ + type: "customer", + id: customer_id, + first_name: card.first_name, + last_name: card.last_name + }, + order: %{ + type: "mercadopago", + id: opts[:order_id] + }, + installments: opts[:installments] || 1, + transaction_amount: value, + payment_method_id: String.downcase(card.brand), + token: token_id, + capture: capture + } + 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/lib/gringotts/gateways/monei.ex b/lib/gringotts/gateways/monei.ex index 82549e71..4d659656 100644 --- a/lib/gringotts/gateways/monei.ex +++ b/lib/gringotts/gateways/monei.ex @@ -1,8 +1,8 @@ defmodule Gringotts.Gateways.Monei do - @moduledoc ~S""" - [MONEI](https://www.monei.net) gateway implementation. + @moduledoc """ + [MONEI][home] gateway implementation. - For reference see [MONEI's API (v1) documentation](https://docs.monei.net). + For reference see [MONEI's API (v1) documentation][docs]. The following features of MONEI are implemented: @@ -15,36 +15,51 @@ defmodule Gringotts.Gateways.Monei do | Debit | `purchase/3` | `DB` | | Tokenization / Registrations | `store/2` | | - > **What's this last column `type`?**\ + > **What's this last column `type`?** + > > That's the `paymentType` of the request, which you can ignore unless you'd - > like to contribute to this module. Please read the [MONEI - > Guides](https://docs.monei.net). + > like to contribute to this module. Please read the [MONEI Guides][docs]. + + [home]: https://monei.net + [docs]: https://docs.monei.net ## The `opts` argument - Most `Gringotts` API calls accept an optional `Keyword` list `opts` to supply - optional arguments for transactions with the MONEI gateway. The following keys - are supported: - - | Key | Remark | Status | - | ---- | --- | ---- | - | `billing_address` | | Not implemented | - | `cart` | | Not implemented | - | `currency` | | **Implemented** | - | `customParameters` | | Not implemented | - | `customer` | | Not implemented | - | `invoice` | | Not implemented | - | `merchant` | | Not implemented | - | `shipping_address` | | Not implemented | - | `shipping_customer` | | Not implemented | - - > All these keys are being implemented, track progress in - > [issue #36](https://github.com/aviabird/gringotts/issues)! + Most `Gringotts` API calls accept an optional `keyword` list `opts` to supply + [optional arguments][extra-arg-docs] for transactions with the MONEI + gateway. The following keys are supported: + + | Key | Remark | + | ---- | --- | + | [`billing`][ba] | Address of the customer, which can be used for AVS risk check. | + | [`cart`][cart] | **Not Implemented** | + | [`custom`][custom] | It's a map of "name"-"value" pairs, and all of it is echoed back in the response. | + | [`customer`][c] | Annotate transactions with customer info on your Monei account, and helps in risk management. | + | [`invoice_id`][b] | Merchant provided invoice identifier, must be unique per transaction with Monei. | + | [`transaction_id`][b] | Merchant provided token for a transaction, must be unique per transaction with Monei. | + | [`category`][b] | The category of the transaction. | + | [`merchant`][m] | Information about the merchant, which overrides the cardholder's bank statement. | + | [`register`][t] | Also store payment data included in this request for future use. | + | [`shipping`][sa] | Location of recipient of goods, for logistics. | + | [`shipping_customer`][c] | Recipient details, could be different from `customer`. | + + > These keys are being implemented, track progress in [issue #36][iss36]! + + [extra-arg-docs]: https://docs.monei.net/reference/parameters + [ba]: https://docs.monei.net/reference/parameters#billing-address + [cart]: https://docs.monei.net/reference/parameters#cart + [custom]: https://docs.monei.net/reference/parameters#custom-parameters + [c]: https://docs.monei.net/reference/parameters#customer + [b]: https://docs.monei.net/reference/parameters#basic + [m]: https://docs.monei.net/reference/parameters#merchant + [t]: https://docs.monei.net/reference/parameters#tokenization + [sa]: https://docs.monei.net/reference/parameters#shipping-address + [iss36]: https://github.com/aviabird/gringotts/issues/36 ## Registering your MONEI account at `Gringotts` - After [making an account on MONEI](https://dashboard.monei.net/signin), head - to the dashboard and find your account "secrets" in the `Sub-Accounts > Overview` section. + After [making an account on MONEI][dashboard], head to the dashboard and find + your account "secrets" in the `Sub-Accounts > Overview` section. Here's how the secrets map to the required configuration parameters for MONEI: @@ -58,64 +73,68 @@ defmodule Gringotts.Gateways.Monei do fields** and would look something like this: config :gringotts, Gringotts.Gateways.Monei, - adapter: Gringotts.Gateways.Monei, userId: "your_secret_user_id", password: "your_secret_password", entityId: "your_secret_channel_id" + [dashboard]: https://dashboard.monei.net/signin + + ## Scope of this module + + * MONEI does not process money in cents, and the `amount` is rounded to 2 + decimal places. + * Although MONEI supports payments from [various][all-card-list] + [cards][card-acc], [banks][bank-acc] and [virtual accounts][virtual-acc] + (like some wallets), this library only accepts payments by [(supported) + cards][all-card-list]. + + [all-card-list]: https://support.monei.net/charges-and-refunds/accepted-credit-cards-payment-methods + [card-acc]: https://docs.monei.net/reference/parameters#card + [bank-acc]: https://docs.monei.net/reference/parameters#bank-account + [virtual-acc]: https://docs.monei.net/reference/parameters#virtual-account - ## Scope of this module, and _quirks_ + ## Supported countries - * MONEI does not process money in cents, and the `amount` is rounded to 2 decimal places. - * Although MONEI supports payments from [various - cards](https://support.monei.net/charges-and-refunds/accepted-credit-cards-payment-methods), - banks and virtual accounts (like some wallets), this library only accepts - payments by (supported) cards. + MONEI supports the countries listed [here][all-country-list] - ## Supported currencies and countries + ## Supported currencies - The following currencies are supported: `USD`, `GBP`, `NAD`, `TWD`, `VUV`, - `NZD`, `NGN`, `NIO`, `NGN`, `NOK`, `PKR`, `PAB`, `PGK`, `PYG`, `PEN`, `NPR`, - `ANG`, `AWG`, `PHP`, `QAR`, `RUB`, `RWF`, `SHP`, `STD`, `SAR`, `SCR`, `SLL`, - `SGD`, `VND`, `SOS`, `ZAR`, `ZWL`, `YER`, `SDG`, `SZL`, `SEK`, `CHF`, `SYP`, - `TJS`, `THB`, `TOP`, `TTD`, `AED`, `TND`, `TRY`, `AZN`, `UGX`, `MKD`, `EGP`, - `GBP`, `TZS`, `UYU`, `UZS`, `WST`, `YER`, `RSD`, `ZMW`, `TWD`, `AZN`, `GHS`, - `RSD`, `MZN`, `AZN`, `MDL`, `TRY`, `XAF`, `XCD`, `XOF`, `XPF`, `MWK`, `SRD`, - `MGA`, `AFN`, `TJS`, `AOA`, `BYN`, `BGN`, `CDF`, `BAM`, `UAH`, `GEL`, `PLN`, - `BRL` and `CUC`. + MONEI supports the currecncies [listed here][all-currency-list], and ***this + module*** supports a subset of those: - > [Here](https://support.monei.net/international/currencies-supported-by-monei) - > is the up-to-date currency list. _Please [raise an - > issue](https://github.com/aviabird/gringotts/issues) if the list above has - > become out-of-date!_ + :AED, :AFN, :ANG, :AOA, :AWG, :AZN, :BAM, :BGN, :BRL, :BYN, :CDF, :CHF, :CUC, + :EGP, :EUR, :GBP, :GEL, :GHS, :MDL, :MGA, :MKD, :MWK, :MZN, :NAD, :NGN, :NIO, + :NOK, :NPR, :NZD, :PAB, :PEN, :PGK, :PHP, :PKR, :PLN, :PYG, :QAR, :RSD, :RUB, + :RWF, :SAR, :SCR, :SDG, :SEK, :SGD, :SHP, :SLL, :SOS, :SRD, :STD, :SYP, :SZL, + :THB, :TJS, :TOP, :TRY, :TTD, :TWD, :TZS, :UAH, :UGX, :USD, :UYU, :UZS, :VND, + :VUV, :WST, :XAF, :XCD, :XOF, :XPF, :YER, :ZAR, :ZMW, :ZWL - MONEI supports the countries listed - [here](https://support.monei.net/international/what-countries-does-monei-support). + > Please [raise an issue][new-issue] if you'd like us to add support for more + > currencies + + [all-currency-list]: https://support.monei.net/international/currencies-supported-by-monei + [new-issue]: https://github.com/aviabird/gringotts/issues + [all-country-list]: https://support.monei.net/international/what-countries-does-monei-support ## Following the examples 1. First, set up a sample application and configure it to work with MONEI. - You could do that from scratch by following our [Getting Started](#) guide. - - To save you time, we recommend [cloning our example - repo](https://github.com/aviabird/gringotts_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" + - To save you time, we recommend [cloning our example repo][example-repo] + that gives you a pre-configured sample app ready-to-go. + + You could use the same config or update it the with your "secrets" that you see in `Dashboard > Sub-accounts` as described [above](#module-registering-your-monei-account-at-gringotts). - 2. Run an `iex` session with `iex -S mix` and add some variable bindings and - aliases to it (to save some time): - ``` - iex> alias Gringotts.{Response, CreditCard, Gateways.Monei} - iex> opts = [currency: "EUR"] # The default currency is EUR, and this is just for an example. - iex> card = %CreditCard{first_name: "Jo", - last_name: "Doe", - number: "4200000000000000", - year: 2099, month: 12, - verification_code: "123", brand: "VISA"} - ``` + 2. To save a lot of time, create a [`.iex.exs`][iex-docs] file as shown in + [this gist][monei.iex.exs] to introduce a set of handy bindings and + aliases. + + We'll be using these bindings in the examples below. - We'll be using these in the examples below. + [example-repo]: https://github.com/aviabird/gringotts_example + [iex-docs]: https://hexdocs.pm/iex/IEx.html#module-the-iex-exs-file + [monei.iex.exs]: https://gist.github.com/oyeb/a2e2ac5986cc90a12a6136f6bf1357e5 ## TODO @@ -129,11 +148,16 @@ defmodule Gringotts.Gateways.Monei do use Gringotts.Gateways.Base use Gringotts.Adapter, required_config: [:userId, :entityId, :password] import Poison, only: [decode: 1] - alias Gringotts.{CreditCard, Response} + alias Gringotts.{CreditCard, Response, Money} @base_url "https://test.monei-api.net" @default_headers ["Content-Type": "application/x-www-form-urlencoded", charset: "UTF-8"] - @default_currency "EUR" + + @supported_currencies ~w(AED AFN ANG AOA AWG AZN BAM BGN BRL BYN CDF CHF CUC + EGP EUR GBP GEL GHS MDL MGA MKD MWK MZN NAD NGN NIO NOK NPR NZD PAB PEN PGK + PHP PKR PLN PYG QAR RSD RUB RWF SAR SCR SDG SEK SGD SHP SLL SOS SRD STD SYP + SZL THB TJS TOP TRY TTD TWD TZS UAH UGX USD UYU UZS VND VUV WST XAF XCD XOF + XPF YER ZAR ZMW ZWL) @version "v1" @@ -154,8 +178,8 @@ defmodule Gringotts.Gateways.Monei do nil => {nil, nil} } - # MONEI supports payment by card, bank account and even something obscure: virtual account - # opts has the auth keys. + # MONEI supports payment by card, bank account and even something obscure: + # virtual account opts has the auth keys. @doc """ Performs a (pre) Authorize operation. @@ -169,35 +193,36 @@ defmodule Gringotts.Gateways.Monei do * `capture/3` _an_ amount. * `void/2` a pre-authorization. - ## Note + ## Note - A stand-alone pre-authorization [expires in - 72hrs](https://docs.monei.net/tutorials/manage-payments/backoffice). + * The `:register` option when set to `true` will store this card for future + use, and you will recieve a registration `token` in the `:token` field of + the `Response` struct. + * A stand-alone pre-authorization [expires in + 72hrs](https://docs.monei.net/tutorials/manage-payments/backoffice). ## Example - The following session shows how one would (pre) authorize a payment of $40 on a sample `card`. + The following example shows how one would (pre) authorize a payment of $42 on + a sample `card`. - iex> opts = [currency: "EUR"] # The default currency is EUR, and this is just for an example. - iex> card = %Gringotts.CreditCard{first_name: "Jo", last_name: "Doe", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} - iex> auth_result = Gringotts.authorize(Gringotts.Gateways.Monei, 40, card, opts) + iex> amount = Money.new(42, :USD) + iex> card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + iex> {:ok, auth_result} = Gringotts.authorize(Gringotts.Gateways.Monei, amount, card, opts) iex> auth_result.id # This is the authorization ID + iex> auth_result.token # This is the registration ID/token """ - @spec authorize(number, CreditCard.t(), keyword) :: {:ok | :error, Response} - def authorize(amount, card = %CreditCard{}, opts) when is_integer(amount) do - authorize(amount / 1, card, opts) - end + @spec authorize(Money.t(), CreditCard.t(), keyword) :: {:ok | :error, Response.t()} + def authorize(amount, %CreditCard{} = card, opts) do + {currency, value} = Money.to_string(amount) - def authorize(amount, card = %CreditCard{}, opts) when is_float(amount) do params = [ paymentType: "PA", - amount: :erlang.float_to_binary(amount, decimals: 2), - currency: currency(opts) + amount: value ] ++ card_params(card) - auth_info = Keyword.fetch!(opts, :config) - commit(:post, "payments", params, auth_info) + commit(:post, "payments", params, [{:currency, currency} | opts]) end @doc """ @@ -216,29 +241,24 @@ defmodule Gringotts.Gateways.Monei do ## Example - The following session shows how one would (partially) capture a previously + The following example shows how one would (partially) capture a previously authorized a payment worth $35 by referencing the obtained authorization `id`. - iex> opts = [currency: "EUR"] # The default currency is EUR, and this is just for an example. - iex> card = %Gringotts.CreditCard{first_name: "Jo", last_name: "Doe", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} - iex> capture_result = Gringotts.capture(Gringotts.Gateways.Monei, 35, auth_result.id, opts) + iex> amount = Money.new(35, :USD) + iex> {:ok, capture_result} = Gringotts.capture(Gringotts.Gateways.Monei, amount, auth_result.id, opts) """ - @spec capture(number, String.t(), keyword) :: {:ok | :error, Response} - def capture(amount, payment_id, opts) + @spec capture(String.t(), Money.t(), keyword) :: {:ok | :error, Response.t()} + def capture(payment_id, amount, opts) - def capture(amount, <>, opts) when is_integer(amount) do - capture(amount / 1, payment_id, opts) - end + def capture(<>, amount, opts) do + {currency, value} = Money.to_string(amount) - def capture(amount, <>, opts) when is_float(amount) do params = [ paymentType: "CP", - amount: :erlang.float_to_binary(amount, decimals: 2), - currency: currency(opts) + amount: value ] - auth_info = Keyword.fetch!(opts, :config) - commit(:post, "payments/#{payment_id}", params, auth_info) + commit(:post, "payments/#{payment_id}", params, [{:currency, currency} | opts]) end @doc """ @@ -247,73 +267,33 @@ defmodule Gringotts.Gateways.Monei do MONEI 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 session shows how one would process a payment in one-shot, - without (pre) authorization. - - iex> opts = [currency: "EUR"] # The default currency is EUR, and this is just for an example. - iex> card = %Gringotts.CreditCard{first_name: "Jo", last_name: "Doe", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} - iex> purchase_result = Gringotts.purchase(Gringotts.Gateways.Monei, 40, card, opts) - """ - @spec purchase(number, CreditCard.t(), keyword) :: {:ok | :error, Response} - def purchase(amount, card = %CreditCard{}, opts) when is_integer(amount) do - purchase(amount / 1, card, opts) - end - - def purchase(amount, card = %CreditCard{}, opts) when is_float(amount) do - params = - card_params(card) ++ - [ - paymentType: "DB", - amount: :erlang.float_to_binary(amount, decimals: 2), - currency: currency(opts) - ] - - auth_info = Keyword.fetch!(opts, :config) - commit(:post, "payments", params, auth_info) - end - - @doc """ - Voids the referenced payment. - - This method attempts a reversal of the either a previous `purchase/3` or - `authorize/3` referenced by `payment_id`. - - As a consequence, the customer will never see any booking on his - statement. Refer MONEI's [Backoffice - Operations](https://docs.monei.net/tutorials/manage-payments/backoffice) - guide. - - ## Voiding a previous authorization - - MONEI will reverse the authorization by sending a "reversal request" to the - payment source (card issuer) to clear the funds held against the - authorization. If some of the authorized amount was captured, only the - remaining amount is cleared. **[citation-needed]** - - ## Voiding a previous purchase + ## Note - MONEI will reverse the payment, by sending all the amount back to the - customer. Note that this is not the same as `refund/3`. + * The `:register` option when set to `true` will store this card for future + use, and you will recieve a registration `token` in the `:token` field of + the `Response` struct. ## Example - The following session shows how one would void a previous (pre) - authorization. Remember that our `capture/3` example only did a partial - capture. + The following example shows how one would process a payment worth $42 in + one-shot, without (pre) authorization. - iex> opts = [currency: "EUR"] # The default currency is EUR, and this is just for an example. - iex> card = %Gringotts.CreditCard{first_name: "Jo", last_name: "Doe", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} - iex> void_result = Gringotts.void(Gringotts.Gateways.Monei, auth_result.id, opts) + iex> amount = Money.new(42, :USD) + 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.Monei, amount, card, opts) + iex> purchase_result.token # This is the registration ID/token """ - @spec void(String.t(), keyword) :: {:ok | :error, Response} - def void(payment_id, opts) + @spec purchase(Money.t(), CreditCard.t(), keyword) :: {:ok | :error, Response.t()} + def purchase(amount, %CreditCard{} = card, opts) do + {currency, value} = Money.to_string(amount) - def void(<>, opts) do - params = [paymentType: "RV"] - auth_info = Keyword.fetch!(opts, :config) - commit(:post, "payments/#{payment_id}", params, auth_info) + params = + [ + paymentType: "DB", + amount: value + ] ++ card_params(card) + + commit(:post, "payments", params, [{:currency, currency} | opts]) end @doc """ @@ -329,27 +309,22 @@ defmodule Gringotts.Gateways.Monei do ## Example - The following session shows how one would refund a previous purchase (and - similarily for captures). + The following example shows how one would (completely) refund a previous + purchase (and similarily for captures). - iex> opts = [currency: "EUR"] # The default currency is EUR, and this is just for an example. - iex> card = %Gringotts.CreditCard{first_name: "Jo", last_name: "Doe", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} - iex> refund_result = Gringotts.refund(Gringotts.Gateways.Monei, purchase_result.id, opts) + iex> amount = Money.new(42, :USD) + iex> {:ok, refund_result} = Gringotts.refund(Gringotts.Gateways.Monei, purchase_result.id, amount) """ - @spec refund(number, String.t(), keyword) :: {:ok | :error, Response} - def refund(amount, payment_id, opts) when is_integer(amount) do - refund(amount / 1, payment_id, opts) - end - + @spec refund(Money.t(), String.t(), keyword) :: {:ok | :error, Response.t()} def refund(amount, <>, opts) do + {currency, value} = Money.to_string(amount) + params = [ paymentType: "RF", - amount: :erlang.float_to_binary(amount, decimals: 2), - currency: currency(opts) + amount: value ] - auth_info = Keyword.fetch!(opts, :config) - commit(:post, "payments/#{payment_id}", params, auth_info) + commit(:post, "payments/#{payment_id}", params, [{:currency, currency} | opts]) end @doc """ @@ -359,6 +334,8 @@ defmodule Gringotts.Gateways.Monei do which can be used to effectively process _One-Click_ and _Recurring_ payments, and return a registration token for reference. + The registration token is available in the `Response.id` field. + It is recommended to associate these details with a "Customer" by passing customer details in the `opts`. @@ -369,18 +346,17 @@ defmodule Gringotts.Gateways.Monei do ## Example - The following session shows how one would store a card (a payment-source) for + The following example shows how one would store a card (a payment-source) for future use. - iex> opts = [currency: "EUR"] # The default currency is EUR, and this is just for an example. - iex> card = %Gringotts.CreditCard{first_name: "Jo", last_name: "Doe", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} - iex> store_result = Gringotts.store(Gringotts.Gateways.Monei, card, opts) + iex> card = %Gringotts.CreditCard{first_name: "Harry", last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, verification_code: "123", brand: "VISA"} + iex> {:ok, store_result} = Gringotts.store(Gringotts.Gateways.Monei, card) + iex> store_result.id # This is the registration token """ - @spec store(CreditCard.t(), keyword) :: {:ok | :error, Response} + @spec store(CreditCard.t(), keyword) :: {:ok | :error, Response.t()} def store(%CreditCard{} = card, opts) do params = card_params(card) - auth_info = Keyword.fetch!(opts, :config) - commit(:post, "registrations", params, auth_info) + commit(:post, "registrations", params, opts) end @doc """ @@ -390,10 +366,50 @@ defmodule Gringotts.Gateways.Monei do Deletes previously stored payment-source data. """ - @spec unstore(String.t(), keyword) :: {:ok | :error, Response} - def unstore(<>, opts) do - auth_info = Keyword.fetch!(opts, :config) - commit(:delete, "registrations/#{registrationId}", [], auth_info) + @spec unstore(String.t(), keyword) :: {:ok | :error, Response.t()} + def unstore(registration_id, opts) + + def unstore(<>, opts) do + commit(:delete, "registrations/#{registration_id}", [], opts) + end + + @doc """ + Voids the referenced payment. + + This method attempts a reversal of the either a previous `purchase/3`, + `capture/3` or `authorize/3` referenced by `payment_id`. + + As a consequence, the customer will never see any booking on his + statement. Refer MONEI's [Backoffice + Operations](https://docs.monei.net/tutorials/manage-payments/backoffice) + guide. + + ## Voiding a previous authorization + + MONEI will reverse the authorization by sending a "reversal request" to the + payment source (card issuer) to clear the funds held against the + authorization. If some of the authorized amount was captured, only the + remaining amount is cleared. **[citation-needed]** + + ## Voiding a previous purchase + + MONEI will reverse the payment, by sending all the amount back to the + customer. Note that this is not the same as `refund/3`. + + ## 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.Monei, auth_result.id, opts) + """ + @spec void(String.t(), keyword) :: {:ok | :error, Response.t()} + def void(payment_id, opts) + + def void(<>, opts) do + params = [paymentType: "RV"] + commit(:post, "payments/#{payment_id}", params, opts) end defp card_params(card) do @@ -407,80 +423,167 @@ defmodule Gringotts.Gateways.Monei do ] end - # Makes the request to MONEI's network. - @spec commit(atom, String.t(), keyword, map) :: {:ok | :error, Response} - defp commit(method, endpoint, params, opts) do - auth_params = [ - "authentication.userId": opts[:userId], - "authentication.password": opts[:password], - "authentication.entityId": opts[:entityId] + defp auth_params(opts) do + [ + "authentication.userId": opts[:config][:userId], + "authentication.password": opts[:config][:password], + "authentication.entityId": opts[:config][:entityId] ] + end - body = params ++ auth_params + # Makes the request to MONEI's network. + @spec commit(atom, String.t(), keyword, keyword) :: {:ok | :error, Response.t()} + defp commit(:post, endpoint, params, opts) do url = "#{base_url(opts)}/#{version(opts)}/#{endpoint}" - network_response = - case method do - :post -> HTTPoison.post(url, {:form, body}, @default_headers) - :delete -> HTTPoison.delete(url <> "?" <> URI.encode_query(auth_params)) - end + case expand_params(Keyword.delete(opts, :config), params[:paymentType]) do + {:error, reason} -> + {:error, Response.error(reason: reason)} + + validated_params -> + url + |> HTTPoison.post( + {:form, params ++ validated_params ++ auth_params(opts)}, + @default_headers + ) + |> respond + end + end - respond(network_response) + # This clause is only used by `unstore/2` + defp commit(:delete, endpoint, _params, opts) do + base_url = "#{base_url(opts)}/#{version(opts)}/#{endpoint}" + auth_params = auth_params(opts) + query_string = auth_params |> URI.encode_query() + + (base_url <> "?" <> query_string) + |> HTTPoison.delete() + |> respond end - # Parses MONEI's response and returns a `Gringotts.Response` struct in a `:ok`, `:error` tuple. - @spec respond(term) :: {:ok | :error, Response} + # Parses MONEI's response and returns a `Gringotts.Response` struct in a + # `:ok`, `:error` tuple. + @spec respond(term) :: {:ok | :error, Response.t()} defp respond(monei_response) defp respond({:ok, %{status_code: 200, body: body}}) do - case decode(body) do - {:ok, decoded_json} -> - case verification_result(decoded_json) do - {:ok, results} -> {:ok, Response.success([{:id, decoded_json["id"]} | results])} - {:error, errors} -> {:ok, Response.error([{:id, decoded_json["id"]} | errors])} - end + common = [raw: body, status_code: 200] + + with {:ok, decoded_json} <- decode(body), + {:ok, results} <- parse_response(decoded_json) do + {:ok, Response.success(common ++ results)} + else + {:not_ok, errors} -> + {:ok, Response.error(common ++ errors)} {:error, _} -> - {:error, Response.error(raw: body, code: :undefined_response_from_monei)} + {:error, Response.error([reason: "undefined response from monei"] ++ common)} end end defp respond({:ok, %{status_code: status_code, body: body}}) do - {:error, Response.error(code: status_code, raw: body)} + {:error, Response.error(status_code: status_code, raw: body)} end defp respond({:error, %HTTPoison.Error{} = error}) do { :error, Response.error( - code: error.id, - reason: :network_fail?, - description: "HTTPoison says '#{error.reason}'" + reason: "network related failure", + message: "HTTPoison says '#{error.reason}' [ID: #{error.id || "nil"}]" ) } end - defp verification_result(%{"result" => result} = data) do + defp parse_response(%{"result" => result} = data) do {address, zip_code} = @avs_code_translator[result["avsResponse"]] - code = result["code"] results = [ - code: code, - description: result["description"], - risk: data["risk"]["score"], + id: data["id"], + token: data["registrationId"], + gateway_code: result["code"], + message: result["description"], + fraud_review: data["risk"], cvc_result: @cvc_code_translator[result["cvvResponse"]], - avs_result: [address: address, zip_code: zip_code], - raw: data + avs_result: %{address: address, zip_code: zip_code} ] - if String.match?(code, ~r{^(000\.000\.|000\.100\.1|000\.[36])}) do + non_nil_params = Enum.filter(results, fn {_, v} -> v != nil end) + verify(non_nil_params) + end + + defp verify(results) do + if String.match?(results[:gateway_code], ~r{^(000\.000\.|000\.100\.1|000\.[36])}) do {:ok, results} else - {:error, [{:reason, result["description"]} | results]} + {:not_ok, [{:reason, results[:message]} | results]} + end + end + + defp expand_params(params, action_type) do + Enum.reduce_while(params, [], fn {k, v}, acc -> + case k do + :currency -> + if valid_currency?(v), + do: {:cont, [{:currency, v} | acc]}, + else: {:halt, {:error, "Invalid currency"}} + + :customer -> + {:cont, acc ++ make(action_type, "customer", v)} + + :merchant -> + {:cont, acc ++ make(action_type, "merchant", v)} + + :billing -> + {:cont, acc ++ make(action_type, "billing", v)} + + :shipping -> + {:cont, acc ++ make(action_type, "shipping", v)} + + :invoice_id -> + {:cont, [{"merchantInvoiceId", v} | acc]} + + :transaction_id -> + {:cont, [{"merchantTransactionId", v} | acc]} + + :category -> + {:cont, [{"transactionCategory", v} | acc]} + + :shipping_customer -> + {:cont, acc ++ make(action_type, "shipping.customer", v)} + + :custom -> + {:cont, acc ++ make_custom(v)} + + :register -> + {:cont, acc ++ make(action_type, :register, v)} + + unsupported -> + {:halt, {:error, "Unsupported optional param '#{unsupported}'"}} + end + end) + end + + defp valid_currency?(currency) do + currency in @supported_currencies + end + + defp make(action_type, _prefix, _param) when action_type in ["CP", "RF", "RV"], do: [] + + defp make(action_type, prefix, param) do + case prefix do + :register -> + if action_type in ["PA", "DB"], do: [createRegistration: true], else: [] + + _ -> + Enum.into(param, [], fn {k, v} -> {"#{prefix}.#{k}", v} end) end end - defp base_url(opts), do: opts[:test_url] || @base_url - defp currency(opts), do: opts[:currency] || @default_currency - defp version(opts), do: opts[:api_version] || @version + defp make_custom(custom_map) do + Enum.into(custom_map, [], fn {k, v} -> {"customParameters[#{k}]", "#{v}"} end) + end + + defp base_url(opts), do: opts[:config][:test_url] || @base_url + defp version(opts), do: opts[:config][:api_version] || @version end diff --git a/lib/gringotts/gateways/paymill.ex b/lib/gringotts/gateways/paymill.ex index b128e061..5cf177e2 100644 --- a/lib/gringotts/gateways/paymill.ex +++ b/lib/gringotts/gateways/paymill.ex @@ -22,12 +22,11 @@ defmodule Gringotts.Gateways.Paymill do Your application config must include 'private_key', 'public_key' config :gringotts, Gringotts.Gateways.Paymill, - adapter: Gringotts.Gateways.Paymill, private_key: "your_privat_key", public_key: "your_public_key" """ use Gringotts.Gateways.Base - alias Gringotts.{ CreditCard, Address, Response} + alias Gringotts.{CreditCard, Address, Response} alias Gringotts.Gateways.Paymill.ResponseHandler, as: ResponseParser use Gringotts.Adapter, required_config: [:private_key, :public_key] @@ -57,7 +56,7 @@ defmodule Gringotts.Gateways.Paymill do iex> Gringotts.authorize(Gringotts.Gateways.Paymill, amount, card, options) """ - @spec authorize(number, String.t | CreditCard.t, Keyword) :: {:ok | :error, Response} + @spec authorize(number, String.t() | CreditCard.t(), Keyword) :: {:ok | :error, Response} def authorize(amount, card_or_token, options) do Keyword.put(options, :money, amount) action_with_token(:authorize, amount, card_or_token, options) @@ -82,7 +81,7 @@ defmodule Gringotts.Gateways.Paymill do iex> Gringotts.purchase(Gringotts.Gateways.Paymill, amount, card, options) """ - @spec purchase(number, CreditCard.t, Keyword) :: {:ok | :error, Response} + @spec purchase(number, CreditCard.t(), Keyword) :: {:ok | :error, Response} def purchase(amount, card, options) do Keyword.put(options, :money, amount) action_with_token(:purchase, amount, card, options) @@ -100,7 +99,7 @@ defmodule Gringotts.Gateways.Paymill do iex> Gringotts.capture(Gringotts.Gateways.Paymill, token, amount, options) """ - @spec capture(String.t, number, Keyword) :: {:ok | :error, Response} + @spec capture(String.t(), number, Keyword) :: {:ok | :error, Response} def capture(authorization, amount, options) do post = add_amount([], amount, options) ++ [{"preauthorization", authorization}] @@ -117,13 +116,13 @@ defmodule Gringotts.Gateways.Paymill do iex> Gringotts.void(Gringotts.Gateways.Paymill, token, options) """ - @spec void(String.t, Keyword) :: {:ok | :error, Response} + @spec void(String.t(), Keyword) :: {:ok | :error, Response} def void(authorization, options) do commit(:delete, "preauthorizations/#{authorization}", [], options) end @doc false - @spec authorize_with_token(number, String.t, Keyword) :: term + @spec authorize_with_token(number, String.t(), Keyword) :: term def authorize_with_token(money, card_token, options) do post = add_amount([], money, options) ++ [{"token", card_token}] @@ -131,50 +130,53 @@ defmodule Gringotts.Gateways.Paymill do end @doc false - @spec purchase_with_token(number, String.t, Keyword) :: term + @spec purchase_with_token(number, String.t(), Keyword) :: term def purchase_with_token(money, card_token, options) do post = add_amount([], money, options) ++ [{"token", card_token}] commit(:post, "transactions", post, options) end - @spec save_card(CreditCard.t, Keyword) :: Response + @spec save_card(CreditCard.t(), Keyword) :: Response defp save_card(card, options) do - {:ok, %HTTPoison.Response{body: response}} = HTTPoison.get( + {:ok, %HTTPoison.Response{body: response}} = + HTTPoison.get( get_save_card_url(), get_headers(options), - params: get_save_card_params(card, options)) + params: get_save_card_params(card, options) + ) - parse_card_response(response) + parse_card_response(response) end - @spec save(CreditCard.t, Keyword) :: Response + @spec save(CreditCard.t(), Keyword) :: Response defp save(card, options) do save_card(card, options) end defp action_with_token(action, amount, "tok_" <> id = card_token, options) do - apply(__MODULE__, String.to_atom("#{action}_with_token"), [amount, card_token , options]) + apply(__MODULE__, String.to_atom("#{action}_with_token"), [amount, card_token, options]) end defp action_with_token(action, amount, %CreditCard{} = card, options) do {:ok, response} = save_card(card, options) card_token = get_token(response) - apply(__MODULE__, String.to_atom("#{action}_with_token"), [amount, card_token , options]) + apply(__MODULE__, String.to_atom("#{action}_with_token"), [amount, card_token, options]) end defp get_save_card_params(card, options) do - [ {"transaction.mode" , "CONNECTOR_TEST"}, - {"channel.id" , get_config(:public_key, options)}, - {"jsonPFunction" , "jsonPFunction"}, - {"account.number" , card.number}, - {"account.expiry.month" , card.month}, - {"account.expiry.year" , card.year}, - {"account.verification" , card.verification_code}, - {"account.holder" , "#{card.first_name} #{card.last_name}"}, - {"presentation.amount3D" , get_amount(options)}, - {"presentation.currency3D" , get_currency(options)} + [ + {"transaction.mode", "CONNECTOR_TEST"}, + {"channel.id", get_config(:public_key, options)}, + {"jsonPFunction", "jsonPFunction"}, + {"account.number", card.number}, + {"account.expiry.month", card.month}, + {"account.expiry.year", card.year}, + {"account.verification", card.verification_code}, + {"account.holder", CreditCard.full_name(card)}, + {"presentation.amount3D", get_amount(options)}, + {"presentation.currency3D", get_currency(options)} ] end @@ -194,9 +196,9 @@ defmodule Gringotts.Gateways.Paymill do defp parse_card_response(response) do response - |> String.replace(~r/jsonPFunction\(/,"") + |> String.replace(~r/jsonPFunction\(/, "") |> String.replace(~r/\)/, "") - |> Poison.decode + |> Poison.decode() end defp get_currency(options), do: options[:currency] || @default_currency @@ -208,130 +210,132 @@ defmodule Gringotts.Gateways.Paymill do end defp commit(method, action, parameters \\ nil, options) do - HTTPoison.request(method, @live_url <> action, {:form, parameters }, get_headers(options), []) - |> ResponseParser.parse + method + |> HTTPoison.request(@live_url <> action, {:form, parameters}, get_headers(options), []) + |> ResponseParser.parse() end defp get_config(key, options) do get_in(options, [:config, key]) end + @moduledoc false defmodule ResponseHandler do alias Gringotts.Response @response_code %{ - 10001 => "Undefined response", - 10002 => "Waiting for something", - 11000 => "Retry request at a later time", - - 20000 => "Operation successful", - 20100 => "Funds held by acquirer", - 20101 => "Funds held by acquirer because merchant is new", - 20200 => "Transaction reversed", - 20201 => "Reversed due to chargeback", - 20202 => "Reversed due to money-back guarantee", - 20203 => "Reversed due to complaint by buyer", - 20204 => "Payment has been refunded", - 20300 => "Reversal has been canceled", - 22000 => "Initiation of transaction successful", - - 30000 => "Transaction still in progress", - 30100 => "Transaction has been accepted", - 31000 => "Transaction pending", - 31100 => "Pending due to address", - 31101 => "Pending due to uncleared eCheck", - 31102 => "Pending due to risk review", - 31103 => "Pending due regulatory review", - 31104 => "Pending due to unregistered/unconfirmed receiver", - 31200 => "Pending due to unverified account", - 31201 => "Pending due to non-captured funds", - 31202 => "Pending due to international account (accept manually)", - 31203 => "Pending due to currency conflict (accept manually)", - 31204 => "Pending due to fraud filters (accept manually)", - - 40000 => "Problem with transaction data", - 40001 => "Problem with payment data", - 40002 => "Invalid checksum", - 40100 => "Problem with credit card data", - 40101 => "Problem with CVV", - 40102 => "Card expired or not yet valid", - 40103 => "Card limit exceeded", - 40104 => "Card is not valid", - 40105 => "Expiry date not valid", - 40106 => "Credit card brand required", - 40200 => "Problem with bank account data", - 40201 => "Bank account data combination mismatch", - 40202 => "User authentication failed", - 40300 => "Problem with 3-D Secure data", - 40301 => "Currency/amount mismatch", - 40400 => "Problem with input data", - 40401 => "Amount too low or zero", - 40402 => "Usage field too long", - 40403 => "Currency not allowed", - 40410 => "Problem with shopping cart data", - 40420 => "Problem with address data", - 40500 => "Permission error with acquirer API", - 40510 => "Rate limit reached for acquirer API", - 42000 => "Initiation of transaction failed", - 42410 => "Initiation of transaction expired", - - 50000 => "Problem with back end", - 50001 => "Country blacklisted", - 50002 => "IP address blacklisted", - 50004 => "Live mode not allowed", - 50005 => "Insufficient permissions (API key)", - 50100 => "Technical error with credit card", - 50101 => "Error limit exceeded", - 50102 => "Card declined", - 50103 => "Manipulation or stolen card", - 50104 => "Card restricted", - 50105 => "Invalid configuration data", - 50200 => "Technical error with bank account", - 50201 => "Account blacklisted", - 50300 => "Technical error with 3-D Secure", - 50400 => "Declined because of risk issues", - 50401 => "Checksum was wrong", - 50402 => "Bank account number was invalid (formal check)", - 50403 => "Technical error with risk check", - 50404 => "Unknown error with risk check", - 50405 => "Unknown bank code", - 50406 => "Open chargeback", - 50407 => "Historical chargeback", - 50408 => "Institution / public bank account (NCA)", - 50409 => "KUNO/Fraud", - 50410 => "Personal Account Protection (PAP)", - 50420 => "Rejected due to acquirer fraud settings", - 50430 => "Rejected due to acquirer risk settings", - 50440 => "Failed due to restrictions with acquirer account", - 50450 => "Failed due to restrictions with user account", - 50500 => "General timeout", - 50501 => "Timeout on side of the acquirer", - 50502 => "Risk management transaction timeout", - 50600 => "Duplicate operation", - 50700 => "Cancelled by user", - 50710 => "Failed due to funding source", - 50711 => "Payment method not usable, use other payment method", - 50712 => "Limit of funding source was exceeded", - 50713 => "Means of payment not reusable (canceled by user)", - 50714 => "Means of payment not reusable (expired)", - 50720 => "Rejected by acquirer", - 50730 => "Transaction denied by merchant", - 50800 => "Preauthorisation failed", - 50810 => "Authorisation has been voided", - 50820 => "Authorisation period expired" - } + 10_001 => "Undefined response", + 10_002 => "Waiting for something", + 11_000 => "Retry request at a later time", + 20_000 => "Operation successful", + 20_100 => "Funds held by acquirer", + 20_101 => "Funds held by acquirer because merchant is new", + 20_200 => "Transaction reversed", + 20_201 => "Reversed due to chargeback", + 20_202 => "Reversed due to money-back guarantee", + 20_203 => "Reversed due to complaint by buyer", + 20_204 => "Payment has been refunded", + 20_300 => "Reversal has been canceled", + 22_000 => "Initiation of transaction successful", + 30_000 => "Transaction still in progress", + 30_100 => "Transaction has been accepted", + 31_000 => "Transaction pending", + 31_100 => "Pending due to address", + 31_101 => "Pending due to uncleared eCheck", + 31_102 => "Pending due to risk review", + 31_103 => "Pending due regulatory review", + 31_104 => "Pending due to unregistered/unconfirmed receiver", + 31_200 => "Pending due to unverified account", + 31_201 => "Pending due to non-captured funds", + 31_202 => "Pending due to international account (accept manually)", + 31_203 => "Pending due to currency conflict (accept manually)", + 31_204 => "Pending due to fraud filters (accept manually)", + 40_000 => "Problem with transaction data", + 40_001 => "Problem with payment data", + 40_002 => "Invalid checksum", + 40_100 => "Problem with credit card data", + 40_101 => "Problem with CVV", + 40_102 => "Card expired or not yet valid", + 40_103 => "Card limit exceeded", + 40_104 => "Card is not valid", + 40_105 => "Expiry date not valid", + 40_106 => "Credit card brand required", + 40_200 => "Problem with bank account data", + 40_201 => "Bank account data combination mismatch", + 40_202 => "User authentication failed", + 40_300 => "Problem with 3-D Secure data", + 40_301 => "Currency/amount mismatch", + 40_400 => "Problem with input data", + 40_401 => "Amount too low or zero", + 40_402 => "Usage field too long", + 40_403 => "Currency not allowed", + 40_410 => "Problem with shopping cart data", + 40_420 => "Problem with address data", + 40_500 => "Permission error with acquirer API", + 40_510 => "Rate limit reached for acquirer API", + 42_000 => "Initiation of transaction failed", + 42_410 => "Initiation of transaction expired", + 50_000 => "Problem with back end", + 50_001 => "Country blacklisted", + 50_002 => "IP address blacklisted", + 50_004 => "Live mode not allowed", + 50_005 => "Insufficient permissions (API key)", + 50_100 => "Technical error with credit card", + 50_101 => "Error limit exceeded", + 50_102 => "Card declined", + 50_103 => "Manipulation or stolen card", + 50_104 => "Card restricted", + 50_105 => "Invalid configuration data", + 50_200 => "Technical error with bank account", + 50_201 => "Account blacklisted", + 50_300 => "Technical error with 3-D Secure", + 50_400 => "Declined because of risk issues", + 50_401 => "Checksum was wrong", + 50_402 => "Bank account number was invalid (formal check)", + 50_403 => "Technical error with risk check", + 50_404 => "Unknown error with risk check", + 50_405 => "Unknown bank code", + 50_406 => "Open chargeback", + 50_407 => "Historical chargeback", + 50_408 => "Institution / public bank account (NCA)", + 50_409 => "KUNO/Fraud", + 50_410 => "Personal Account Protection (PAP)", + 50_420 => "Rejected due to acquirer fraud settings", + 50_430 => "Rejected due to acquirer risk settings", + 50_440 => "Failed due to restrictions with acquirer account", + 50_450 => "Failed due to restrictions with user account", + 50_500 => "General timeout", + 50_501 => "Timeout on side of the acquirer", + 50_502 => "Risk management transaction timeout", + 50_600 => "Duplicate operation", + 50_700 => "Cancelled by user", + 50_710 => "Failed due to funding source", + 50_711 => "Payment method not usable, use other payment method", + 50_712 => "Limit of funding source was exceeded", + 50_713 => "Means of payment not reusable (canceled by user)", + 50_714 => "Means of payment not reusable (expired)", + 50_720 => "Rejected by acquirer", + 50_730 => "Transaction denied by merchant", + 50_800 => "Preauthorisation failed", + 50_810 => "Authorisation has been voided", + 50_820 => "Authorisation period expired" + } def parse({:ok, %HTTPoison.Response{body: body, status_code: 200}}) do body = Poison.decode!(body) parse_body(body) end + def parse({:ok, %HTTPoison.Response{body: body, status_code: 400}}) do body = Poison.decode!(body) + [] |> set_params(body) end + def parse({:ok, %HTTPoison.Response{body: body, status_code: 404}}) do body = Poison.decode!(body) + [] |> set_success(body) |> set_params(body) @@ -341,7 +345,8 @@ defmodule Gringotts.Gateways.Paymill do defp set_success(opts, %{"error" => error}) do opts ++ [message: error, success: false] end - defp set_success(opts, %{"transaction" => %{ "response_code" => 20000}}) do + + defp set_success(opts, %{"transaction" => %{"response_code" => 20_000}}) do opts ++ [success: true] end @@ -361,31 +366,33 @@ defmodule Gringotts.Gateways.Paymill do end end - #Status code + # Status code defp parse_status_code(opts, %{"status" => "failed"} = body) do response_code = get_in(body, ["transaction", "response_code"]) response_msg = Map.get(@response_code, response_code, -1) opts ++ [message: response_msg] end - defp parse_status_code(opts, %{ "transaction" => transaction}) do + + defp parse_status_code(opts, %{"transaction" => transaction}) do response_code = Map.get(transaction, "response_code", -1) response_msg = Map.get(@response_code, response_code, -1) opts ++ [status_code: response_code, message: response_msg] end + defp parse_status_code(opts, %{"response_code" => code}) do response_msg = Map.get(@response_code, code, -1) opts ++ [status_code: code, message: response_msg] end - #Authorization + # Authorization defp parse_authorization(opts, %{"status" => "failed"}) do opts ++ [success: false] end - defp parse_authorization(opts, %{ "id" => id} = auth) do + + defp parse_authorization(opts, %{"id" => id} = auth) do opts ++ [authorization: id] end defp set_params(opts, body), do: opts ++ [params: body] end - end diff --git a/lib/gringotts/gateways/stripe.ex b/lib/gringotts/gateways/stripe.ex index 3df0beac..94a8ce48 100644 --- a/lib/gringotts/gateways/stripe.ex +++ b/lib/gringotts/gateways/stripe.ex @@ -1,9 +1,8 @@ defmodule Gringotts.Gateways.Stripe do - @moduledoc """ Stripe gateway implementation. For reference see [Stripe's API documentation](https://stripe.com/docs/api). The following features of Stripe are implemented: - + | Action | Method | | ------ | ------ | | Pre-authorize | `authorize/3` | @@ -18,7 +17,7 @@ defmodule Gringotts.Gateways.Stripe do Most `Gringotts` API calls accept an optional `Keyword` list `opts` to supply optional arguments for transactions with the Stripe gateway. The following keys are supported: - + | Key | Remark | Status | | ---- | --- | ---- | | `currency` | | **Implemented** | @@ -38,20 +37,19 @@ defmodule Gringotts.Gateways.Stripe do | `default_source` | | Not implemented | | `email` | | Not implemented | | `shipping` | | Not implemented | - + ## Registering your Stripe account at `Gringotts` After [making an account on Stripe](https://stripe.com/), head to the dashboard and find your account `secrets` in the `API` section. - + ## Here's how the secrets map to the required configuration parameters for Stripe: | Config parameter | Stripe secret | | ------- | ---- | | `:secret_key` | **Secret key** | - + Your Application config must look something like this: - + config :gringotts, Gringotts.Gateways.Stripe, - adapter: Gringotts.Gateways.Stripe, secret_key: "your_secret_key", default_currency: "usd" """ @@ -59,11 +57,12 @@ defmodule Gringotts.Gateways.Stripe do @base_url "https://api.stripe.com/v1" use Gringotts.Gateways.Base - use Gringotts.Adapter, required_config: [:secret_key, :default_currency] + use Gringotts.Adapter, required_config: [:secret_key] alias Gringotts.{ CreditCard, - Address + Address, + Money } @doc """ @@ -72,29 +71,40 @@ defmodule Gringotts.Gateways.Stripe do The authorization validates the card details with the banking network, places a hold on the transaction amount in the customer’s issuing bank and also triggers risk management. Funds are not transferred. - + Stripe returns an `charge_id` which should be stored at your side and can be used later to: * `capture/3` an amount. * `void/2` a pre-authorization. - + ## Note Uncaptured charges expire in 7 days. For more information, [see authorizing charges and settling later](https://support.stripe.com/questions/can-i-authorize-a-charge-and-then-wait-to-settle-it-later). ## Example The following session shows how one would (pre) authorize a payment of $10 on a sample `card`. - - iex> payment = %{ - expiration: {2018, 12}, number: "4242424242424242", cvc: "123", name: "John Doe", - street1: "123 Main", street2: "Suite 100", city: "New York", region: "NY", country: "US", + + iex> card = %CreditCard{ + first_name: "John", + last_name: "Smith", + number: "4242424242424242", + year: "2017", + month: "12", + verification_code: "123" + } + + address = %Address{ + street1: "123 Main", + city: "New York", + region: "NY", + country: "US", postal_code: "11111" } - iex> opts = [currency: "usd"] + iex> opts = [currency: "usd", address: address] iex> amount = 10 - iex> Gringotts.authorize(Gringotts.Gateways.Stripe, amount, payment, opts) + iex> Gringotts.authorize(Gringotts.Gateways.Stripe, amount, card, opts) """ - @spec authorize(Float, Map, List) :: Map + @spec authorize(Money.t(), CreditCard.t() | String.t(), keyword) :: map def authorize(amount, payment, opts \\ []) do params = create_params_for_auth_or_purchase(amount, payment, opts, false) commit(:post, "charges", params, opts) @@ -102,26 +112,37 @@ defmodule Gringotts.Gateways.Stripe do @doc """ Transfers amount from the customer to the merchant. - + Stripe 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 session shows how one would process a payment in one-shot, without (pre) authorization. - iex> payemnt = %{ - expiration: {2018, 12}, number: "4242424242424242", cvc: "123", name: "John Doe", - street1: "123 Main", street2: "Suite 100", city: "New York", region: "NY", country: "US", + iex> card = %CreditCard{ + first_name: "John", + last_name: "Smith", + number: "4242424242424242", + year: "2017", + month: "12", + verification_code: "123" + } + + address = %Address{ + street1: "123 Main", + city: "New York", + region: "NY", + country: "US", postal_code: "11111" } - iex> opts = [currency: "usd"] + iex> opts = [currency: "usd", address: address] iex> amount = 5 - iex> Gringotts.purchase(Gringotts.Gateways.Stripe, amount, payment, opts) + iex> Gringotts.purchase(Gringotts.Gateways.Stripe, amount, card, opts) """ - @spec purchase(Float, Map, List) :: Map + @spec purchase(Money.t(), CreditCard.t() | String.t(), keyword) :: map def purchase(amount, payment, opts \\ []) do params = create_params_for_auth_or_purchase(amount, payment, opts) commit(:post, "charges", params, opts) @@ -147,7 +168,7 @@ defmodule Gringotts.Gateways.Stripe do iex> Gringotts.capture(Gringotts.Gateways.Stripe, id, amount, opts) """ - @spec capture(String.t, Float, List) :: Map + @spec capture(String.t(), Money.t(), keyword) :: map def capture(id, amount, opts \\ []) do params = optional_params(opts) ++ amount_params(amount) commit(:post, "charges/#{id}/capture", params, opts) @@ -155,7 +176,7 @@ defmodule Gringotts.Gateways.Stripe do @doc """ Voids the referenced payment. - + This method attempts a reversal of the either a previous `purchase/3` or `authorize/3` referenced by `charge_id`. As a consequence, the customer will never see any booking on his @@ -169,7 +190,7 @@ defmodule Gringotts.Gateways.Stripe do ## Voiding a previous purchase Stripe will reverse the payment, by sending all the amount back to the customer. Note that this is not the same as `refund/3`. - + ## Example The following session shows how one would void a previous (pre) authorization. Remember that our `capture/3` example only did a partial @@ -180,7 +201,7 @@ defmodule Gringotts.Gateways.Stripe do iex> Gringotts.void(Gringotts.Gateways.Stripe, id, opts) """ - @spec void(String.t, List) :: Map + @spec void(String.t(), keyword) :: map def void(id, opts \\ []) do params = optional_params(opts) commit(:post, "charges/#{id}/refund", params, opts) @@ -202,7 +223,7 @@ defmodule Gringotts.Gateways.Stripe do iex> Gringotts.refund(Gringotts.Gateways.Stripe, amount, id, opts) """ - @spec refund(Float, String.t, List) :: Map + @spec refund(Money.t(), String.t(), keyword) :: map def refund(amount, id, opts \\ []) do params = optional_params(opts) ++ amount_params(amount) commit(:post, "charges/#{id}/refund", params, opts) @@ -210,7 +231,7 @@ defmodule Gringotts.Gateways.Stripe do @doc """ Stores the payment-source data for later use. - + Stripe can store the payment-source details, for example card which can be used to effectively to process One-Click and Recurring_ payments, and return a `customer_id` for reference. @@ -218,17 +239,28 @@ defmodule Gringotts.Gateways.Stripe do The following session shows how one would store a card (a payment-source) for future use. - iex> payment = %{ - expiration: {2018, 12}, number: "4242424242424242", cvc: "123", name: "John Doe", - street1: "123 Main", street2: "Suite 100", city: "New York", region: "NY", country: "US", + iex> card = %CreditCard{ + first_name: "John", + last_name: "Smith", + number: "4242424242424242", + year: "2017", + month: "12", + verification_code: "123" + } + + address = %Address{ + street1: "123 Main", + city: "New York", + region: "NY", + country: "US", postal_code: "11111" } - iex> opts = [] + iex> opts = [address: address] - iex> Gringotts.store(Gringotts.Gateways.Stripe, payment, opts) + iex> Gringotts.store(Gringotts.Gateways.Stripe, card, opts) """ - @spec store(Map, List) :: Map + @spec store(CreditCard.t() | String.t(), keyword) :: map def store(payment, opts \\ []) do params = optional_params(opts) ++ source_params(payment, opts) commit(:post, "customers", params, opts) @@ -247,102 +279,97 @@ defmodule Gringotts.Gateways.Stripe do iex> Gringotts.unstore(Gringotts.Gateways.Stripe, id, opts) """ - @spec unstore(String.t) :: Map + @spec unstore(String.t()) :: map def unstore(id, opts \\ []), do: commit(:delete, "customers/#{id}", [], opts) # Private methods defp create_params_for_auth_or_purchase(amount, payment, opts, capture \\ true) do - params = optional_params(opts) - ++ [capture: capture] - ++ amount_params(amount) - ++ source_params(payment, opts) - - params - |> Keyword.has_key?(:currency) - |> with_currency(params, opts[:config]) + [capture: capture] ++ + optional_params(opts) ++ amount_params(amount) ++ source_params(payment, opts) end - def with_currency(true, params, config), do: params - def with_currency(false, params, config), do: [{:currency, config[:default_currency]} | params] - defp create_card_token(params, opts) do commit(:post, "tokens", params, opts) end - defp amount_params(amount), do: [amount: money_to_cents(amount)] - - defp source_params(%CreditCard{} = card, opts) do - params = - card_params(card) ++ - address_params(opts[:address]) - - response = create_card_token(params, opts) - - case Map.has_key?(response, "error") do - true -> [] - false -> response - |> Map.get("id") - |> source_params - end + defp amount_params(amount) do + {currency, int_value, _} = Money.to_integer(amount) + [amount: int_value, currency: currency] end - defp source_params(token_or_customer) do + defp source_params(token_or_customer, _) when is_binary(token_or_customer) do [head, _] = String.split(token_or_customer, "_") + case head do "tok" -> [source: token_or_customer] "cus" -> [customer: token_or_customer] end end - defp source_params(_, opts), do: [] + defp source_params(%CreditCard{} = card, opts) do + params = card_params(card) ++ address_params(opts[:address]) + + response = create_card_token(params, opts) + + if Map.has_key?(response, "error") do + [] + else + response + |> Map.get("id") + |> source_params(opts) + end + end + + defp source_params(_, _), do: [] defp card_params(%CreditCard{} = card) do - card = Map.from_struct(card) - - [ "card[name]": card[:name], - "card[number]": card[:number], - "card[exp_year]": card[:year], - "card[exp_month]": card[:month], - "card[cvc]": card[:verification_code] - ] + [ + "card[name]": CreditCard.full_name(card), + "card[number]": card.number, + "card[exp_year]": card.year, + "card[exp_month]": card.month, + "card[cvc]": card.verification_code + ] end defp card_params(_), do: [] defp address_params(%Address{} = address) do - address = Map.from_struct(address) - - [ "card[address_line1]": address[:street1], - "card[address_line2]": address[:street2], - "card[address_city]": address[:city], - "card[address_state]": address[:region], - "card[address_zip]": address[:postal_code], - "card[address_country]": address[:country] + [ + "card[address_line1]": address.street1, + "card[address_line2]": address.street2, + "card[address_city]": address.city, + "card[address_state]": address.region, + "card[address_zip]": address.postal_code, + "card[address_country]": address.country ] end defp address_params(_), do: [] - defp commit(method, path, params \\ [], opts \\ []) do + defp commit(method, path, params, opts) do auth_token = "Bearer " <> opts[:config][:secret_key] - headers = [{"Content-Type", "application/x-www-form-urlencoded"}, {"Authorization", auth_token}] - data = params_to_string(params) - response = HTTPoison.request(method, "#{@base_url}/#{path}", data, headers) + + headers = [ + {"Content-Type", "application/x-www-form-urlencoded"}, + {"Authorization", auth_token} + ] + + response = HTTPoison.request(method, "#{@base_url}/#{path}", {:form, params}, headers) format_response(response) end defp optional_params(opts) do opts - |> Keyword.delete(:config) - |> Keyword.delete(:address) + |> Keyword.delete(:config) + |> Keyword.delete(:address) end defp format_response(response) do case response do - {:ok, %HTTPoison.Response{body: body}} -> body |> Poison.decode! + {:ok, %HTTPoison.Response{body: body}} -> body |> Poison.decode!() _ -> %{"error" => "something went wrong, please try again later"} end end - end diff --git a/lib/gringotts/gateways/trexle.ex b/lib/gringotts/gateways/trexle.ex index 1bc315fb..a9c3d988 100644 --- a/lib/gringotts/gateways/trexle.ex +++ b/lib/gringotts/gateways/trexle.ex @@ -1,9 +1,8 @@ defmodule Gringotts.Gateways.Trexle do - @moduledoc """ - Trexle Payment Gateway Implementation: + [Trexle][home] Payment Gateway implementation. - For further details, please refer [Trexle API documentation](https://docs.trexle.com/). + > For further details, please refer [Trexle API documentation][docs]. Following are the features that have been implemented for the Trexle Gateway: @@ -16,120 +15,193 @@ defmodule Gringotts.Gateways.Trexle do | Store | `store/2` | ## The `opts` argument - A `Keyword` list `opts` passed as an optional argument for transactions with the gateway. Following are the keys + + Most `Gringotts` API calls accept an optional `keyword` list `opts` to supply + optional arguments for transactions with Trexle. The following keys are supported: - * email - * ip_address - * description + * `email` + * `ip_address` + * `description` + + [docs]: https://docs.trexle.com/ + [home]: https://trexle.com/ + + ## Registering your Trexle account at `Gringotts` - ## Trexle account registeration with `Gringotts` - After creating your account successfully on [Trexle](https://docs.trexle.com/) follow the [dashboard link](https://trexle.com/dashboard/api-keys) to fetch the secret api_key. + After [creating your account][dashboard] successfully on Trexle, head to the dashboard and find + your account "secrets" in the [`API keys`][keys] section. + + Here's how the secrets map to the required configuration parameters for MONEI: + + | Config parameter | Trexle secret | + | ------- | ---- | + | `:api_key` | **API key** | Your Application config must look something like this: config :gringotts, Gringotts.Gateways.Trexle, - adapter: Gringotts.Gateways.Trexle, - api_key: "Secret API key", - default_currency: "USD" + api_key: "your-secret-API-key" + + [dashboard]: https://trexle.com/dashboard/ + [keys]: https://trexle.com/dashboard/api-keys + + ## Scope of this module + + * Trexle processes money in cents.**citation-needed**. + + ## Supported Gateways + + Find the official [list here][gateways]. + + [gateways]: https://trexle.com/payment-gateway + + ## Following the examples + + 1. First, set up a sample application and configure it to work with Trexle. + - You could do that from scratch by following our [Getting Started][gs] guide. + - To save you time, we recommend [cloning our example repo][example-repo] + that gives you a pre-configured sample app ready-to-go. + + You could use the same config or update it the with your "secrets" + that as described + [above](#module-registering-your-trexle-account-at-gringotts). + + 2. To save a lot of time, create a [`.iex.exs`][iex-docs] file as shown in + [this gist][trexle.iex.exs] to introduce a set of handy bindings and + aliases. + + We'll be using these bindings in the examples below. + + [example-repo]: https://github.com/aviabird/gringotts_example + [iex-docs]: https://hexdocs.pm/iex/IEx.html#module-the-iex-exs-file + [trexle.iex.exs]: https://gist.github.com/oyeb/055f40e9ad4102f5480febd2cfa00787 + [gs]: https://github.com/aviabird/gringotts/wiki """ @base_url "https://core.trexle.com/api/v1/" use Gringotts.Gateways.Base - use Gringotts.Adapter, required_config: [:api_key, :default_currency] + use Gringotts.Adapter, required_config: [:api_key] import Poison, only: [decode: 1] - alias Gringotts.{Response} + alias Gringotts.{Response, CreditCard, Address, Money} @doc """ - Performs the authorization of the card to be used for payment. + Performs a (pre) Authorize operation. - Authorizes your card with the given amount and returns a charge token and captured status as false in response. + The authorization validates the `card` details with the banking network, + places a hold on the transaction `amount` in the customer’s issuing bank and + also triggers risk management. Funds are not transferred. + + Trexle returns a "charge token", avaliable in the `Response.authorization` + field, which can be used in future to perform a `capture/3`. ### Example + + The following session shows how one would (pre) authorize a payment of $100 on + a sample `card`. + ``` - iex> amount = 100 - - iex> card = %{ - name: "John Doe", - number: "5200828282828210", - expiry_month: 1, - expiry_year: 2018, - cvc: "123", - address_line1: "456 My Street", - address_city: "Ottawa", - address_postcode: "K1C2N6", - address_state: "ON", - address_country: "CA" - } - - iex> options = [email: "john@trexle.com", ip_address: "66.249.79.118" , description: "Store Purchase 1437598192"] - - iex> Gringotts.authorize(:payment_worker, Gringotts.Gateways.Trexle, amount, card, options) + iex> amount = Money.new(10, :USD) + iex> card = %CreditCard{ + first_name: "Harry", + last_name: "Potter", + number: "5200828282828210", + year: 2099, month: 12, + verification_code: "123", + brand: "VISA"} + iex> address = %Address{ + street1: "301, Gryffindor", + street2: "Hogwarts School of Witchcraft and Wizardry, Hogwarts Castle", + city: "Highlands", + region: "SL", + country: "GB", + postal_code: "11111", + phone: "(555)555-5555"} + iex> options = [email: "masterofdeath@ministryofmagic.gov", + ip_address: "127.0.0.1", + billing_address: address, + description: "For our valued customer, Mr. Potter"] + iex> Gringotts.authorize(Gringotts.Gateways.Trexle, amount, card, options) ``` """ - - @spec authorize(float, map, list) :: map + @spec authorize(Money.t(), CreditCard.t(), keyword) :: {:ok | :error, Response} def authorize(amount, payment, opts \\ []) do params = create_params_for_auth_or_purchase(amount, payment, opts, false) commit(:post, "charges", params, opts) end @doc """ - Performs the amount transfer from the customer to the merchant. + Captures a pre-authorized `amount`. + + `amount` is transferred to the merchant account by MONEI when it is smaller or + equal to the amount used in the pre-authorization referenced by `charge_token`. - The actual amount deduction performed by Trexle using the customer's card info. + Trexle returns a "charge token", avaliable in the `Response.authorization` + field, which can be used in future to perform a `refund/2`. + + ## Note + + Multiple captures cannot be performed on the same "charge token". If the + captured amount is smaller than the (pre) authorized amount, the "un-captured" + amount is released.**citation-needed** ## Example + + The following example shows how one would (partially) capture a previously + authorized a payment worth $10 by referencing the obtained `charge_token`. + ``` - iex> card = %{ - name: "John Doe", - number: "5200828282828210", - expiry_month: 1, - expiry_year: 2018, - cvc: "123", - address_line1: "456 My Street", - address_city: "Ottawa", - address_postcode: "K1C2N6", - address_state: "ON", - address_country: "CA" - } - - iex> options = [email: "john@trexle.com", ip_address: "66.249.79.118" ,description: "Store Purchase 1437598192"] - - iex> amount = 50 - - iex> Gringotts.purchase(:payment_worker, Gringotts.Gateways.Trexle, amount, card, options) + iex> amount = Money.new(10, :USD) + iex> token = "some-real-token" + iex> Gringotts.capture(Gringotts.Gateways.Trexle, token, amount) ``` """ - - @spec purchase(float, map, list) :: map - def purchase(amount, payment, opts \\ []) do - params = create_params_for_auth_or_purchase(amount, payment, opts) - commit(:post, "charges", params, opts) + @spec capture(String.t(), Money.t(), keyword) :: {:ok | :error, Response} + def capture(charge_token, amount, opts \\ []) do + {_, int_value, _} = Money.to_integer(amount) + params = [amount: int_value] + commit(:put, "charges/#{charge_token}/capture", params, opts) end @doc """ - Captures a particular amount using the charge token of a pre authorized card. + Transfers `amount` from the customer to the merchant. - The amount specified should be less than or equal to the amount given prior to capture while authorizing the card. - If the amount mentioned is less than the amount given in authorization process, the mentioned amount is debited. - Please note that multiple captures can't be performed for a given charge token from the authorization process. + Trexle 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 - ``` - iex> amount = 100 + ## Example - iex> token = "charge_6a5fcdc6cdbf611ee3448a9abad4348b2afab3ec" + The following session shows how one would process a payment worth $100 in + one-shot, without (pre) authorization. - iex> Gringotts.capture(:payment_worker, Gringotts.Gateways.Trexle, token, amount) + ``` + iex> amount = Money.new(10, :USD) + iex> card = %CreditCard{ + first_name: "Harry", + last_name: "Potter", + number: "5200828282828210", + year: 2099, month: 12, + verification_code: "123", + brand: "VISA"} + iex> address = %Address{ + street1: "301, Gryffindor", + street2: "Hogwarts School of Witchcraft and Wizardry, Hogwarts Castle", + city: "Highlands", + region: "SL", + country: "GB", + postal_code: "11111", + phone: "(555)555-5555"} + iex> options = [email: "masterofdeath@ministryofmagic.gov", + ip_address: "127.0.0.1", + billing_address: address, + description: "For our valued customer, Mr. Potter"] + iex> Gringotts.purchase(Gringotts.Gateways.Trexle, amount, card, options) ``` """ - - @spec capture(String.t, float, list) :: map - def capture(charge_token, amount, opts \\ []) do - params = [amount: amount] - commit(:put, "charges/#{charge_token}/capture", params, opts) + @spec purchase(Money.t(), CreditCard.t(), keyword) :: {:ok | :error, Response} + def purchase(amount, payment, opts \\ []) do + params = create_params_for_auth_or_purchase(amount, payment, opts) + commit(:post, "charges", params, opts) end @doc """ @@ -138,24 +210,28 @@ defmodule Gringotts.Gateways.Trexle do Trexle processes a full or partial refund worth `amount`, referencing a previous `purchase/3` or `capture/3`. - Multiple refund can be performed for the same charge token from purchase or capture done before performing refund action unless the cumulative amount is less than the amount given while authorizing. + Trexle returns a "refund token", avaliable in the `Response.authorization` + field. - ## Example - The following session shows how one would refund a previous purchase (and similarily for captures). - ``` - iex> amount = 5 + Multiple, partial refunds can be performed on the same "charge token" + referencing a previous `purchase/3` or `capture/3` till the cumulative refunds + equals the `capture/3`d or `purchase/3`d amount. - iex> token = "charge_668d3e169b27d4938b39246cb8c0890b0bd84c3c" + ## Example - iex> options = [email: "john@trexle.com", ip_address: "66.249.79.118", description: "Store Purchase 1437598192"] + The following session shows how one would refund $100 of a previous + `purchase/3` (and similarily for `capture/3`s). - iex> Gringotts.refund(:payment_worker, Gringotts.Gateways.Trexle, amount, token, options) + ``` + iex> amount = Money.new(10, :USD) + iex> token = "some-real-token" + iex> Gringotts.refund(Gringotts.Gateways.Trexle, amount, token) ``` """ - - @spec refund(float, String.t, list) :: map + @spec refund(Money.t(), String.t(), keyword) :: {:ok | :error, Response} def refund(amount, charge_token, opts \\ []) do - params = [amount: amount] + {_, int_value, _} = Money.to_integer(amount) + params = [amount: int_value] commit(:post, "charges/#{charge_token}/refunds", params, opts) end @@ -163,85 +239,115 @@ defmodule Gringotts.Gateways.Trexle do Stores the card information for future use. ## Example - The following session shows how one would store a card (a payment-source) for future use. + + The following session shows how one would store a card (a payment-source) for + future use. ``` - iex> card = %{ - name: "John Doe", - number: "5200828282828210", - expiry_month: 1, - expiry_year: 2018, - cvc: "123", - address_line1: "456 My Street", - address_city: "Ottawa", - address_postcode: "K1C2N6", - address_state: "ON", - address_country: "CA" - } - - iex> options = [email: "john@trexle.com", ip_address: "66.249.79.118", description: "Store Purchase 1437598192"] - - iex> Gringotts.store(:payment_worker, Gringotts.Gateways.Trexle, card, options) + iex> card = %CreditCard{ + first_name: "Harry", + last_name: "Potter", + number: "5200828282828210", + year: 2099, month: 12, + verification_code: "123", + brand: "VISA"} + iex> address = %Address{ + street1: "301, Gryffindor", + street2: "Hogwarts School of Witchcraft and Wizardry, Hogwarts Castle", + city: "Highlands", + region: "SL", + country: "GB", + postal_code: "11111", + phone: "(555)555-5555"} + iex> options = [email: "masterofdeath@ministryofmagic.gov", + ip_address: "127.0.0.1", + billing_address: address, + description: "For our valued customer, Mr. Potter"] + iex> Gringotts.store(Gringotts.Gateways.Trexle, card, options) ``` """ - - @spec store(map, list) :: map + @spec store(CreditCard.t(), keyword) :: {:ok | :error, Response} def store(payment, opts \\ []) do - params = [email: @email]++card_params(payment) + params = + [email: opts[:email]] ++ card_params(payment) ++ address_params(opts[:billing_address]) + commit(:post, "customers", params, opts) end defp create_params_for_auth_or_purchase(amount, payment, opts, capture \\ true) do + {currency, int_value, _} = Money.to_integer(amount) + [ capture: capture, - amount: amount, - currency: opts[:config][:default_currency], + amount: int_value, + currency: currency, email: opts[:email], ip_address: opts[:ip_address], description: opts[:description] - ] ++ card_params(payment) + ] ++ card_params(payment) ++ address_params(opts[:billing_address]) end - defp card_params(%{} = card) do + defp card_params(%CreditCard{} = card) do [ - "card[name]": card[:name], - "card[number]": card[:number], - "card[expiry_year]": card[:expiry_year], - "card[expiry_month]": card[:expiry_month], - "card[cvc]": card[:cvc], - "card[address_line1]": card[:address_line1], - "card[address_city]": card[:address_city], - "card[address_postcode]": card[:address_postcode], - "card[address_state]": card[:address_state], - "card[address_country]": card[:address_country] + "card[name]": CreditCard.full_name(card), + "card[number]": card.number, + "card[expiry_year]": card.year, + "card[expiry_month]": card.month, + "card[cvc]": card.verification_code ] end - defp commit(method, path, params \\ [], opts \\ []) do + defp address_params(%Address{} = address) do + [ + "card[address_line1]": address.street1, + "card[address_line2]": address.street2, + "card[address_city]": address.city, + "card[address_postcode]": address.postal_code, + "card[address_state]": address.region, + "card[address_country]": address.country + ] + end + + defp commit(method, path, params, opts) do auth_token = "Basic #{Base.encode64(opts[:config][:api_key])}" - headers = [{"Content-Type", "application/x-www-form-urlencoded"}, {"Authorization", auth_token}] - data = params_to_string(params) - options = [hackney: [:insecure, basic_auth: {opts[:config][:api_key], "password"}]] + + headers = [ + {"Content-Type", "application/x-www-form-urlencoded"}, + {"Authorization", auth_token} + ] + + options = [basic_auth: {opts[:config][:api_key], "password"}] url = "#{@base_url}#{path}" - response = HTTPoison.request(method, url, data, headers, options) + response = HTTPoison.request(method, url, {:form, params}, headers, options) response |> respond end - @spec respond(term) :: - {:ok, Response} | - {:error, Response} + @spec respond(term) :: {:ok | :error, Response} defp respond(response) defp respond({:ok, %{status_code: code, body: body}}) when code in [200, 201] do - case decode(body) do - {:ok, results} -> {:ok, Response.success(raw: results, status_code: code)} - end + {:ok, results} = decode(body) + token = results["response"]["token"] + message = results["response"]["status_message"] + + { + :ok, + %Response{id: token, message: message, raw: body, status_code: code} + } end defp respond({:ok, %{status_code: status_code, body: body}}) do - {:error, Response.error(status_code: status_code, raw: body)} + {:ok, results} = decode(body) + detail = results["detail"] + {:error, %Response{status_code: status_code, message: detail, reason: detail, raw: body}} end defp respond({:error, %HTTPoison.Error{} = error}) do - {:error, Response.error(code: error.id, reason: :network_fail?, description: "HTTPoison says '#{error.reason}'")} + { + :error, + %Response{ + reason: "network related failure", + message: "HTTPoison says '#{error.reason}' [ID: #{error.id || "nil"}]" + } + } end end diff --git a/lib/gringotts/gateways/wire_card.ex b/lib/gringotts/gateways/wire_card.ex index 14134ed2..939a0fef 100644 --- a/lib/gringotts/gateways/wire_card.ex +++ b/lib/gringotts/gateways/wire_card.ex @@ -1,5 +1,6 @@ # call => Gringotts.Gateways.WireCard.authorize(100, creditcard, options) import XmlBuilder + defmodule Gringotts.Gateways.WireCard do @moduledoc """ WireCard System Plugins @@ -7,7 +8,7 @@ defmodule Gringotts.Gateways.WireCard do @test_url "https://c3-test.wirecard.com/secure/ssl-gateway" @live_url "https://c3.wirecard.com/secure/ssl-gateway" @homepage_url "http://www.wirecard.com" - + @doc """ Wirecard only allows phone numbers with a format like this: +xxx(yyy)zzz-zzzz-ppp, where: xxx = Country code @@ -18,8 +19,8 @@ defmodule Gringotts.Gateways.WireCard do number 5551234 within area code 202 (country code 1). """ @valid_phone_format ~r/\+\d{1,3}(\(?\d{3}\)?)?\d{3}-\d{4}-\d{3}/ - @default_currency "EUR" - @default_amount 100 + @default_currency "EUR" + @default_amount 100 use Gringotts.Gateways.Base use Gringotts.Adapter, required_config: [:login, :password, :signature] @@ -38,10 +39,10 @@ defmodule Gringotts.Gateways.WireCard do then then the :recurring option will be forced to "Repeated" =========================================================== TODO: Mandatorily check for :login,:password, :signature in options - Note: payment_menthod for now is only credit_card and + Note: payment_menthod for now is only credit_card and TODO: change it so it can also have GuWID ================================================ - E.g: => + E.g: => creditcard = %CreditCard{ number: "4200000000000000", month: 12, @@ -65,19 +66,19 @@ defmodule Gringotts.Gateways.WireCard do } options = [ config: %{ - login: "00000031629CA9FA", + login: "00000031629CA9FA", password: "TestXAPTER", signature: "00000031629CAFD5", - }, + }, order_id: 1, billing_address: address, description: 'Wirecard remote test purchase', email: "soleone@example.com", ip: "127.0.0.1", test: true - ] + ] """ - @spec authorize(Integer | Float, CreditCard.t | String.t, Keyword) :: { :ok, Map } + @spec authorize(Integer | Float, CreditCard.t() | String.t(), Keyword) :: {:ok, Map} def authorize(money, payment_method, options \\ []) def authorize(money, %CreditCard{} = creditcard, options) do @@ -92,9 +93,9 @@ defmodule Gringotts.Gateways.WireCard do @doc """ Capture - the first paramter here should be a GuWid/authorization. - Authorization is obtained by authorizing the creditcard. + Authorization is obtained by authorizing the creditcard. """ - @spec capture(String.t, Float, Keyword) :: { :ok, Map } + @spec capture(String.t(), Float, Keyword) :: {:ok, Map} def capture(authorization, money, options \\ []) when is_binary(authorization) do options = Keyword.put(options, :preauthorization, authorization) commit(:post, :capture, money, options) @@ -106,7 +107,7 @@ defmodule Gringotts.Gateways.WireCard do transaction. If a GuWID is given, rather than a CreditCard, then then the :recurring option will be forced to "Repeated" """ - @spec purchase(Float | Integer, CreditCard| String.t, Keyword) :: { :ok, Map } + @spec purchase(Float | Integer, CreditCard | String.t(), Keyword) :: {:ok, Map} def purchase(money, payment_method, options \\ []) def purchase(money, %CreditCard{} = creditcard, options) do @@ -120,33 +121,33 @@ defmodule Gringotts.Gateways.WireCard do end @doc """ - Void - A credit card purchase that a seller cancels after it has - been authorized but before it has been settled. - A void transaction does not appear on the customer's + Void - A credit card purchase that a seller cancels after it has + been authorized but before it has been settled. + A void transaction does not appear on the customer's credit card statement, though it might appear in a list - of pending transactions when the customer checks their + of pending transactions when the customer checks their account online. ==== Parameters ====== identification - The authorization string returned from the initial authorization or purchase. """ - @spec void(String.t, Keyword) :: { :ok, Map } + @spec void(String.t(), Keyword) :: {:ok, Map} def void(identification, options \\ []) when is_binary(identification) do options = Keyword.put(options, :preauthorization, identification) commit(:post, :reversal, nil, options) end - + @doc """ Performs a credit. - - This transaction indicates that money - should flow from the merchant to the customer. + + This transaction indicates that money + should flow from the merchant to the customer. ==== Parameters ==== - money -- The amount to be credited to the customer + money -- The amount to be credited to the customer as an Integer value in cents. identification -- GuWID """ - @spec refund(Float, String.t, Keyword) :: { :ok, Map } + @spec refund(Float, String.t(), Keyword) :: {:ok, Map} def refund(money, identification, options \\ []) when is_binary(identification) do options = Keyword.put(options, :preauthorization, identification) commit(:post, :bookback, money, options) @@ -161,69 +162,76 @@ defmodule Gringotts.Gateways.WireCard do "RECURRING_TRANSACTION/Type" set to "Initial". Subsequent transactions can then use the GuWID in place of a credit card by setting "RECURRING_TRANSACTION/Type" to "Repeated". - + This implementation of card store utilizes a Wirecard "Authorization Check" (a Preauthorization that is automatically reversed). It defaults to a check amount of "100" (i.e. $1.00) but this can be overriden (see below). - + IMPORTANT: In order to reuse the stored reference, the +authorization+ from the response should be saved by your application code. - + ==== Options specific to +store+ - + * :amount -- The amount, in cents, that should be "validated" by the Authorization Check. This amount will be reserved and then reversed. Default is 100. - + Note: This is not the only way to achieve a card store operation at Wirecard. Any +purchase+ or +authorize+ can be sent with +options[:recurring] = 'Initial'+ to make the returned authorization/GuWID usable in later transactions with +options[:recurring] = 'Repeated'+. """ - @spec store(CreditCard.t, Keyword) :: { :ok, Map } + @spec store(CreditCard.t(), Keyword) :: {:ok, Map} def store(%CreditCard{} = creditcard, options \\ []) do - options = options - |> Keyword.put(:credit_card, creditcard) - |> Keyword.put(:recurring, "Initial") + options = + options + |> Keyword.put(:credit_card, creditcard) + |> Keyword.put(:recurring, "Initial") + money = options[:amount] || @default_amount # Amex does not support authorization_check case creditcard.brand do "american_express" -> commit(:post, :preauthorization, money, options) - _ -> commit(:post, :authorization_check, money, options) + _ -> commit(:post, :authorization_check, money, options) end end - - # =================== Private Methods =================== - + + # =================== Private Methods =================== + # Contact WireCard, make the XML request, and parse the # reply into a Response object. defp commit(method, action, money, options) do - #TODO: validate and setup address hash as per AM + # TODO: validate and setup address hash as per AM request = build_request(action, money, options) - headers = %{ "Content-Type" => "text/xml", - "Authorization" => encoded_credentials( - options[:config][:login], options[:config][:password] - ) - } - method |> HTTPoison.request(base_url(options) , request, headers) |> respond + + headers = %{ + "Content-Type" => "text/xml", + "Authorization" => + encoded_credentials( + options[:config][:login], + options[:config][:password] + ) + } + + method |> HTTPoison.request(base_url(options), request, headers) |> respond end - defp respond({:ok, %{ status_code: 200, body: body}}) do + defp respond({:ok, %{status_code: 200, body: body}}) do response = parse(body) {:ok, response} end defp respond({:ok, %{body: body, status_code: status_code}}) do - { :error, "Some Error Occurred: \n #{ inspect body }" } + {:error, "Some Error Occurred: \n #{inspect(body)}"} end # Read the XML message from the gateway and check if it was successful, # and also extract required return values from the response # TODO: parse XML Response - defp parse(data) do + defp parse(data) do XmlToMap.naive_map(data) end @@ -231,15 +239,18 @@ defmodule Gringotts.Gateways.WireCard do defp build_request(action, money, options) do options = Keyword.put(options, :action, action) - request = doc(element(:WIRECARD_BXML, [ - element(:W_REQUEST, [ - element(:W_JOB, [ - element(:JobID, ""), - element(:BusinessCaseSignature, options[:config][:signature]), - add_transaction_data(action, money, options) + request = + doc( + element(:WIRECARD_BXML, [ + element(:W_REQUEST, [ + element(:W_JOB, [ + element(:JobID, ""), + element(:BusinessCaseSignature, options[:config][:signature]), + add_transaction_data(action, money, options) + ]) ]) ]) - ])) + ) request end @@ -250,11 +261,14 @@ defmodule Gringotts.Gateways.WireCard do defp add_transaction_data(action, money, options) do element("FNC_CC_#{atom_to_upcase_string(options[:action])}", [ element(:FunctionID, "dummy_description"), - element(:CC_TRANSACTION, [ - element(:TransactionID, options[:order_id]), - element(:CommerceType, (if options[:commerce_type], do: options[:commerce_type])) - ] ++ add_action_data(action, money, options) ++ add_customer_data(options) - )]) + element( + :CC_TRANSACTION, + [ + element(:TransactionID, options[:order_id]), + element(:CommerceType, if(options[:commerce_type], do: options[:commerce_type])) + ] ++ add_action_data(action, money, options) ++ add_customer_data(options) + ) + ]) end # Includes the IP address of the customer to the transaction-xml @@ -269,9 +283,14 @@ defmodule Gringotts.Gateways.WireCard do def add_action_data(action, money, options) do case options[:action] do # returns array of elements - action when(action in [:preauthorization, :purchase, :authorization_check]) -> create_elems_for_preauth_or_purchase_or_auth_check(money, options) - action when(action in [:capture, :bookback]) -> create_elems_for_capture_or_bookback(money, options) - action when(action == :reversal) -> add_guwid(options[:preauthorization]) + action when action in [:preauthorization, :purchase, :authorization_check] -> + create_elems_for_preauth_or_purchase_or_auth_check(money, options) + + action when action in [:capture, :bookback] -> + create_elems_for_capture_or_bookback(money, options) + + action when action == :reversal -> + add_guwid(options[:preauthorization]) end end @@ -280,25 +299,29 @@ defmodule Gringotts.Gateways.WireCard do add_guwid(options[:preauthorization]) ++ [add_amount(money, options)] end - # Creates xml request elements if action is preauth, purchase ir auth_check + # Creates xml request elements if action is preauth, purchase ir auth_check # TODO: handle nil values if array not generated defp create_elems_for_preauth_or_purchase_or_auth_check(money, options) do # TODO: setup_recurring_flag - add_invoice(money, options) ++ element_for_credit_card_or_guwid(options) ++ add_address(options[:billing_address]) + add_invoice(money, options) ++ + element_for_credit_card_or_guwid(options) ++ add_address(options[:billing_address]) end - + defp add_address(address) do if address do [ element(:CORPTRUSTCENTER_DATA, [ element(:ADDRESS, [ element(:Address1, address[:address1]), - element(:Address2, (if address[:address2], do: address[:address2])), + element(:Address2, if(address[:address2], do: address[:address2])), element(:City, address[:city]), - element(:Zip, address[:zip]), + element(:Zip, address[:zip]), add_state(address), element(:Country, address[:country]), - element(:Phone, (if regex_match(@valid_phone_format ,address[:phone]), do: address[:phone])), + element( + :Phone, + if(regex_match(@valid_phone_format, address[:phone]), do: address[:phone]) + ), element(:Email, address[:email]) ]) ]) @@ -307,9 +330,9 @@ defmodule Gringotts.Gateways.WireCard do end defp add_state(address) do - if ( regex_match(~r/[A-Za-z]{2}/, address[:state]) && regex_match(~r/^(us|ca)$/i, address[:country]) - ) do - element(:State, (String.upcase(address[:state]))) + if regex_match(~r/[A-Za-z]{2}/, address[:state]) && + regex_match(~r/^(us|ca)$/i, address[:country]) do + element(:State, String.upcase(address[:state])) end end @@ -320,7 +343,7 @@ defmodule Gringotts.Gateways.WireCard do add_guwid(options[:preauthorization]) end end - + # Includes Guwid data to transaction-xml defp add_guwid(preauth) do [element(:GuWID, preauth)] @@ -329,13 +352,15 @@ defmodule Gringotts.Gateways.WireCard do # Includes the credit-card data to the transaction-xml # TODO: Format Credit Card month, ref AM defp add_creditcard(creditcard) do - [element(:CREDIT_CARD_DATA, [ - element(:CreditCardNumber, creditcard.number), - element(:CVC2, creditcard.verification_code), - element(:ExpirationYear, creditcard.year), - element(:ExpirationMonth, creditcard.month), - element(:CardHolderName, join_string([creditcard.first_name, creditcard.last_name], " ")) - ])] + [ + element(:CREDIT_CARD_DATA, [ + element(:CreditCardNumber, creditcard.number), + element(:CVC2, creditcard.verification_code), + element(:ExpirationYear, creditcard.year), + element(:ExpirationMonth, creditcard.month), + element(:CardHolderName, join_string([creditcard.first_name, creditcard.last_name], " ")) + ]) + ] end # Includes the payment (amount, currency, country) to the transaction-xml @@ -345,11 +370,11 @@ defmodule Gringotts.Gateways.WireCard do element(:Currency, currency(options)), element(:CountryCode, options[:billing_address][:country]), element(:RECURRING_TRANSACTION, [ - element(:Type, (options[:recurring] || "Single")) + element(:Type, options[:recurring] || "Single") ]) ] end - + # Include the amount in the transaction-xml # TODO: check for localized currency or currency # localized_amount(money, options[:currency] || currency(money)) @@ -357,23 +382,24 @@ defmodule Gringotts.Gateways.WireCard do defp atom_to_upcase_string(atom) do atom - |> to_string - |> String.upcase + |> to_string + |> String.upcase() end # Encode login and password in Base64 to supply as HTTP header # (for http basic authentication) defp encoded_credentials(login, password) do - join_string([login, password], ":") - |> Base.encode64 - |> (&( "Basic "<> &1)).() + [login, password] + |> join_string(":") + |> Base.encode64() + |> (&("Basic " <> &1)).() end defp join_string(list_of_words, joiner), do: Enum.join(list_of_words, joiner) defp regex_match(regex, string), do: Regex.match?(regex, string) - defp base_url(opts), do: if opts[:test], do: @test_url, else: @live_url + defp base_url(opts), do: if(opts[:test], do: @test_url, else: @live_url) defp currency(opts), do: opts[:currency] || @default_currency end diff --git a/lib/gringotts/money.ex b/lib/gringotts/money.ex new file mode 100644 index 00000000..304cc764 --- /dev/null +++ b/lib/gringotts/money.ex @@ -0,0 +1,184 @@ +defprotocol Gringotts.Money do + @moduledoc """ + Money protocol used by the Gringotts API. + + The `amount` argument required for some of Gringotts' API methods must + implement this protocol. + + If your application is already using a supported Money library, just pass in + the Money struct and things will work out of the box. + + Otherwise, just wrap your `amount` with the `currency` together in a `Map` + like so, + + price = %{value: Decimal.new("20.18"), currency: "USD"} + + and the API will accept it (as long as the currency is valid [ISO 4217 + currency code](https://www.iso.org/iso-4217-currency-codes.html)). + + ## Note on the `Any` implementation + + Both `to_string/1` and `to_integer/1` assume that the precision for the `currency` + is 2 digits after decimal. + """ + @fallback_to_any true + @type t :: Gringotts.Money.t() + + @doc """ + Returns the ISO 4217 compliant currency code associated with this sum of money. + + This must be an UPCASE `string` + """ + @spec currency(t) :: String.t() + def currency(money) + + @doc """ + Returns a `Decimal.t` representing the "worth" of this sum of money in the + associated `currency`. + """ + @spec value(t) :: Decimal.t() + def value(money) + + @doc """ + Returns the ISO4217 `currency` code as string and `value` as an integer. + + Useful for gateways that require amount as integer (like cents instead of + dollars). + + ## Note + + Conversion from `Decimal.t` to `integer` is potentially lossy and the rounding + (if required) is performed (automatically) by the Money library defining the + type, or in the implementation of this protocol method. + + If you want to implement this method for your custom type, please ensure that + the rounding strategy (if any rounding is applied) must be + [`half_even`][wiki-half-even]. + + **To keep things predictable and transparent, merchants should round the + `amount` themselves**, perhaps by explicitly calling the relevant method of + the Money library in their application _before_ passing it to `Gringotts`'s + public API. + + ## Examples + + # the money lib is aliased as "MoneyLib" + + iex> usd_price = MoneyLib.new("4.1234", :USD) + #MoneyLib<4.1234, "USD"> + iex> Gringotts.Money.to_integer(usd_price) + {"USD", 412, -2} + + iex> bhd_price = MoneyLib.new("4.1234", :BHD) + #MoneyLib<4.1234, "BHD"> + iex> Gringotts.Money.to_integer(bhd_price) + {"BHD", 4123, -3} + # the Bahraini dinar is divided into 1000 fils unlike the dollar which is + # divided in 100 cents + + [wiki-half-even]: https://en.wikipedia.org/wiki/Rounding#Round_half_to_even + """ + @spec to_integer(Money.t()) :: + {currency :: String.t(), value :: integer, exponent :: neg_integer} + def to_integer(money) + + @doc """ + Returns a tuple of ISO4217 `currency` code and the `value` as strings. + + The stringified `value` must match this regex: `~r/-?\\d+\\.\\d\\d{n}/` where + `n+1` should match the required precision for the `currency`. There should be + no place value separators except the decimal point (like commas). + + > Gringotts will not (and cannot) validate this of course. + + ## Note + + Conversion from `Decimal.t` to `string` is potentially lossy and the rounding + (if required) is performed (automatically) by the Money library defining the + type, or in the implementation of this protocol method. + + If you want to implement this method for your custom type, please ensure that + the rounding strategy (if any rounding is applied) must be + [`half_even`][wiki-half-even]. + + **To keep things predictable and transparent, merchants should round the + `amount` themselves**, perhaps by explicitly calling the relevant method of + the Money library in their application _before_ passing it to `Gringotts`'s + public API. + + ## Examples + + # the money lib is aliased as "MoneyLib" + + iex> usd_price = MoneyLib.new("4.1234", :USD) + #MoneyLib<4.1234, "USD"> + iex> Gringotts.Money.to_string(usd_price) + {"USD", "4.12"} + + iex> bhd_price = MoneyLib.new("4.1234", :BHD) + #MoneyLib<4.1234, "BHD"> + iex> Gringotts.Money.to_string(bhd_price) + {"BHD", "4.123"} + + [wiki-half-even]: https://en.wikipedia.org/wiki/Rounding#Round_half_to_even + """ + @spec to_string(t) :: {currency :: String.t(), value :: String.t()} + def to_string(money) +end + +# this implementation is used for dispatch on ex_money (and will also fire for +# money) +if Code.ensure_compiled?(Money) do + defimpl Gringotts.Money, for: Money do + def currency(money), do: money.currency |> Atom.to_string() + def value(money), do: money.amount + + def to_integer(money) do + {_, int_value, exponent, _} = Money.to_integer_exp(money) + {currency(money), int_value, exponent} + end + + def to_string(money) do + {:ok, currency_data} = Cldr.Currency.currency_for_code(currency(money)) + reduced = Money.reduce(money) + + { + currency(reduced), + value(reduced) + |> Decimal.round(currency_data.digits) + |> Decimal.to_string() + } + end + end +end + +if Code.ensure_compiled?(Monetized.Money) do + defimpl Gringotts.Money, for: Monetized.Money do + def currency(money), do: money.currency + def value(money), do: money.amount + end +end + +# Assumes that the currency is subdivided into 100 units +defimpl Gringotts.Money, for: Any do + def currency(%{currency: currency}), do: currency + def value(%{value: %Decimal{} = value}), do: value + + def to_integer(%{value: %Decimal{} = value, currency: currency}) do + { + currency, + value + |> Decimal.mult(Decimal.new(100)) + |> Decimal.round(0) + |> Decimal.to_integer(), + -2 + } + end + + def to_string(%{value: %Decimal{} = value, currency: currency}) do + { + currency, + value |> Decimal.round(2) |> Decimal.to_string() + } + end +end diff --git a/lib/gringotts/response.ex b/lib/gringotts/response.ex index b9ba7270..c64ec0a2 100644 --- a/lib/gringotts/response.ex +++ b/lib/gringotts/response.ex @@ -1,26 +1,82 @@ defmodule Gringotts.Response do - @moduledoc ~S""" - Module which defines the struct for response struct. - - Response struct is a standard response from public API to the application. - - It mostly has such as:- - * `success`: boolean indicating the status of the transaction - * `authorization`: token which is used to issue requests without the card info - * `status_code`: response code - * `error_code`: error code if there is error else nil - * `message`: message related to the status of the response - * `avs_result`: result for address verfication - * `cvc_result`: result for cvc verification - * `params`: original raw response from the gateway - * `fraud_review`: information related to fraudulent transactions + @moduledoc """ + Defines the Response `struct` and some utilities. + + All `Gringotts` public API calls will return a `Response.t` wrapped in an + `:ok` or `:error` `tuple`. It is guaranteed that an `:ok` will be returned + only when the request succeeds at the gateway, ie, no error occurs. """ - + defstruct [ - :success, :authorization, :status_code, :error_code, :message, - :avs_result, :cvc_result, :params, :fraud_review + :success, + :id, + :token, + :status_code, + :gateway_code, + :reason, + :message, + :avs_result, + :cvc_result, + :raw, + :fraud_review ] + @typedoc """ + The standard Response from `Gringotts`. + + | Field | Type | Description | + |----------------|-------------------|---------------------------------------| + | `success` | `boolean` | Indicates the status of the\ + transaction. | + | `id` | `String.t` | Gateway supplied identifier of the\ + transaction. | + | `token` | `String.t` | Gateway supplied `token`. _This is\ + different from `Response.id`_. | + | `status_code` | `non_neg_integer` | `HTTP` response code. | + | `gateway_code` | `String.t` | Gateway's response code "as-is". | + | `message` | `String.t` | String describing the response status.| + | `avs_result` | `map` | Address Verification Result.\ + Schema: `%{street: String.t,\ + zip_code: String.t}` | + | `cvc_result` | `String.t` | Result of the [CVC][cvc] validation. | + | `reason` | `String.t` | Explain the `reason` of error, in\ + case of error. `nil` otherwise. | + | `raw` | `String.t` | Raw response from the gateway. | + | `fraud_review` | `term` | Gateway's risk assessment of the\ + transaction. | + + ## Notes + + 1. It is not guaranteed that all fields will be populated for all calls, and + some gateways might insert non-standard fields. Please refer the Gateways' + docs for that information. + + 2. `success` is deprecated in `v1.1.0` and will be removed in `v1.2.0`. + + 3. For some actions the Gateway returns an additional token, say as reponse to + a customer tokenization/registration. In such cases the `id` is not + useable because it refers to the transaction, the `token` is. + + > On the other hand for authorizations or captures, there's no `token`. + + 4. The schema of `fraud_review` is Gateway specific. + + [cvc]: https://en.wikipedia.org/wiki/Card_security_code + """ + @type t :: %__MODULE__{ + success: boolean, + id: String.t(), + token: String.t(), + status_code: non_neg_integer, + gateway_code: String.t(), + reason: String.t(), + message: String.t(), + avs_result: %{street: String.t(), zip_code: String.t()}, + cvc_result: String.t(), + raw: String.t(), + fraud_review: term + } + def success(opts \\ []) do new(true, opts) end diff --git a/lib/gringotts/worker.ex b/lib/gringotts/worker.ex deleted file mode 100644 index 786ad6ed..00000000 --- a/lib/gringotts/worker.ex +++ /dev/null @@ -1,89 +0,0 @@ -defmodule Gringotts.Worker do - @moduledoc ~S""" - A central supervised worker handling all the calls for different gateways - - It's main task is to re-route the requests to the respective gateway methods. - - State for this worker currently is:- - * `gateways`:- a list of all the gateways configured in the application. - * `all_configs`:- All the configurations for all the gateways that are configured. - """ - use GenServer - - def start_link(gateways, all_config, opts \\ []) do - GenServer.start_link(__MODULE__, [gateways, all_config], opts) - end - - def init([gateways, all_config]) do - {:ok, %{configs: all_config, gateways: gateways}} - end - - @doc """ - Handles call for `authorize` method - """ - def handle_call({:authorize, gateway, amount, card, opts}, _from, state) do - {gateway, config} = set_gateway_and_config(gateway) - response = gateway.authorize(amount, card, [{:config, config} | opts]) - {:reply, response, state} - end - - @doc """ - Handles call for `purchase` method - """ - def handle_call({:purchase, gateway, amount, card, opts}, _from, state) do - {gateway, config} = set_gateway_and_config(gateway) - response = gateway.purchase(amount, card, [{:config, config} | opts]) - {:reply, response, state} - end - - @doc """ - Handles call for `capture` method - """ - def handle_call({:capture, gateway, id, amount, opts}, _from, state) do - {gateway, config} = set_gateway_and_config(gateway) - response = gateway.capture(id, amount, [{:config, config} | opts]) - {:reply, response, state} - end - - @doc """ - Handles call for `void` method - """ - def handle_call({:void, gateway, id, opts}, _from, state) do - {gateway, config} = set_gateway_and_config(gateway) - response = gateway.void(id, [{:config, config} | opts]) - {:reply, response, state} - end - - @doc """ - Handles call for 'refund' method - """ - def handle_call({:refund, gateway, amount, id, opts}, _from, state) do - {gateway, config} = set_gateway_and_config(gateway) - response = gateway.refund(amount, id, [{:config, config} | opts]) - {:reply, response, state} - end - - @doc """ - Handles call for `store` method - """ - def handle_call({:store, gateway, card, opts}, _from, state) do - {gateway, config} = set_gateway_and_config(gateway) - response = gateway.store(card, [{:config, config} | opts]) - {:reply, response, state} - end - - @doc """ - Handles call for 'unstore' method - """ - def handle_call({:unstore, gateway, customer_id, opts}, _from, state) do - {gateway, config} = set_gateway_and_config(gateway) - response = gateway.unstore(customer_id, [{:config, config} | opts]) - {:reply, response, state} - end - - defp set_gateway_and_config(request_gateway) do - global_config = Application.get_env(:gringotts, :global_config) || [mode: :test] - gateway_config = Application.get_env(:gringotts, request_gateway) - {request_gateway, Keyword.merge(global_config, gateway_config)} - end -end diff --git a/lib/mix/new.ex b/lib/mix/new.ex new file mode 100644 index 00000000..7b4fcd2e --- /dev/null +++ b/lib/mix/new.ex @@ -0,0 +1,142 @@ +defmodule Mix.Tasks.Gringotts.New do + @shortdoc """ + Generates a barebones implementation for a gateway. + """ + + @moduledoc """ + Generates a barebones implementation for a gateway. + + It expects the (brand) name of the gateway as argument and we recommend that + it be capitalized. *This will not necessarily be the module name*. + + ``` + mix gringotts.new NAME [-m, --module MODULE] [-f, --file FILENAME] [--url URL] + ``` + + A barebones implementation of the gateway will be created along with skeleton + mock and integration tests in `lib/gringotts/gateways/`. The command will + prompt for the module name, and other metadata. + + ## Options + + > ***Tip!*** + > You can supply the extra arguments to `gringotts.new` to skip (some of) the + > prompts. + + * `-m` `--module` - The module name for the Gateway. + * `-f` `--file` - The filename. + * `--url` - The homepage of the gateway. + + ## Examples + + mix gringotts.new FooBar + + The prompts for this will be: + ``` + MODULE = "Foobar" + URL = "https://www.foobar.com" + FILENAME = "foo_bar.ex" + ``` + """ + + use Mix.Task + import Mix.Generator + + @long_msg ~s{ +Comma separated list of required configuration keys: +(This can be skipped by hitting `Enter`) +> } + + def run(args) do + {key_list, [name], []} = + OptionParser.parse( + args, + switches: [module: :string, url: :string, file: :string], + aliases: [m: :module, f: :file] + ) + + Mix.Shell.IO.info("Generating barebones implementation for #{name}.") + Mix.Shell.IO.info("Hit enter to select the suggestion.") + + module_suggestion = + name |> String.split() |> Enum.map(&String.capitalize(&1)) |> Enum.join("") + + module_name = + case Keyword.fetch(key_list, :module) do + :error -> + prompt_with_suggestion("\nModule name", module_suggestion) + + {:ok, mod_name} -> + mod_name + end + + url = + case Keyword.fetch(key_list, :url) do + :error -> + prompt_with_suggestion( + "\nHomepage URL", + "https://www.#{String.downcase(module_suggestion)}.com" + ) + + {:ok, url} -> + url + end + + file_name = + case Keyword.fetch(key_list, :file) do + :error -> + prompt_with_suggestion("\nFilename", Macro.underscore(module_name) <> ".ex") + + {:ok, filename} -> + filename + end + + file_base_name = String.slice(file_name, 0..-4) + + required_keys = + case Mix.Shell.IO.prompt(@long_msg) |> String.trim() do + "" -> + [] + + keys -> + String.split(keys, ",") |> Enum.map(&String.trim(&1)) |> Enum.map(&String.to_atom(&1)) + end + + bindings = [ + gateway: name, + gateway_module: module_name, + gateway_underscore: file_name, + # The key :gateway_filename is not used in any template as of now. + gateway_filename: "#{file_name}", + required_config_keys: required_keys, + gateway_url: url, + mock_test_filename: "#{file_base_name}_test.exs", + mock_response_filename: "#{file_base_name}_mock.exs" + ] + + if Mix.Shell.IO.yes?( + "\nDoes this look good?\n#{inspect(bindings, pretty: true, width: 40)}\n>" + ) do + gateway = EEx.eval_file("templates/gateway.eex", bindings) + mock = EEx.eval_file("templates/test.eex", bindings) + mock_response = EEx.eval_file("templates/mock_response.eex", bindings) + integration = EEx.eval_file("templates/integration.eex", bindings) + + create_file("lib/gringotts/gateways/#{bindings[:gateway_filename]}", gateway) + create_file("test/integration/gateways/#{bindings[:mock_test_filename]}", integration) + + if Mix.Shell.IO.yes?("\nAlso create empty mock test suite?\n>") do + create_file("test/gateways/#{bindings[:mock_test_filename]}", mock) + create_file("test/mocks/#{bindings[:mock_response_filename]}", mock_response) + end + else + Mix.Shell.IO.info("Doing nothing, bye!") + end + end + + defp prompt_with_suggestion(message, suggestion) do + decorated_message = "#{message} [#{suggestion}]" + response = Mix.Shell.IO.prompt(decorated_message) |> String.trim() + if response == "", do: suggestion, else: response + end +end diff --git a/mix.exs b/mix.exs index 261a72da..c3074816 100644 --- a/mix.exs +++ b/mix.exs @@ -1,6 +1,6 @@ defmodule Gringotts.Mixfile do use Mix.Project - + def project do [ app: :gringotts, @@ -17,13 +17,14 @@ defmodule Gringotts.Mixfile do tool: ExCoveralls ], preferred_cli_env: [ - "coveralls": :test, - "coveralls.detail": :test, - "coveralls.post": :test, - "coveralls.html": :test, - "coveralls.travis": :test + coveralls: :test, + "coveralls.detail": :test, + "coveralls.json": :test, + "coveralls.html": :test ], - deps: deps()] + deps: deps(), + docs: docs() + ] end # Configuration for the OTP application @@ -31,8 +32,7 @@ defmodule Gringotts.Mixfile do # Type `mix help compile.app` for more information def application do [ - applications: [:httpoison, :hackney, :elixir_xml_to_map], - mod: {Gringotts.Application, []} + applications: [:httpoison, :hackney, :elixir_xml_to_map, :timex] ] end @@ -49,23 +49,49 @@ defmodule Gringotts.Mixfile do [ {:poison, "~> 3.1.0"}, {:httpoison, "~> 0.13"}, - {:ex_doc, "~> 0.16", only: :dev, runtime: false}, + {:xml_builder, "~> 2.1"}, + {:elixir_xml_to_map, "~> 0.1"}, + + # Money related + {:decimal, "~> 1.0", optional: true}, + # ex_money is just needed for tests. + {:ex_money, "~> 1.1.0", only: [:dev, :test], optional: true}, + + # docs and tests + {:ex_doc, "~> 0.18", only: :dev, runtime: false}, {:mock, "~> 0.3.0", only: :test}, {:bypass, "~> 0.8", only: :test}, - {:xml_builder, "~> 0.1.1"}, - {:elixir_xml_to_map, "~> 0.1"}, - {:excoveralls, "~> 0.7", only: :test}, + {:excoveralls, "~> 0.8", only: :test}, + {:exvcr, "~> 0.10", only: :test}, + + # various analyses tools {:credo, "~> 0.3", only: [:dev, :test]}, - {:inch_ex, only: :docs}, - {:dialyxir, "~> 0.3", only: [:dev]} + {:inch_ex, "~> 0.5", only: :docs}, + {:dialyxir, "~> 0.3", only: :dev}, + {:timex, "~> 3.2"} ] end defp description do """ - Gringotts is a payment processing library in Elixir integrating - various payment gateways, this draws motivation for shopify's + Gringotts is a payment processing library in Elixir integrating + various payment gateways, this draws motivation for shopify's activemerchant ruby gem. """ end + + defp docs do + [ + main: "Gringotts", + logo: "images/lg.png", + source_url: "https://github.com/aviabird/gringotts", + groups_for_modules: groups_for_modules() + ] + end + + defp groups_for_modules do + [ + Gateways: ~r/^Gringotts.Gateways.?/ + ] + end end diff --git a/mix.lock b/mix.lock index 8ac3cafb..83a82360 100644 --- a/mix.lock +++ b/mix.lock @@ -1,17 +1,27 @@ -%{"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, +%{ + "abnf2": {:hex, :abnf2, "0.1.2", "6f8792b8ac3288dba5fc889c2bceae9fe78f74e1a7b36bea9726ffaa9d7bef95", [:mix], [], "hexpm"}, + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, "bypass": {:hex, :bypass, "0.8.1", "16d409e05530ece4a72fabcf021a3e5c7e15dcc77f911423196a0c551f2a15ca", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "certifi": {:hex, :certifi, "2.0.0", "a0c0e475107135f76b8c1d5bc7efb33cd3815cb3cf3dea7aefdd174dabead064", [:rebar3], [], "hexpm"}, + "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm"}, "credo": {:hex, :credo, "0.8.10", "261862bb7363247762e1063713bb85df2bbd84af8d8610d1272cd9c1943bba63", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"}, + "decimal": {:hex, :decimal, "1.4.1", "ad9e501edf7322f122f7fc151cce7c2a0c9ada96f2b0155b8a09a795c2029770", [:mix], [], "hexpm"}, "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, "earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [:mix], [], "hexpm"}, "elixir_xml_to_map": {:hex, :elixir_xml_to_map, "0.1.1", "57e924cd11731947bfd245ce57d0b8dd8b7168bf8edb20cd156a2982ca96fdfa", [:mix], [{:erlsom, "~>1.4", [hex: :erlsom, repo: "hexpm", optional: false]}], "hexpm"}, "erlsom": {:hex, :erlsom, "1.4.1", "53dbacf35adfea6f0714fd0e4a7b0720d495e88c5e24e12c5dc88c7b62bd3e49", [:rebar3], [], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.18.1", "37c69d2ef62f24928c1f4fdc7c724ea04aecfdf500c4329185f8e3649c915baf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, - "excoveralls": {:hex, :excoveralls, "0.8.0", "99d2691d3edf8612f128be3f9869c4d44b91c67cec92186ce49470ae7a7404cf", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "ex_cldr": {:hex, :ex_cldr, "1.4.4", "654966e8724d607e5cf9ecd5509ffcf66868b17e479bbd22ab2e9123595f9103", [:mix], [{:abnf2, "~> 0.1", [hex: :abnf2, repo: "hexpm", optional: false]}, {:decimal, "~> 1.4", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.13", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, "~> 2.1 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm"}, + "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "1.3.1", "50a117654dff8f8ee6958e68a65d0c2835a7e2f1aff94c1ea8f582c04fdf0bd4", [:mix], [{:decimal, "~> 1.4", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 1.4.0", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.1 or ~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, + "ex_doc": {:hex, :ex_doc, "0.18.3", "f4b0e4a2ec6f333dccf761838a4b253d75e11f714b85ae271c9ae361367897b7", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, + "ex_money": {:hex, :ex_money, "1.1.3", "843eed0a5673206de33be47cdc06574401abc3e2d33cbcf6d74e160226791ae4", [:mix], [{:decimal, "~> 1.4", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: true]}, {:ex_cldr, "~> 1.0", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 1.0", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}], "hexpm"}, + "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm"}, + "excoveralls": {:hex, :excoveralls, "0.8.1", "0bbf67f22c7dbf7503981d21a5eef5db8bbc3cb86e70d3798e8c802c74fa5e27", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, - "hackney": {:hex, :hackney, "1.10.1", "c38d0ca52ea80254936a32c45bb7eb414e7a96a521b4ce76d00a69753b157f21", [:rebar3], [{:certifi, "2.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, + "exvcr": {:hex, :exvcr, "0.10.0", "5150808404d9f48dbda636f70f7f8fefd93e2433cd39f695f810e73b3a9d1736", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:httpoison, "~> 0.13", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.0", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, + "gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], [], "hexpm"}, + "hackney": {:hex, :hackney, "1.11.0", "4951ee019df102492dabba66a09e305f61919a8a183a7860236c0fde586134b6", [:rebar3], [{:certifi, "2.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, "httpoison": {:hex, :httpoison, "0.13.0", "bfaf44d9f133a6599886720f3937a7699466d23bb0cd7a88b6ba011f53c6f562", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "idna": {:hex, :idna, "5.1.0", "d72b4effeb324ad5da3cab1767cb16b17939004e789d8c0ad5b70f3cea20c89a", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, "inch_ex": {:hex, :inch_ex, "0.5.6", "418357418a553baa6d04eccd1b44171936817db61f4c0840112b420b8e378e67", [:mix], [{:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, @@ -21,9 +31,12 @@ "mime": {:hex, :mime, "1.2.0", "78adaa84832b3680de06f88f0997e3ead3b451a440d183d688085be2d709b534", [:mix], [], "hexpm"}, "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, "mock": {:hex, :mock, "0.3.1", "994f00150f79a0ea50dc9d86134cd9ebd0d177ad60bd04d1e46336cdfdb98ff9", [:mix], [{:meck, "~> 0.8.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, - "plug": {:hex, :plug, "1.4.3", "236d77ce7bf3e3a2668dc0d32a9b6f1f9b1f05361019946aae49874904be4aed", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, + "plug": {:hex, :plug, "1.5.0", "224b25b4039bedc1eac149fb52ed456770b9678bbf0349cdd810460e1e09195b", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, + "timex": {:hex, :timex, "3.2.1", "639975eac45c4c08c2dbf7fc53033c313ff1f94fad9282af03619a3826493612", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, + "tzdata": {:hex, :tzdata, "0.5.16", "13424d3afc76c68ff607f2df966c0ab4f3258859bbe3c979c9ed1606135e7352", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, - "xml_builder": {:hex, :xml_builder, "0.1.2", "b48ab9ed0a24f43a6061e0c21deda88b966a2121af5c445d4fc550dd822e23dc", [:mix], [], "hexpm"}} + "xml_builder": {:hex, :xml_builder, "2.1.0", "c249d5339427c13cae11e9d9d0e8b40d25d228b9ecc54029f24017385e60280b", [:mix], [], "hexpm"}, +} diff --git a/templates/gateway.eex b/templates/gateway.eex new file mode 100644 index 00000000..6169bae4 --- /dev/null +++ b/templates/gateway.eex @@ -0,0 +1,274 @@ +defmodule Gringotts.Gateways.<%= gateway_module %> do + @moduledoc """ + [<%= gateway %>][home] gateway implementation. + + ## Instructions! + + ***This is an example `moduledoc`, and suggests some items that should be + documented in here.*** + + The quotation boxes like the one below will guide you in writing excellent + documentation for your gateway. All our gateways are documented in this manner + and we aim to keep our docs as consistent with each other as possible. + **Please read them and do as they suggest**. Feel free to add or skip sections + though. + + If you'd like to make edits to the template docs, they exist at + `templates/gateway.eex`. We encourage you to make corrections and open a PR + and tag it with the label `template`. + + ***Actual docs begin below this line!*** + + -------------------------------------------------------------------------------- + + > List features that have been implemented, and what "actions" they map to as + > per the <%= gateway %> gateway docs. + > A table suits really well for this. + + ## Optional or extra parameters + + Most `Gringotts` API calls accept an optional `Keyword` list `opts` to supply + optional arguments for transactions with the gateway. + + > List all available (ie, those that will be supported by this module) keys, a + > description of their function/role and whether they have been implemented + > and tested. + > A table suits really well for this. + + ## Registering your <%= gateway %> account at `Gringotts` + + Explain how to make an account with the gateway and show how to put the + `required_keys` (like authentication info) to the configuration. + <%= if required_config_keys != [] do %> + > Here's how the secrets map to the required configuration parameters for <%= gateway %>: + > + > | Config parameter | <%= gateway %> secret | + > | ------- | ---- | + <%= for key <- required_config_keys do %>> | `<%= inspect(key) %>` | **<%= Macro.camelize("#{key}") %>** | + <% end %><% end %> + > Your Application config<%= if required_config_keys != [] do %> **must include the `<%= inspect(required_config_keys) %>` field(s)** and<% end %> would look + > something like this: + > + > config :gringotts, Gringotts.Gateways.<%= gateway_module %>, + <%= for key <- required_config_keys do %>> <%= "#{key}" %>: "your_secret_<%= "#{key}" %>" + <% end %> + + ## Scope of this module + + > It's unlikely that your first iteration will support all features of the + > gateway, so list down those items that are missing. + + ## Supported currencies and countries + + > It's enough if you just add a link to the gateway's docs or FAQ that provide + > info about this. + + ## Following the examples + + 1. First, set up a sample application and configure it to work with MONEI. + - 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-monei-account-at-<%= + gateway %>). + + 2. Run an `iex` session with `iex -S mix` and add some variable bindings and + aliases to it (to save some time): + ``` + iex> alias Gringotts.{Response, CreditCard, Gateways.<%= gateway_module %>} + iex> card = %CreditCard{first_name: "Jo", + last_name: "Doe", + number: "4200000000000000", + year: 2099, month: 12, + verification_code: "123", brand: "VISA"} + ``` + + > Add any other frequently used bindings up here. + + We'll be using these in the examples below. + + [gs]: https://github.com/aviabird/gringotts/wiki/ + [home]: <%= gateway_url %> + [example]: https://github.com/aviabird/gringotts_example + """ + + # The Base module has the (abstract) public API, and some utility + # implementations. + use Gringotts.Gateways.Base + + # 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: <%= inspect(required_config_keys) %> + + import Poison, only: [decode: 1] + + alias Gringotts.{Money, + CreditCard, + 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. + + > ** You could perhaps:** + > 1. describe what are the important fields in the Response struct + > 2. mention what a merchant can do with these important fields (ex: + > `capture/3`, etc.) + + ## Note + + > If there's anything noteworthy about this operation, it comes here. + + ## Example + + > A barebones example using the bindings you've suggested in the `moduledoc`. + """ + @spec authorize(Money.t(), CreditCard.t(), keyword) :: {:ok | :error, Response} + def authorize(amount, card = %CreditCard{}, opts) do + # commit(args, ...) + end + + @doc """ + Captures a pre-authorized `amount`. + + `amount` is transferred to the merchant account by <%= gateway %> used in the + pre-authorization referenced by `payment_id`. + + ## Note + + > If there's anything noteworthy about this operation, it comes here. + > For example, does the gateway support partial, multiple captures? + + ## Example + + > A barebones example using the bindings you've suggested in the `moduledoc`. + """ + @spec capture(String.t(), Money.t, keyword) :: {:ok | :error, Response} + def capture(payment_id, amount, opts) do + # commit(args, ...) + end + + @doc """ + Transfers `amount` from the customer to the merchant. + + <%= gateway %> attempts to process a purchase on behalf of the customer, by + debiting `amount` from the customer's account by charging the customer's + `card`. + + ## Note + + > If there's anything noteworthy about this operation, it comes here. + + ## Example + + > A barebones example using the bindings you've suggested in the `moduledoc`. + """ + @spec purchase(Money.t, CreditCard.t(), keyword) :: {:ok | :error, Response} + def purchase(amount, card = %CreditCard{}, opts) do + # commit(args, ...) + 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 + + > Which transactions can be voided? + > Is there a limited time window within which a void can be perfomed? + + ## Example + + > A barebones example using the bindings you've suggested in the `moduledoc`. + """ + @spec void(String.t(), keyword) :: {:ok | :error, Response} + def void(payment_id, opts) do + # commit(args, ...) + end + + @doc """ + Refunds the `amount` to the customer's account with reference to a prior transfer. + + > Refunds are allowed on which kinds of "prior" transactions? + + ## Note + + > The end customer will usually see two bookings/records on his statement. Is + > that true for <%= gateway %>? + > Is there a limited time window within which a void can be perfomed? + + ## Example + + > A barebones example using the bindings you've suggested in the `moduledoc`. + """ + @spec refund(Money.t, String.t(), keyword) :: {:ok | :error, Response} + def refund(amount, payment_id, opts) do + # commit(args, ...) + end + + @doc """ + Stores the payment-source data for later use. + + > This usually enable "One Click" and/or "Recurring Payments" + + ## Note + + > If there's anything noteworthy about this operation, it comes here. + + ## Example + + > A barebones example using the bindings you've suggested in the `moduledoc`. + """ + @spec store(CreditCard.t(), keyword) :: {:ok | :error, Response} + def store(%CreditCard{} = card, opts) do + # commit(args, ...) + end + + @doc """ + Removes card or payment info that was previously `store/2`d + + Deletes previously stored payment-source data. + + ## Note + + > If there's anything noteworthy about this operation, it comes here. + + ## Example + + > A barebones example using the bindings you've suggested in the `moduledoc`. + """ + @spec unstore(String.t(), keyword) :: {:ok | :error, Response} + def unstore(registration_id, opts) do + # commit(args, ...) + end + + ############################################################################### + # PRIVATE METHODS # + ############################################################################### + + # Makes the request to <%= gateway %>'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(_) :: {:ok | :error, Response} + defp commit(_) do + # resp = HTTPoison.request(args, ...) + # respond(resp, ...) + end + + # Parses <%= gateway %>'s response and returns a `Gringotts.Response` struct + # in a `:ok`, `:error` tuple. + @spec respond(term) :: {:ok | :error, Response} + defp respond(<%= gateway_underscore %>_response) + defp respond({:ok, %{status_code: 200, body: body}}), do: "something" + defp respond({:ok, %{status_code: status_code, body: body}}), do: "something" + defp respond({:error, %HTTPoison.Error{} = error}), do: "something" +end diff --git a/templates/integration.eex b/templates/integration.eex new file mode 100644 index 00000000..74bc5fc7 --- /dev/null +++ b/templates/integration.eex @@ -0,0 +1,35 @@ +defmodule Gringotts.Integration.Gateways.<%= gateway_module <> "Test"%> do + # Integration tests for the <%= gateway_module%> + + use ExUnit.Case, async: false + alias Gringotts.Gateways.<%= gateway_module%> + + @moduletag :integration + + setup_all do + Application.put_env(:gringotts, Gringotts.Gateways.<%= gateway_module%>, + [ <%= if required_config_keys == [] do %># some_key: "some_secret_key"<% else %><%= for key <- Enum.intersperse(required_config_keys, ",") do %><%= if key === "," do %><%= "#{key}" %><% else %> + <%= "#{key}" %>: "your_secret_<%= "#{key}" %>"<% end %><% end %><% end %> + ] + ) + end + + # Group the test cases by public api + describe "purchase" do + end + + describe "authorize" do + end + + describe "capture" do + end + + describe "void" do + end + + describe "refund" do + end + + describe "environment setup" do + end +end diff --git a/templates/mock_response.eex b/templates/mock_response.eex new file mode 100644 index 00000000..b50bc667 --- /dev/null +++ b/templates/mock_response.eex @@ -0,0 +1,9 @@ +defmodule Gringotts.Gateways.<%= gateway_module <> "Mock"%> do + + # The module should include mock responses for test cases in <%= mock_test_filename %>. + # e.g. + # def successful_purchase do + # {:ok, %HTTPoison.Response{body: ~s[{data: "successful_purchase"}]} + # end + +end diff --git a/templates/test.eex b/templates/test.eex new file mode 100644 index 00000000..93c0b62d --- /dev/null +++ b/templates/test.eex @@ -0,0 +1,32 @@ +defmodule Gringotts.Gateways.<%= gateway_module <> "Test" %> do + # The file contains mocked tests for <%= gateway_module%> + + # We recommend using [mock][1] for this, you can place the mock responses from + # the Gateway in `test/mocks/<%= mock_response_filename %>` file, which has also been + # generated for you. + # + # [1]: https://github.com/jjh42/mock + + # Load the mock response file before running the tests. + Code.require_file "../mocks/<%= mock_response_filename %>", __DIR__ + + use ExUnit.Case, async: false + alias Gringotts.Gateways.<%= gateway_module%> + import Mock + + # Group the test cases by public api + describe "purchase" do + end + + describe "authorize" do + end + + describe "capture" do + end + + describe "void" do + end + + describe "refund" do + end +end diff --git a/test/gateways/authorize_net_test.exs b/test/gateways/authorize_net_test.exs index 664ae5cb..cec0b0cd 100644 --- a/test/gateways/authorize_net_test.exs +++ b/test/gateways/authorize_net_test.exs @@ -1,52 +1,110 @@ defmodule Gringotts.Gateways.AuthorizeNetTest do - - Code.require_file "../mocks/authorize_net_mock.exs", __DIR__ + Code.require_file("../mocks/authorize_net_mock.exs", __DIR__) use ExUnit.Case, async: false alias Gringotts.Gateways.AuthorizeNetMock, as: MockResponse alias Gringotts.CreditCard alias Gringotts.Gateways.AuthorizeNet, as: ANet - + import Mock - @card %CreditCard { + @auth %{name: "64jKa6NA", transaction_key: "4vmE338dQmAN6m7B"} + @card %CreditCard{ number: "5424000000000015", month: 12, - year: 2020, + year: 2099, verification_code: 999 } - @bad_card %CreditCard { + @bad_card %CreditCard{ number: "123", month: 10, year: 2010, verification_code: 123 } - @amount 20 - @bad_amount "a" + @amount Money.new("2.99", :USD) @opts [ - config: %{name: "64jKa6NA", transactionKey: "4vmE338dQmAN6m7B"}, - refId: "123456", - order: %{invoiceNumber: "INV-12345", description: "Product Description"}, - lineItem: %{itemId: "1", name: "vase", description: "Cannes logo", quantity: "18", unitPrice: "45.00"} + 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: Money.mult!(@amount, 18) + }, + tax: %{name: "VAT", amount: Money.new("0.1", :EUR), description: "Value Added Tax"}, + shipping: %{ + name: "SAME-DAY-DELIVERY", + amount: Money.new("0.56", :EUR), + description: "Zen Logistics" + }, + duty: %{ + name: "import_duty", + amount: Money.new("0.25", :EUR), + description: "Upon import of goods" + } ] @opts_refund [ - config: %{name: "64jKa6NA", transaction_key: "4vmE338dQmAN6m7B"}, - ref_id: "123456", - payment: %{card: %{number: "5424000000000015", year: 2020, month: 12}} + 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: %{name: "64jKa6NA", transaction_key: "4vmE338dQmAN6m7B"}, - ref_id: "123456", - payment: %{card: %{number: "123", year: 2020, month: 12}} + config: @auth, + ref_id: "123456", + payment: %{card: %{number: "123", year: 2099, month: 12}} ] @opts_store [ - config: %{name: "64jKa6NA", transaction_key: "4vmE338dQmAN6m7B"}, - profile: %{merchant_customer_id: "123456", description: "Profile description here", email: "customer-profile-email@here.com"} + config: @auth, + profile: %{ + merchant_customer_id: "123456", + description: "Profile description here", + email: "customer-profile-email@here.com" + } ] @opts_store_no_profile [ - config: %{name: "64jKa6NA", transaction_key: "4vmE338dQmAN6m7B"}, + 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" @@ -56,34 +114,26 @@ defmodule Gringotts.Gateways.AuthorizeNetTest do @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) -> MockResponse.successful_purchase_response end] do - {:ok, response} = ANet.purchase(@amount, @card, @opts) - assert response.raw["createTransactionResponse"]["messages"]["resultCode"] == "Ok" - end - end - - test "with bad amount" do - with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers) -> MockResponse.bad_amount_purchase_response end] do - {:error, response} = ANet.purchase(@bad_amount, @card, @opts) - assert response.raw["createTransactionResponse"]["messages"]["resultCode"] == "Error" + post: fn _url, _body, _headers -> + MockResponse.successful_purchase_response() + end do + assert {:ok, _response} = ANet.purchase(@amount, @card, @opts) end end test "with bad card" do - with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers) -> MockResponse.bad_card_purchase_response end] do - {:error, response} = ANet.purchase(@amount, @bad_card, @opts) - assert response.raw["ErrorResponse"]["messages"]["resultCode"] == "Error" - end - end - - test "test network error" do - with_mock HTTPoison, [request: fn(_method, _url, _body, _headers) -> MockResponse.network_error_response end] do - assert {:error, response} = ANet.purchase(@amount, @card, @opts) + with_mock HTTPoison, + post: fn _url, _body, _headers -> + MockResponse.bad_card_purchase_response() + end do + assert {:error, response} = ANet.purchase(@amount, @bad_card, @opts) end end end @@ -91,25 +141,19 @@ defmodule Gringotts.Gateways.AuthorizeNetTest do describe "authorize" do test "successful response with right params" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers) -> MockResponse.successful_authorize_response end] do - {:ok, response} = ANet.authorize(@amount, @card, @opts) - assert response.raw["createTransactionResponse"]["messages"]["resultCode"] == "Ok" - end - end - - test "with bad amount" do - with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers) -> MockResponse.bad_amount_purchase_response end] do - {:error, response} = ANet.authorize(@bad_amount, @card, @opts) - assert response.raw["createTransactionResponse"]["messages"]["resultCode"] == "Error" + post: fn _url, _body, _headers -> + MockResponse.successful_authorize_response() + end do + assert {:ok, _response} = ANet.authorize(@amount, @card, @opts) end end test "with bad card" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers) -> MockResponse.bad_card_purchase_response end] do - {:error, response} = ANet.authorize(@amount, @bad_card, @opts) - assert response.raw["ErrorResponse"]["messages"]["resultCode"] == "Error" + post: fn _url, _body, _headers -> + MockResponse.bad_card_purchase_response() + end do + assert {:error, response} = ANet.authorize(@amount, @bad_card, @opts) end end end @@ -117,17 +161,16 @@ defmodule Gringotts.Gateways.AuthorizeNetTest do describe "capture" do test "successful response with right params" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers) -> MockResponse.successful_capture_response end] do - {:ok, response} = ANet.capture(@capture_id, @amount, @opts) - assert response.raw["createTransactionResponse"]["messages"]["resultCode"] == "Ok" + post: fn _url, _body, _headers -> + MockResponse.successful_capture_response() + end do + assert {:ok, _response} = ANet.capture(@capture_id, @amount, @opts) end end - + test "with bad transaction id" do - with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers) -> MockResponse.bad_id_capture end] do - {:error, response} = ANet.capture(@capture_invalid_id, @amount, @opts) - assert response.raw["createTransactionResponse"]["messages"]["resultCode"] == "Error" + with_mock HTTPoison, post: fn _url, _body, _headers -> MockResponse.bad_id_capture() end do + assert {:error, response} = ANet.capture(@capture_invalid_id, @amount, @opts) end end end @@ -135,43 +178,38 @@ defmodule Gringotts.Gateways.AuthorizeNetTest do describe "refund" do test "successful response with right params" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers) -> MockResponse.successful_refund_response end] do - {:ok, response} = ANet.refund(@amount, @refund_id, @opts_refund) - assert response.raw["createTransactionResponse"]["messages"]["resultCode"] == "Ok" + post: fn _url, _body, _headers -> + MockResponse.successful_refund_response() + end do + assert {:ok, _response} = ANet.refund(@amount, @refund_id, @opts_refund) end end test "bad payment params" do - with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers) -> MockResponse.bad_card_refund end] do - {:error, response} = ANet.refund(@amount, @refund_id, @opts_refund_bad_payment) - assert response.raw["ErrorResponse"]["messages"]["resultCode"] == "Error" + with_mock HTTPoison, post: fn _url, _body, _headers -> MockResponse.bad_card_refund() end do + assert {:error, response} = ANet.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) -> MockResponse.debit_less_than_refund end] do - {:error, response} = ANet.refund(@amount, @refund_id, @opts_refund) - assert response.raw["createTransactionResponse"]["messages"]["resultCode"] == "Error" + post: fn _url, _body, _headers -> MockResponse.debit_less_than_refund() end do + assert {:error, response} = ANet.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) -> MockResponse.successful_void end] do - {:ok, response} = ANet.void(@void_id, @opts) - assert response.raw["createTransactionResponse"]["messages"]["resultCode"] == "Ok" + with_mock HTTPoison, post: fn _url, _body, _headers -> MockResponse.successful_void() end do + assert {:ok, _response} = ANet.void(@void_id, @opts) end end - + test "with bad transaction id" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers) -> MockResponse.void_non_existent_id end] do - {:error, response} = ANet.void(@void_invalid_id, @opts) - assert response.raw["createTransactionResponse"]["messages"]["resultCode"] == "Error" + post: fn _url, _body, _headers -> MockResponse.void_non_existent_id() end do + assert {:error, response} = ANet.void(@void_invalid_id, @opts) end end end @@ -179,29 +217,66 @@ defmodule Gringotts.Gateways.AuthorizeNetTest do describe "store" do test "successful response with right params" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers) -> MockResponse.successful_store_response end] do - {:ok, response} = ANet.store(@card, @opts_store) - assert response.raw["createCustomerProfileResponse"]["messages"]["resultCode"] == "Ok" + post: fn _url, _body, _headers -> MockResponse.successful_store_response() end do + assert {:ok, _response} = ANet.store(@card, @opts_store) + end + end + + test "successful response without validation and customer type" do + with_mock HTTPoison, + post: fn _url, _body, _headers -> MockResponse.successful_store_response() end do + assert {:ok, _response} = ANet.store(@card, @opts_store_without_validation) end end test "without any profile" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers) -> MockResponse.store_without_profile_fields end] do - {:error, response} = ANet.store(@card, @opts_store_no_profile) - assert response.raw["createCustomerProfileResponse"]["messages"]["resultCode"] == "Error" - end + post: fn _url, _body, _headers -> + MockResponse.store_without_profile_fields() + end do + assert {:error, response} = ANet.store(@card, @opts_store_no_profile) + + "Error" + end + end + + test "with customer profile id" do + with_mock HTTPoison, + post: fn _url, _body, _headers -> + MockResponse.customer_payment_profile_success_response() + end do + assert {:ok, _response} = ANet.store(@card, @opts_customer_profile) + + "Ok" + end + end + + test "successful response without valiadtion mode and customer type" do + with_mock HTTPoison, + post: fn _url, _body, _headers -> MockResponse.successful_store_response() end do + assert {:ok, _response} = ANet.store(@card, @opts_customer_profile_args) + end end end describe "unstore" do test "successful response with right params" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers) -> MockResponse.successful_unstore_response end] do - {:ok, response} = ANet.unstore(@unstore_id, @opts) - assert response.raw["deleteCustomerProfileResponse"]["messages"]["resultCode"] == "Ok" + post: fn _url, _body, _headers -> + MockResponse.successful_unstore_response() + end do + assert {:ok, _response} = ANet.unstore(@unstore_id, @opts) end end end + test "network error type non existent domain" do + with_mock HTTPoison, + post: fn _url, _body, _headers -> + MockResponse.netwok_error_non_existent_domain() + end do + assert {:error, response} = ANet.purchase(@amount, @card, @opts) + assert response.message == "HTTPoison says 'nxdomain' [ID: nil]" + end + end end diff --git a/test/gateways/bogus_test.exs b/test/gateways/bogus_test.exs index b7c10ebd..9235bcf8 100644 --- a/test/gateways/bogus_test.exs +++ b/test/gateways/bogus_test.exs @@ -4,50 +4,46 @@ defmodule Gringotts.Gateways.BogusTest do alias Gringotts.Response alias Gringotts.Gateways.Bogus, as: Gateway + @some_id "some_arbitrary_id" + @amount Money.new(5, :USD) + test "authorize" do - {:ok, %Response{authorization: authorization, success: success}} = - Gateway.authorize(10.95, :card, []) + {:ok, %Response{id: id, success: success}} = Gateway.authorize(@amount, :card, []) assert success - assert authorization != nil + assert id != nil end test "purchase" do - {:ok, %Response{authorization: authorization, success: success}} = - Gateway.purchase(10.95, :card, []) + {:ok, %Response{id: id, success: success}} = Gateway.purchase(@amount, :card, []) assert success - assert authorization != nil + assert id != nil end test "capture" do - {:ok, %Response{authorization: authorization, success: success}} = - Gateway.capture(1234, 5, []) + {:ok, %Response{id: id, success: success}} = Gateway.capture(@some_id, @amount, []) assert success - assert authorization != nil + assert id != nil end test "void" do - {:ok, %Response{authorization: authorization, success: success}} = - Gateway.void(1234, []) + {:ok, %Response{id: id, success: success}} = Gateway.void(@some_id, []) assert success - assert authorization != nil + assert id != nil end test "store" do - {:ok, %Response{success: success}} = - Gateway.store(%Gringotts.CreditCard{}, []) + {:ok, %Response{success: success}} = Gateway.store(%Gringotts.CreditCard{}, []) assert success end test "unstore with customer" do - {:ok, %Response{success: success}} = - Gateway.unstore(1234, []) + {:ok, %Response{success: success}} = Gateway.unstore(@some_id, []) assert success end - end diff --git a/test/gateways/cams_test.exs b/test/gateways/cams_test.exs index 85024543..a6117a78 100644 --- a/test/gateways/cams_test.exs +++ b/test/gateways/cams_test.exs @@ -1,190 +1,187 @@ defmodule Gringotts.Gateways.CamsTest do - - Code.require_file "../mocks/cams_mock.exs", __DIR__ + Code.require_file("../mocks/cams_mock.exs", __DIR__) use ExUnit.Case, async: false + alias Gringotts.{ - CreditCard, Response + CreditCard, + Response } + alias Gringotts.Gateways.CamsMock, as: MockResponse alias Gringotts.Gateways.Cams, as: Gateway import Mock - @payment %CreditCard{ + @card %CreditCard{ number: "4111111111111111", - month: 9, - year: 2018, - first_name: "Gopal", - last_name: "Shimpi", - verification_code: "123", - brand: "visa" + month: 11, + year: 2099, + first_name: "Harry", + last_name: "Potter", + verification_code: "999", + brand: "VISA" } - @bad_payment %CreditCard { - number: "411111111111111", - month: 9, - year: 2018, - first_name: "Gopal", - last_name: "Shimpi", - verification_code: "123", - brand: "visa" + @bad_card %CreditCard{ + number: "42", + month: 11, + year: 2099, + first_name: "Harry", + last_name: "Potter", + verification_code: "999", + brand: "VISA" } @address %{ - name: "Jim Smith", - address1: "456 My Street", - address2: "Apt 1", - company: "Widgets Inc", - city: "Ottawa", - state: "ON", - zip: "K1C2N6", - country: "US", - phone: "(555)555-5555", - fax: "(555)555-6666" + street1: "301, Gryffindor", + street2: "Hogwarts School of Witchcraft and Wizardry, Hogwarts Castle", + city: "Highlands", + state: "Scotland", + country: "GB", + company: "Ollivanders", + zip: "K1C2N6", + phone: "(555)555-5555", + fax: "(555)555-6666" } + @auth %{username: "some_secret_user_name", password: "some_secret_password"} @options [ - config: %{ - username: "testintegrationc", - password: "password9" - }, - order_id: 0001, + order_id: 1, billing_address: @address, - description: "Store Purchase" + description: "Store Purchase", + config: @auth ] - @money 100 - @bad_money "G" - @authorization "3921111362" - @bad_authorization "300000000" + @money Money.new(:USD, 100) + @money_more Money.new(:USD, 101) + @money_less Money.new(:USD, 99) + @bad_currency Money.new(:INR, 100) - describe "purchase" do + @id "some_transaction_id" + @bad_id "some_fake_transaction_id" - test "with all good" do + describe "purchase" do + test "with correct params" do with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.successful_purchase end] do - {:ok, %Response{success: result}} = Gateway.purchase(@money, @payment, @options) - assert result + post: fn _url, _body, _headers -> MockResponse.successful_purchase() end do + assert {:ok, %Response{}} = Gateway.purchase(@money, @card, @options) end end test "with bad card" do with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.failed_purchase_with_bad_credit_card end] do - {:ok, %Response{message: result}} = Gateway.purchase(@money, @bad_payment, @options) - assert String.contains?(result, "Invalid Credit Card Number") - end - end + post: fn _url, _body, _headers -> MockResponse.failed_purchase_with_bad_credit_card() end do + {:error, %Response{reason: reason}} = Gateway.purchase(@money, @bad_card, @options) - test "with bad amount" do - with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.failed_purchase_with_bad_money end] do - {:ok, %Response{message: result}} = Gateway.purchase(@bad_money, @payment, @options) - assert String.contains?(result, "Invalid amount") + assert String.contains?(reason, "Invalid Credit Card Number") end end test "with invalid currency" do with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.with_invalid_currency end] do - {:ok, %Response{message: result}} = Gateway.purchase(@money, @payment, @options) - assert String.contains?(result, "The cc payment type") + post: fn _url, _body, _headers -> MockResponse.with_invalid_currency() end do + {:error, %Response{reason: reason}} = Gateway.purchase(@bad_currency, @card, @options) + assert String.contains?(reason, "The cc payment type") end end end describe "authorize" do - test "with all good" do + test "with correct params" do with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.successful_authorize end] do - {:ok, %Response{success: result}} = Gateway.authorize(@money, @payment, @options) - assert result + post: fn _url, _body, _headers -> MockResponse.successful_authorize() end do + assert {:ok, %Response{}} = Gateway.authorize(@money, @card, @options) end end test "with bad card" do with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.failed_authorized_with_bad_card end] do - {:ok, %Response{message: result}} = Gateway.authorize(@money, @bad_payment, @options) - assert String.contains?(result, "Invalid Credit Card Number") + post: fn _url, _body, _headers -> MockResponse.failed_authorized_with_bad_card() end do + {:error, %Response{reason: reason}} = Gateway.authorize(@money, @bad_card, @options) + + assert String.contains?(reason, "Invalid Credit Card Number") end end end describe "capture" do test "with full amount" do - with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.successful_capture end] do - {:ok, %Response{success: result}} = Gateway.capture(@money, @authorization, @options) - assert result + with_mock HTTPoison, post: fn _url, _body, _headers -> MockResponse.successful_capture() end do + assert {:ok, %Response{}} = Gateway.capture(@money, @id, @options) end end test "with partial amount" do - with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.successful_capture end] do - {:ok, %Response{success: result}} = Gateway.capture(@money - 1, @authorization, @options) - assert result + with_mock HTTPoison, post: fn _url, _body, _headers -> MockResponse.successful_capture() end do + assert {:ok, %Response{}} = Gateway.capture(@money_less, @id, @options) end end test "with invalid transaction_id" do with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.invalid_transaction_id end] do - {:ok, %Response{message: result}} = Gateway.capture(@money, @bad_authorization, @options) - assert String.contains?(result, "Transaction not found") + post: fn _url, _body, _headers -> MockResponse.invalid_transaction_id() end do + {:error, %Response{reason: reason}} = Gateway.capture(@money, @bad_id, @options) + + assert String.contains?(reason, "Transaction not found") end end - test "with more than authorization amount" do + test "with more than authorized amount" do with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.more_than_authorization_amount end] do - {:ok, %Response{message: result}} = Gateway.capture(@money + 1, @authorization, @options) - assert String.contains?(result, "exceeds the authorization amount") + post: fn _url, _body, _headers -> MockResponse.more_than_authorization_amount() end do + {:error, %Response{reason: reason}} = Gateway.capture(@money_more, @id, @options) + + assert String.contains?(reason, "exceeds the authorization amount") end end test "on already captured transaction" do with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.multiple_capture_on_same_transaction end] do - {:ok, %Response{message: result}} = Gateway.capture(@money, @authorization, @options) - assert String.contains?(result, "A capture requires that") + post: fn _url, _body, _headers -> MockResponse.multiple_capture_on_same_transaction() end do + {:error, %Response{reason: reason}} = Gateway.capture(@money, @id, @options) + + assert String.contains?(reason, "A capture requires that") end end - end describe "refund" do - test "with all good" do - with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.successful_refund end] do - {:ok, %Response{success: result}} = Gateway.refund(@money, @authorization, @options) - assert result + test "with correct params" do + with_mock HTTPoison, post: fn _url, _body, _headers -> MockResponse.successful_refund() end do + assert {:ok, %Response{}} = Gateway.refund(@money, @id, @options) end end test "with more than purchased amount" do with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.more_than_purchase_amount end] do - {:ok, %Response{message: result}} = Gateway.refund(@money + 1, @authorization, @options) - assert String.contains?(result, "Refund amount may not exceed") + post: fn _url, _body, _headers -> MockResponse.more_than_purchase_amount() end do + {:error, %Response{reason: reason}} = Gateway.refund(@money_more, @id, @options) + + assert String.contains?(reason, "Refund amount may not exceed") end end end - - describe "void" do - test "with all good" do - with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.successful_void end] do - {:ok, %Response{message: result}} = Gateway.void(@authorization, @options) - assert String.contains?(result, "Void Successful") + + describe "void" do + test "with correct params" do + with_mock HTTPoison, post: fn _url, _body, _headers -> MockResponse.successful_void() end do + {:ok, %Response{message: message}} = Gateway.void(@id, @options) + assert String.contains?(message, "Void Successful") end end test "with invalid transaction_id" do with_mock HTTPoison, - [post: fn(_url, _body, _headers) -> MockResponse.invalid_transaction_id end] do - {:ok, %Response{message: result}} = Gateway.void(@bad_authorization, @options) - assert String.contains?(result, "Transaction not found") + post: fn _url, _body, _headers -> MockResponse.invalid_transaction_id() end do + {:error, %Response{reason: reason}} = Gateway.void(@bad_id, @options) + assert String.contains?(reason, "Transaction not found") + end + end + end + + describe "validate" do + test "with correct params" do + with_mock HTTPoison, + post: fn _url, _body, _headers -> MockResponse.validate_creditcard() end do + assert {:ok, %Response{}} = Gateway.validate(@card, @options ++ [config: @auth]) end end end - end diff --git a/test/gateways/global_collect_test.exs b/test/gateways/global_collect_test.exs new file mode 100644 index 00000000..8d09449c --- /dev/null +++ b/test/gateways/global_collect_test.exs @@ -0,0 +1,227 @@ +defmodule Gringotts.Gateways.GlobalCollectTest do + Code.require_file("../mocks/global_collect_mock.exs", __DIR__) + use ExUnit.Case, async: false + alias Gringotts.Gateways.GlobalCollectMock, as: MockResponse + alias Gringotts.Gateways.GlobalCollect + + alias Gringotts.{ + CreditCard + } + + import Mock + + @amount Money.new("500", :USD) + + @bad_amount Money.new("50.3", :USD) + + @shipping_address %{ + street: "Desertroad", + houseNumber: "1", + additionalInfo: "Suite II", + zip: "84536", + city: "Monument Valley", + state: "Utah", + countryCode: "US" + } + + @valid_card %CreditCard{ + number: "4567350000427977", + month: 12, + year: 18, + first_name: "John", + last_name: "Doe", + verification_code: "123", + brand: "VISA" + } + + @invalid_card %CreditCard{ + number: "4567350000427977", + month: 12, + year: 10, + first_name: "John", + last_name: "Doe", + verification_code: "123", + brand: "VISA" + } + + @billing_address %{ + street: "Desertroad", + houseNumber: "13", + additionalInfo: "b", + zip: "84536", + city: "Monument Valley", + state: "Utah", + countryCode: "US" + } + + @invoice %{ + invoiceNumber: "000000123", + invoiceDate: "20140306191500" + } + + @name %{ + title: "Miss", + firstName: "Road", + surname: "Runner" + } + + @valid_token "charge_cb17a0c34e870a479dfa13bd873e7ce7e090ec9b" + + @invalid_token 30 + + @invalid_config [ + config: %{ + secret_api_key: "some_secret_api_key", + api_key_id: "some_api_key_id" + } + ] + + @options [ + config: %{ + secret_api_key: "some_secret_api_key", + api_key_id: "some_api_key_id", + merchant_id: "some_merchant_id" + }, + description: "Store Purchase 1437598192", + merchantCustomerId: "234", + customer_name: "John Doe", + dob: "19490917", + company: "asma", + email: "johndoe@gmail.com", + phone: "7468474533", + order_id: "2323", + invoice: @invoice, + billingAddress: @billing_address, + shippingAddress: @shipping_address, + name: @name, + skipAuthentication: "true" + ] + + describe "purchase" do + test "with valid card" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers -> + MockResponse.test_for_purchase_with_valid_card() + end do + {:ok, response} = GlobalCollect.purchase(@amount, @valid_card, @options) + assert response.status_code == 201 + assert response.success == true + assert response.raw["payment"]["statusOutput"]["isAuthorized"] == true + end + end + + test "with invalid amount" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers -> + MockResponse.test_for_purchase_with_invalid_amount() + end do + {:error, response} = GlobalCollect.purchase(@bad_amount, @valid_card, @options) + assert response.status_code == 400 + assert response.success == false + assert response.message == "INVALID_VALUE: '50.3' is not a valid value for field 'amount'" + end + end + end + + describe "authorize" do + test "with valid card" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers -> + MockResponse.test_for_authorize_with_valid_card() + end do + {:ok, response} = GlobalCollect.authorize(@amount, @valid_card, @options) + assert response.status_code == 201 + assert response.success == true + assert response.raw["payment"]["statusOutput"]["isAuthorized"] == true + end + end + + test "with invalid card" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers -> + MockResponse.test_for_authorize_with_invalid_card() + end do + {:error, response} = GlobalCollect.authorize(@amount, @invalid_card, @options) + assert response.status_code == 400 + assert response.success == false + + assert response.message == + "cardPaymentMethodSpecificInput.card.expiryDate (1210) IS IN THE PAST OR NOT IN CORRECT MMYY FORMAT" + end + end + + test "with invalid amount" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers -> + MockResponse.test_for_authorize_with_invalid_amount() + end do + {:error, response} = GlobalCollect.authorize(@bad_amount, @valid_card, @options) + assert response.status_code == 400 + assert response.success == false + assert response.message == "INVALID_VALUE: '50.3' is not a valid value for field 'amount'" + end + end + end + + describe "refund" do + test "with refund not enabled for the respective account" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers -> MockResponse.test_for_refund() end do + {:error, response} = GlobalCollect.refund(@amount, @valid_token, @options) + assert response.status_code == 400 + assert response.success == false + assert response.message == "ORDER WITHOUT REFUNDABLE PAYMENTS" + end + end + end + + describe "capture" do + test "with valid payment id" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers -> + MockResponse.test_for_capture_with_valid_paymentid() + end do + {:ok, response} = GlobalCollect.capture(@valid_token, @amount, @options) + assert response.status_code == 200 + assert response.success == true + assert response.raw["payment"]["status"] == "CAPTURE_REQUESTED" + end + end + + test "with invalid payment id" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers -> + MockResponse.test_for_capture_with_invalid_paymentid() + end do + {:error, response} = GlobalCollect.capture(@invalid_token, @amount, @options) + assert response.status_code == 404 + assert response.success == false + assert response.message == "UNKNOWN_PAYMENT_ID" + end + end + end + + describe "void" do + test "with valid card" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers -> + MockResponse.test_for_void_with_valid_card() + end do + {:ok, response} = GlobalCollect.void(@valid_token, @options) + assert response.status_code == 200 + assert response.raw["payment"]["status"] == "CANCELLED" + end + end + end + + describe "network failure" do + test "with authorization" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers -> MockResponse.test_for_network_failure() end do + {:error, response} = GlobalCollect.authorize(@amount, @valid_card, @options) + assert response.success == false + assert response.reason == :network_fail? + end + end + end +end diff --git a/test/gateways/mercadopago_test.exs b/test/gateways/mercadopago_test.exs new file mode 100644 index 00000000..74e9c0e4 --- /dev/null +++ b/test/gateways/mercadopago_test.exs @@ -0,0 +1,32 @@ +defmodule Gringotts.Gateways.MercadopagoTest do + # The file contains mocked tests for Mercadopago + + # We recommend using [mock][1] for this, you can place the mock responses from + # the Gateway in `test/mocks/mercadopago_mock.exs` file, which has also been + # generated for you. + # + # [1]: https://github.com/jjh42/mock + + # Load the mock response file before running the tests. + Code.require_file("../mocks/mercadopago_mock.exs", __DIR__) + + use ExUnit.Case, async: false + alias Gringotts.Gateways.Mercadopago + import Mock + + # Group the test cases by public api + describe "purchase" do + end + + describe "authorize" do + end + + describe "capture" do + end + + describe "void" do + end + + describe "refund" do + end +end diff --git a/test/gateways/monei_test.exs b/test/gateways/monei_test.exs index 85b35125..e641d91b 100644 --- a/test/gateways/monei_test.exs +++ b/test/gateways/monei_test.exs @@ -1,14 +1,20 @@ defmodule Gringotts.Gateways.MoneiTest do - use ExUnit.Case, async: false + use ExUnit.Case, async: true alias Gringotts.{ - CreditCard, + CreditCard } + alias Gringotts.Gateways.Monei, as: Gateway + alias Plug.{Conn, Parsers} + + @amount42 Money.new(42, :USD) + @amount3 Money.new(3, :USD) + @bad_currency Money.new(42, :INR) @card %CreditCard{ - first_name: "Jo", - last_name: "Doe", + first_name: "Harry", + last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, @@ -17,15 +23,57 @@ defmodule Gringotts.Gateways.MoneiTest do } @bad_card %CreditCard{ - first_name: "Jo", - last_name: "Doe", + first_name: "Harry", + last_name: "Potter", number: "4200000000000000", year: 2000, month: 12, - verification_code: "123", + verification_code: "123", brand: "VISA" } + @customer %{ + givenName: "Harry", + surname: "Potter", + merchantCustomerId: "the_boy_who_lived", + sex: "M", + birthDate: "1980-07-31", + mobile: "+15252525252", + email: "masterofdeath@ministryofmagic.gov", + ip: "127.0.0.1", + status: "NEW" + } + @merchant %{ + name: "Ollivanders", + city: "South Side", + street: "Diagon Alley", + state: "London", + country: "GB", + submerchantId: "Makers of Fine Wands since 382 B.C." + } + @billing %{ + street1: "301, Gryffindor", + street2: "Hogwarts School of Witchcraft and Wizardry, Hogwarts Castle", + city: "Highlands", + state: "Scotland", + country: "GB" + } + @shipping Map.merge( + %{method: "SAME_DAY_SERVICE", comment: "For our valued customer, Mr. Potter"}, + @billing + ) + + @extra_opts [ + customer: @customer, + merchant: @merchant, + billing: @billing, + shipping: @shipping, + shipping_customer: @customer, + category: "EC", + register: true, + custom: %{"voldemort" => "he who must not be named"} + ] + @auth_success ~s[ {"id": "8a82944a603b12d001603c1a1c2d5d90", "result": { @@ -33,6 +81,14 @@ defmodule Gringotts.Gateways.MoneiTest do "description": "Request successfully processed in 'Merchant in Integrator Test Mode'"} }] + @register_success ~s[ + {"id": "8a82944960e073640160e92da2204743", + "registrationId": "8a82944a60e09c550160e92da144491e", + "result": { + "code": "000.100.110", + "description": "Request successfully processed in 'Merchant in Integrator Test Mode'"} + }] + @store_success ~s[ {"result":{ "code":"000.100.110", @@ -41,7 +97,7 @@ defmodule Gringotts.Gateways.MoneiTest do "card":{ "bin":"420000", "last4Digits":"0000", - "holder":"Jo Doe", + "holder":"Harry Potter", "expiryMonth":"12", "expiryYear":"2099" } @@ -49,84 +105,145 @@ defmodule Gringotts.Gateways.MoneiTest do # A new Bypass instance is needed per test, so that we can do parallel tests setup do - bypass = Bypass.open + bypass = Bypass.open() + auth = %{ - userId: "8a829417539edb400153c1eae83932ac", - password: "6XqRtMGS2N", - entityId: "8a829417539edb400153c1eae6de325e", + userId: "some_secret_user_id", + password: "some_secret_password", + entityId: "some_secret_entity_id", test_url: "http://localhost:#{bypass.port}" } + {:ok, bypass: bypass, auth: auth} end describe "core" do - @tag :skip - test "with unsupported currency.", - %{bypass: bypass, auth: auth} do - Bypass.expect_once bypass, "POST", "/v1/payments", fn conn -> - Plug.Conn.resp(conn, 400, "") - end - {:error, response} = Gateway.authorize(52, @card, [config: auth, - currency: "INR"]) - assert response.code == :unsupported_currency + test "with unsupported currency.", %{auth: auth} do + {:error, response} = Gateway.authorize(@bad_currency, @card, config: auth) + assert response.reason == "Invalid currency" end - test "when MONEI is down or unreachable.", - %{bypass: bypass, auth: auth} do - Bypass.expect_once bypass, fn conn -> - Plug.Conn.resp(conn, 200, @auth_success) - end - Bypass.down bypass - {:error, response} = Gateway.authorize(52.00, @card, [config: auth]) - assert response.reason == :network_fail? - - Bypass.up bypass - {:ok, _} = Gateway.authorize(52.00, @card, [config: auth]) + test "when MONEI is down or unreachable.", %{bypass: bypass, auth: auth} do + Bypass.down(bypass) + {:error, response} = Gateway.authorize(@amount42, @card, config: auth) + assert response.reason == "network related failure" + Bypass.up(bypass) end - end - describe "authorize" do - test "when all is good.", %{bypass: bypass, auth: auth} do - Bypass.expect bypass, "POST", "/v1/payments", fn conn -> - Plug.Conn.resp(conn, 200, @auth_success) - end - {:ok, response} = Gateway.authorize(52.00, @card, [config: auth]) - assert response.code == "000.100.110" + test "that all auth info is picked.", %{bypass: bypass, auth: auth} do + Bypass.expect_once(bypass, "POST", "/v1/payments", fn conn -> + p_conn = parse(conn) + params = p_conn.body_params + assert params["authentication.entityId"] == "some_secret_entity_id" + assert params["authentication.password"] == "some_secret_password" + assert params["authentication.userId"] == "some_secret_user_id" + Conn.resp(conn, 200, @auth_success) + end) + + {:ok, response} = Gateway.purchase(@amount42, @card, config: auth) + assert response.gateway_code == "000.100.110" + end + + test "with all extra_params.", %{bypass: bypass, auth: auth} do + randoms = [ + invoice_id: Base.encode16(:crypto.hash(:md5, :crypto.strong_rand_bytes(32))), + transaction_id: Base.encode16(:crypto.hash(:md5, :crypto.strong_rand_bytes(32))) + ] + + Bypass.expect_once(bypass, "POST", "/v1/payments", fn conn -> + p_conn = parse(conn) + params = p_conn.body_params + assert params["createRegistration"] == "true" + assert params["customParameters"] == @extra_opts[:custom] + assert params["merchantInvoiceId"] == randoms[:invoice_id] + assert params["merchantTransactionId"] == randoms[:transaction_id] + assert params["transactionCategory"] == @extra_opts[:category] + assert params["customer.merchantCustomerId"] == @customer[:merchantCustomerId] + + assert params["shipping.customer.merchantCustomerId"] == @customer[:merchantCustomerId] + + assert params["merchant.submerchantId"] == @merchant[:submerchantId] + assert params["billing.city"] == @billing[:city] + assert params["shipping.method"] == @shipping[:method] + Conn.resp(conn, 200, @register_success) + end) + + opts = randoms ++ @extra_opts ++ [config: auth] + {:ok, response} = Gateway.purchase(@amount42, @card, opts) + assert response.gateway_code == "000.100.110" + assert response.token == "8a82944a60e09c550160e92da144491e" end test "when we get non-json.", %{bypass: bypass, auth: auth} do - Bypass.expect_once bypass, "POST", "/v1/payments", fn conn -> - Plug.Conn.resp(conn, 400, "") - end - {:error, _} = Gateway.authorize(52.00, @card, [config: auth]) + Bypass.expect_once(bypass, "POST", "/v1/payments", fn conn -> + Conn.resp(conn, 400, "") + end) + + {:error, _} = Gateway.authorize(@amount42, @bad_card, config: auth) end + end - test "when card has expired.", %{bypass: bypass, auth: auth} do - Bypass.expect_once bypass, "POST", "/v1/payments", fn conn -> - Plug.Conn.resp(conn, 400, "") - end - {:error, _response} = Gateway.authorize(52, @bad_card, [config: auth]) + describe "authorize" do + test "when all is good.", %{bypass: bypass, auth: auth} do + Bypass.expect(bypass, "POST", "/v1/payments", fn conn -> + p_conn = parse(conn) + params = p_conn.body_params + assert params["amount"] == "42.00" + assert params["currency"] == "USD" + assert params["paymentType"] == "PA" + Conn.resp(conn, 200, @auth_success) + end) + + {:ok, response} = Gateway.authorize(@amount42, @card, config: auth) + assert response.gateway_code == "000.100.110" end end describe "purchase" do test "when all is good.", %{bypass: bypass, auth: auth} do - Bypass.expect_once bypass, "POST", "/v1/payments", fn conn -> - Plug.Conn.resp(conn, 200, @auth_success) - end - {:ok, response} = Gateway.purchase(15, @card, [config: auth]) - assert response.code == "000.100.110" + Bypass.expect_once(bypass, "POST", "/v1/payments", fn conn -> + p_conn = parse(conn) + params = p_conn.body_params + assert params["amount"] == "42.00" + assert params["currency"] == "USD" + assert params["paymentType"] == "DB" + Conn.resp(conn, 200, @auth_success) + end) + + {:ok, response} = Gateway.purchase(@amount42, @card, config: auth) + assert response.gateway_code == "000.100.110" + end + + test "with createRegistration.", %{bypass: bypass, auth: auth} do + Bypass.expect_once(bypass, "POST", "/v1/payments", fn conn -> + p_conn = parse(conn) + params = p_conn.body_params + assert params["createRegistration"] == "true" + Conn.resp(conn, 200, @register_success) + end) + + {:ok, response} = Gateway.purchase(@amount42, @card, register: true, config: auth) + assert response.gateway_code == "000.100.110" + assert response.token == "8a82944a60e09c550160e92da144491e" end end describe "store" do test "when all is good.", %{bypass: bypass, auth: auth} do - Bypass.expect_once bypass, "POST", "/v1/registrations", fn conn -> - Plug.Conn.resp(conn, 200, @store_success) - end - {:ok, response} = Gateway.store(@card, [config: auth]) - assert response.code == "000.100.110" - assert response.raw["card"]["holder"] == "Jo Doe" + Bypass.expect_once(bypass, "POST", "/v1/registrations", fn conn -> + p_conn = parse(conn) + params = p_conn.body_params + assert params["card.cvv"] == "123" + assert params["card.expiryMonth"] == "12" + assert params["card.expiryYear"] == "2099" + assert params["card.holder"] == "Harry Potter" + assert params["card.number"] == "4200000000000000" + assert params["paymentBrand"] == "VISA" + Conn.resp(conn, 200, @store_success) + end) + + {:ok, response} = Gateway.store(@card, config: auth) + assert response.gateway_code == "000.100.110" end end @@ -135,12 +252,45 @@ defmodule Gringotts.Gateways.MoneiTest do Bypass.expect_once( bypass, "POST", - "/v1/payments/7214344252e11af79c0b9e7b4f3f6234", + "/v1/payments/7214344242e11af79c0b9e7b4f3f6234", fn conn -> - Plug.Conn.resp(conn, 200, @auth_success) - end) - {:ok, response} = Gateway.capture(4000, "7214344252e11af79c0b9e7b4f3f6234", [config: auth]) - assert response.code == "000.100.110" + p_conn = parse(conn) + params = p_conn.body_params + assert params["amount"] == "42.00" + assert params["currency"] == "USD" + assert params["paymentType"] == "CP" + Conn.resp(conn, 200, @auth_success) + end + ) + + {:ok, response} = + Gateway.capture("7214344242e11af79c0b9e7b4f3f6234", @amount42, config: auth) + + assert response.gateway_code == "000.100.110" + end + + test "with createRegistration that is ignored", %{bypass: bypass, auth: auth} do + Bypass.expect_once( + bypass, + "POST", + "/v1/payments/7214344242e11af79c0b9e7b4f3f6234", + fn conn -> + p_conn = parse(conn) + params = p_conn.body_params + assert :error == Map.fetch(params, "createRegistration") + Conn.resp(conn, 200, @auth_success) + end + ) + + {:ok, response} = + Gateway.capture( + "7214344242e11af79c0b9e7b4f3f6234", + @amount42, + register: true, + config: auth + ) + + assert response.gateway_code == "000.100.110" end end @@ -149,26 +299,41 @@ defmodule Gringotts.Gateways.MoneiTest do Bypass.expect_once( bypass, "POST", - "/v1/payments/7214344252e11af79c0b9e7b4f3f6234", + "/v1/payments/7214344242e11af79c0b9e7b4f3f6234", fn conn -> - Plug.Conn.resp(conn, 200, @auth_success) - end) - {:ok, response} = Gateway.refund(3, "7214344252e11af79c0b9e7b4f3f6234", [config: auth]) - assert response.code == "000.100.110" + p_conn = parse(conn) + params = p_conn.body_params + assert params["amount"] == "3.00" + assert params["currency"] == "USD" + assert params["paymentType"] == "RF" + Conn.resp(conn, 200, @auth_success) + end + ) + + {:ok, response} = Gateway.refund(@amount3, "7214344242e11af79c0b9e7b4f3f6234", config: auth) + + assert response.gateway_code == "000.100.110" end end - + describe "unstore" do test "when all is good.", %{bypass: bypass, auth: auth} do Bypass.expect_once( bypass, "DELETE", - "/v1/registrations/7214344252e11af79c0b9e7b4f3f6234", + "/v1/registrations/7214344242e11af79c0b9e7b4f3f6234", fn conn -> - Plug.Conn.resp(conn, 200, "") - end) - {:error, response} = Gateway.unstore("7214344252e11af79c0b9e7b4f3f6234", [config: auth]) - assert response.code == :undefined_response_from_monei + p_conn = parse(conn) + params = p_conn.query_params + assert params["authentication.entityId"] == "some_secret_entity_id" + assert params["authentication.password"] == "some_secret_password" + assert params["authentication.userId"] == "some_secret_user_id" + Conn.resp(conn, 200, "") + end + ) + + {:error, response} = Gateway.unstore("7214344242e11af79c0b9e7b4f3f6234", config: auth) + assert response.reason == "undefined response from monei" end end @@ -177,24 +342,25 @@ defmodule Gringotts.Gateways.MoneiTest do Bypass.expect_once( bypass, "POST", - "/v1/payments/7214344252e11af79c0b9e7b4f3f6234", + "/v1/payments/7214344242e11af79c0b9e7b4f3f6234", fn conn -> - Plug.Conn.resp(conn, 200, @auth_success) - end) - {:ok, response} = Gateway.void("7214344252e11af79c0b9e7b4f3f6234", [config: auth]) - assert response.code == "000.100.110" + p_conn = parse(conn) + params = p_conn.body_params + assert :error == Map.fetch(params, :amount) + assert :error == Map.fetch(params, :currency) + assert params["paymentType"] == "RV" + Conn.resp(conn, 200, @auth_success) + end + ) + + {:ok, response} = Gateway.void("7214344242e11af79c0b9e7b4f3f6234", config: auth) + assert response.gateway_code == "000.100.110" end end - @tag :skip - test "respond various scenarios, can't test a private function." do - json_200 = %HTTPoison.Response{body: @auth_success, status_code: 200} - json_not_200 = %HTTPoison.Response{body: @auth_success, status_code: 300} - html_200 = %HTTPoison.Response{body: ~s[\n], status_code: 200} - html_not_200 = %HTTPoison.Response{body: ~s[ - Trexle.validate_config(@invalid_opts) - end - end - end - describe "purchase" do test "with valid card" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers, _options) -> MockResponse.test_for_purchase_with_valid_card end] do - {:ok, response} = Trexle.purchase(@amount, @valid_card, @opts) - assert response.status_code == 201 - assert response.raw["response"]["success"] == true - assert response.raw["response"]["captured"] == false + request: fn _method, _url, _body, _headers, _options -> + MockResponse.test_for_purchase_with_valid_card() + end do + assert {:ok, response} = Trexle.purchase(@amount, @valid_card, @opts) end end test "with invalid card" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers, _options) -> MockResponse.test_for_purchase_with_invalid_card end] do - {:error, response} = Trexle.purchase(@amount, @invalid_card, @opts) - assert response.status_code == 400 - assert response.success == false - assert response.raw == ~s({"error":"Payment failed","detail":"Your card's expiration year is invalid."}) + request: fn _method, _url, _body, _headers, _options -> + MockResponse.test_for_purchase_with_invalid_card() + end do + assert {:error, response} = Trexle.purchase(@amount, @invalid_card, @opts) + assert response.reason == "Your card's expiration year is invalid." end end test "with invalid amount" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers, _options) -> MockResponse.test_for_purchase_with_invalid_amount end] do - {:error, response} = Trexle.purchase(@bad_amount, @valid_card, @opts) + request: fn _method, _url, _body, _headers, _options -> + MockResponse.test_for_purchase_with_invalid_amount() + end do + assert {:error, response} = Trexle.purchase(@bad_amount, @valid_card, @opts) assert response.status_code == 400 - assert response.success == false - assert response.raw == ~s({"error":"Payment failed","detail":"Amount must be at least 50 cents"}) + assert response.reason == "Amount must be at least 50 cents" end end end @@ -102,41 +93,11 @@ defmodule Gringotts.Gateways.TrexleTest do describe "authorize" do test "with valid card" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers, _options) -> MockResponse.test_for_authorize_with_valid_card end] do - {:ok, response} = Trexle.authorize(@amount, @invalid_card, @opts) + request: fn _method, _url, _body, _headers, _options -> + MockResponse.test_for_authorize_with_valid_card() + end do + assert {:ok, response} = Trexle.authorize(@amount, @valid_card, @opts) assert response.status_code == 201 - assert response.raw["response"]["success"] == true - assert response.raw["response"]["captured"] == false - end - end - - test "with invalid card" do - with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers, _options) -> MockResponse.test_for_authorize_with_invalid_card end] do - {:error, response} = Trexle.authorize(@amount, @invalid_card, @opts) - assert response.status_code == 400 - assert response.success == false - assert response.raw == ~s({"error":"Payment failed","detail":"Your card's expiration year is invalid."}) - end - end - - test "with invalid amount" do - with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers, _options) -> MockResponse.test_for_authorize_with_invalid_amount end] do - {:error, response} = Trexle.authorize(@amount, @valid_card, @opts) - assert response.status_code == 400 - assert response.success == false - assert response.raw == ~s({"error":"Payment failed","detail":"Amount must be at least 50 cents"}) - end - end - - test "with missing ip address" do - with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers, _options) -> MockResponse.test_for_authorize_with_missing_ip_address end] do - {:error, response} = Trexle.authorize(@amount, @valid_card, @missingip_opts) - assert response.status_code == 500 - assert response.success == false - assert response.raw == ~s({"error":"ip_address is missing"}) end end end @@ -144,55 +105,62 @@ defmodule Gringotts.Gateways.TrexleTest do describe "refund" do test "with valid token" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers, _options) -> MockResponse.test_for_authorize_with_valid_card end] do - {:ok, response} = Trexle.refund(@amount, @valid_token, @opts) + request: fn _method, _url, _body, _headers, _options -> + MockResponse.test_for_authorize_with_valid_card() + end do + assert {:ok, response} = Trexle.refund(@amount, @valid_token, @opts) assert response.status_code == 201 - assert response.raw["response"]["success"] == true - assert response.raw["response"]["captured"] == false - end - end - - test "with invalid token" do - with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers, _options) -> MockResponse.test_for_authorize_with_invalid_amount end] do - {:error, response} = Trexle.refund(@amount, @invalid_token, @opts) - assert response.status_code == 400 - assert response.success == false - assert response.raw == ~s({"error":"Payment failed","detail":"Amount must be at least 50 cents"}) end end end describe "capture" do - test "with valid chargetoken" do + test "with valid charge token" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers, _options) -> MockResponse.test_for_capture_with_valid_chargetoken end] do - {:ok, response} = Trexle.capture(@valid_token, @amount, @opts) + request: fn _method, _url, _body, _headers, _options -> + MockResponse.test_for_capture_with_valid_chargetoken() + end do + assert {:ok, response} = Trexle.capture(@valid_token, @amount, @opts) + # Why 200 here?? It's 201 everywhere lese. Check trexle docs. assert response.status_code == 200 - assert response.raw["response"]["success"] == true - assert response.raw["response"]["captured"] == true - assert response.raw["response"]["status_message"] == "Transaction approved" end end - test "test_for_capture_with_invalid_chargetoken" do - with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers, _options) -> MockResponse.test_for_capture_with_invalid_chargetoken end] do - {:error, response} = Trexle.capture(@invalid_token, @amount, @opts) - assert response.status_code == 400 - assert response.success == false - assert response.raw == ~s({"error":"Capture failed","detail":"invalid token"}) - end + test "with invalid charge token" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers, _options -> + MockResponse.test_for_capture_with_invalid_chargetoken() + end do + assert {:error, response} = Trexle.capture(@invalid_token, @amount, @opts) + assert response.status_code == 400 + assert response.reason == "invalid token" + end end end describe "store" do test "with valid card" do with_mock HTTPoison, - [request: fn(_method, _url, _body, _headers, _options) -> MockResponse.test_for_store_with_valid_card end] do - {:ok, response} = Trexle.store(@valid_card, @opts) + request: fn _method, _url, _body, _headers, _options -> + MockResponse.test_for_store_with_valid_card() + end do + assert {:ok, response} = Trexle.store(@valid_card, @opts) assert response.status_code == 201 end end end + + describe "network failure" do + test "with authorization" do + with_mock HTTPoison, + request: fn _method, _url, _body, _headers, _options -> + MockResponse.test_for_network_failure() + end do + {:error, response} = Trexle.authorize(@amount, @valid_card, @opts) + + assert response.message == + "HTTPoison says 'some_hackney_error' [ID: some_hackney_error_id]" + end + end + end end diff --git a/test/gateways/wire_card_test.exs b/test/gateways/wire_card_test.exs index d23d9090..59de0f03 100644 --- a/test/gateways/wire_card_test.exs +++ b/test/gateways/wire_card_test.exs @@ -3,18 +3,11 @@ defmodule Gringotts.Gateways.WireCardTest do import Mock - alias Gringotts.{ - CreditCard, - Address, - Response - } - alias Gringotts.Gateways.WireCard, as: Gateway - setup do # TEST_AUTHORIZATION_GUWID = 'C822580121385121429927' # TEST_PURCHASE_GUWID = 'C865402121385575982910' # TEST_CAPTURE_GUWID = 'C833707121385268439116' - + # credit_card = %CreditCard{name: "Longbob", number: "4200000000000000", cvc: "123", expiration: {2015, 11}} # config = %{credentails: {'user', 'pass'}, default_currency: "EUR"} @@ -24,7 +17,4 @@ defmodule Gringotts.Gateways.WireCardTest do test "test_successful_authorization" do assert 1 + 1 == 2 end - - - end diff --git a/test/gringotts_test.exs b/test/gringotts_test.exs index d4634e14..01d01824 100644 --- a/test/gringotts_test.exs +++ b/test/gringotts_test.exs @@ -4,12 +4,11 @@ defmodule GringottsTest do import Gringotts @test_config [ - adapter: GringottsTest.FakeGateway, some_auth_info: :merchant_secret_key, other_secret: :sun_rises_in_the_east ] - @bad_config [adapter: GringottsTest.FakeGateway, some_auth_info: :merchant_secret_key] + @bad_config [some_auth_info: :merchant_secret_key] defmodule FakeGateway do use Gringotts.Adapter, required_config: [:some_auth_info, :other_secret] @@ -49,13 +48,11 @@ defmodule GringottsTest do end test "authorization" do - assert authorize(GringottsTest.FakeGateway, 100, :card, []) == - :authorization_response + assert authorize(GringottsTest.FakeGateway, 100, :card, []) == :authorization_response end test "purchase" do - assert purchase(GringottsTest.FakeGateway, 100, :card, []) == - :purchase_response + assert purchase(GringottsTest.FakeGateway, 100, :card, []) == :purchase_response end test "capture" do @@ -83,7 +80,7 @@ defmodule GringottsTest do assert_raise( ArgumentError, - "expected [:other_secret] to be set, got: [adapter: GringottsTest.FakeGateway, some_auth_info: :merchant_secret_key]\n", + "expected [:other_secret] to be set, got: [some_auth_info: :merchant_secret_key]\n", fn -> authorize(GringottsTest.FakeGateway, 100, :card, []) end ) end diff --git a/test/integration/gateways/mercadopago_test.exs b/test/integration/gateways/mercadopago_test.exs new file mode 100644 index 00000000..3a961610 --- /dev/null +++ b/test/integration/gateways/mercadopago_test.exs @@ -0,0 +1,96 @@ +defmodule Gringotts.Integration.Gateways.MercadopagoTest do + # Integration tests for the Mercadopago + + use ExUnit.Case, async: false + use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney + alias Gringotts.Gateways.Mercadopago, as: Gateway + + @moduletag integration: true + + @amount Money.new(45, :BRL) + @sub_amount Money.new(30, :BRL) + @config [ + access_token: "TEST-2774702803649645-031303-1b9d3d63acb57cdad3458d386eee62bd-307592510", + public_key: "TEST-911f45a1-0560-4c16-915e-a8833830b29a" + ] + @good_card %Gringotts.CreditCard{ + first_name: "Hermoine", + last_name: "Granger", + number: "4509953566233704", + year: 2030, + month: 07, + verification_code: "123", + brand: "VISA" + } + + @bad_card %Gringotts.CreditCard{ + first_name: "Hermoine", + last_name: "Granger", + 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 + ] + @new_cutomer_good_opts [ + order_id: 123_126, + config: @config, + installments: 1 + ] + + def new_email_opts(good) do + no1 = :rand.uniform(1_000_00) |> to_string + no2 = :rand.uniform(1_000_00) |> to_string + no3 = :rand.uniform(1_000_00) |> 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 "[purchase]" do + test "old customer with good_opts and good_card" do + use_cassette "mercadopago/purchase_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 + end + + test "old customer with good_opts and bad_card" do + use_cassette "mercadopago/purchase_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 + end + + test "new cutomer with good_opts and good_card" do + use_cassette "mercadopago/purchase_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 + end + + test "new customer with good_opts and bad_card" do + use_cassette "mercadopago/purchase_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 + end + end + end +end diff --git a/test/integration/gateways/monei_test.exs b/test/integration/gateways/monei_test.exs index 516164ea..59bc9b88 100644 --- a/test/integration/gateways/monei_test.exs +++ b/test/integration/gateways/monei_test.exs @@ -1,65 +1,166 @@ defmodule Gringotts.Integration.Gateways.MoneiTest do - use ExUnit.Case, async: false + use ExUnit.Case, async: true alias Gringotts.{ CreditCard } + alias Gringotts.Gateways.Monei, as: Gateway @moduletag :integration - + + @amount Money.new(42, :EUR) + @sub_amount Money.new(21, :EUR) + @card %CreditCard{ - first_name: "Jo", - last_name: "Doe", + first_name: "Harry", + last_name: "Potter", number: "4200000000000000", year: 2099, month: 12, - verification_code: "123", + verification_code: "123", brand: "VISA" } + @customer %{ + givenName: "Harry", + surname: "Potter", + merchantCustomerId: "the_boy_who_lived", + sex: "M", + birthDate: "1980-07-31", + mobile: "+15252525252", + email: "masterofdeath@ministryofmagic.gov", + ip: "127.0.0.1", + status: "NEW" + } + @merchant %{ + name: "Ollivanders", + city: "South Side", + street: "Diagon Alley", + state: "London", + country: "GB", + submerchantId: "Makers of Fine Wands since 382 B.C." + } + @billing %{ + street1: "301, Gryffindor", + street2: "Hogwarts School of Witchcraft and Wizardry, Hogwarts Castle", + city: "Highlands", + state: "Scotland", + country: "GB" + } + @shipping Map.merge( + %{method: "SAME_DAY_SERVICE", comment: "For our valued customer, Mr. Potter"}, + @billing + ) + + @extra_opts [ + customer: @customer, + merchant: @merchant, + billing: @billing, + shipping: @shipping, + shipping_customer: @customer, + category: "EC", + custom: %{voldemort: "he who must not be named"} + ] + + @auth %{ + userId: "8a8294186003c900016010a285582e0a", + password: "hMkqf2qbWf", + entityId: "8a82941760036820016010a28a8337f6" + } + setup_all do - Application.put_env(:gringotts, Gringotts.Gateways.Monei, [adapter: Gringotts.Gateways.Monei, - userId: "8a8294186003c900016010a285582e0a", - password: "hMkqf2qbWf", - entityId: "8a82941760036820016010a28a8337f6"]) + Application.put_env( + :gringotts, + Gringotts.Gateways.Monei, + adapter: Gringotts.Gateways.Monei, + userId: @auth[:userId], + password: @auth[:password], + entityId: @auth[:entityId] + ) end - test "authorize." do - case Gringotts.authorize(Gateway, 3.1, @card) do - {:ok, response} -> - assert response.code == "000.100.110" - assert response.description == "Request successfully processed in 'Merchant in Integrator Test Mode'" - assert String.length(response.id) == 32 - {:error, _err} -> flunk() + setup do + randoms = [ + invoice_id: Base.encode16(:crypto.hash(:md5, :crypto.strong_rand_bytes(32))), + transaction_id: Base.encode16(:crypto.hash(:md5, :crypto.strong_rand_bytes(32))) + ] + + {:ok, opts: [config: @auth] ++ randoms ++ @extra_opts} + end + + test "[authorize] without tokenisation", %{opts: opts} do + with {:ok, auth_result} <- Gateway.authorize(@amount, @card, opts), + {:ok, _capture_result} <- Gateway.capture(auth_result.id, @amount, opts) do + "yay!" + else + {:error, _err} -> + flunk() end end - @tag :skip - test "capture." do - case Gringotts.capture(Gateway, 32.00, "s") do - {:ok, response} -> - assert response.code == "000.100.110" - assert response.description == "Request successfully processed in 'Merchant in Integrator Test Mode'" - assert String.length(response.id) == 32 - - {:error, _err} -> flunk() + test "[authorize -> capture] with tokenisation", %{opts: opts} do + with {:ok, auth_result} <- Gateway.authorize(@amount, @card, opts ++ [register: true]), + {:ok, _registration_token} <- Map.fetch(auth_result, :token), + {:ok, _capture_result} <- Gateway.capture(auth_result.id, @amount, opts) do + "yay!" + else + {:error, _err} -> + flunk() end end - test "purchase." do - case Gringotts.purchase(Gateway, 32, @card) do - {:ok, response} -> - assert response.code == "000.100.110" - assert response.description == "Request successfully processed in 'Merchant in Integrator Test Mode'" - assert String.length(response.id) == 32 - {:error, _err} -> flunk() + test "[authorize -> void]", %{opts: opts} do + with {:ok, auth_result} <- Gateway.authorize(@amount, @card, opts), + {:ok, _void_result} <- Gateway.void(auth_result.id, opts) do + "yay!" + else + {:error, _err} -> + flunk() end end + test "[purchase/capture -> void]", %{opts: opts} do + with {:ok, purchase_result} <- Gateway.purchase(@amount, @card, opts), + {:ok, _void_result} <- Gateway.void(purchase_result.id, opts) do + "yay!" + else + {:error, _err} -> + flunk() + end + end + + test "[purchase/capture -> refund] (partial)", %{opts: opts} do + with {:ok, purchase_result} <- Gateway.purchase(@amount, @card, opts), + {:ok, _refund_result} <- Gateway.refund(@sub_amount, purchase_result.id, opts) do + "yay!" + else + {:error, _err} -> + flunk() + end + end + + test "[store]", %{opts: opts} do + assert {:ok, _store_result} = Gateway.store(@card, opts) + end + + @tag :skip + test "[store -> unstore]", %{opts: opts} do + with {:ok, store_result} <- Gateway.store(@card, opts), + {:ok, _unstore_result} <- Gateway.unstore(store_result.id, opts) do + "yay!" + else + {:error, _err} -> + flunk() + end + end + + test "[purchase]", %{opts: opts} do + assert {:ok, _response} = Gateway.purchase(@amount, @card, opts) + end + test "Environment setup" do config = Application.get_env(:gringotts, Gringotts.Gateways.Monei) assert config[:adapter] == Gringotts.Gateways.Monei end - end diff --git a/test/integration/gateways/stripe_test.exs b/test/integration/gateways/stripe_test.exs new file mode 100644 index 00000000..e4d3e070 --- /dev/null +++ b/test/integration/gateways/stripe_test.exs @@ -0,0 +1,45 @@ +defmodule Gringotts.Gateways.StripeTest do + use ExUnit.Case + + alias Gringotts.Gateways.Stripe + + alias Gringotts.{ + CreditCard, + Address + } + + @moduletag integration: true + + @amount Money.new(5, :USD) + @card %CreditCard{ + first_name: "John", + last_name: "Smith", + number: "4242424242424242", + # Can't be more than 50 years in the future, Haha. + year: "2068", + month: "12", + verification_code: "123" + } + + @address %Address{ + street1: "123 Main", + street2: "Suite 100", + city: "New York", + region: "NY", + country: "US", + postal_code: "11111" + } + + @required_opts [config: [secret_key: "sk_test_vIX41hayC0BKrPWQerLuOMld"]] + @optional_opts [address: @address] + + describe "authorize/3" do + test "with correct params" do + response = Stripe.authorize(@amount, @card, @required_opts ++ @optional_opts) + assert Map.has_key?(response, "id") + assert response["amount"] == 500 + assert response["captured"] == false + assert response["currency"] == "usd" + end + end +end diff --git a/test/integration/money.exs b/test/integration/money.exs new file mode 100644 index 00000000..e4aaa331 --- /dev/null +++ b/test/integration/money.exs @@ -0,0 +1,61 @@ +defmodule Gringotts.Integration.Gateways.MoneyTest do + use ExUnit.Case, async: true + + alias Gringotts.Money, as: MoneyProtocol + + @moduletag :integration + + @ex_money Money.new(42, :EUR) + @ex_money_long Money.new("42.126456", :EUR) + @ex_money_bhd Money.new(42, :BHD) + + @any %{value: Decimal.new(42), currency: "EUR"} + @any_long %{value: Decimal.new("42.126456"), currency: "EUR"} + @any_bhd %{value: Decimal.new("42"), currency: "BHD"} + + describe "ex_money" do + test "value is a Decimal.t" do + assert match?(%Decimal{}, MoneyProtocol.value(@ex_money)) + end + + test "currency is an upcase String.t" do + the_currency = MoneyProtocol.currency(@ex_money) + assert match?(currency when is_binary(currency), the_currency) + assert the_currency == String.upcase(the_currency) + end + + test "to_integer" do + assert match?({"EUR", 4200, -2}, MoneyProtocol.to_integer(@ex_money)) + assert match?({"BHD", 42_000, -3}, MoneyProtocol.to_integer(@ex_money_bhd)) + end + + test "to_string" do + assert match?({"EUR", "42.00"}, MoneyProtocol.to_string(@ex_money)) + assert match?({"EUR", "42.13"}, MoneyProtocol.to_string(@ex_money_long)) + assert match?({"BHD", "42.000"}, MoneyProtocol.to_string(@ex_money_bhd)) + end + end + + describe "Any" do + test "value is a Decimal.t" do + assert match?(%Decimal{}, MoneyProtocol.value(@any)) + end + + test "currency is an upcase String.t" do + the_currency = MoneyProtocol.currency(@any) + assert match?(currency when is_binary(currency), the_currency) + assert the_currency == String.upcase(the_currency) + end + + test "to_integer" do + assert match?({"EUR", 4200, -2}, MoneyProtocol.to_integer(@any)) + assert match?({"BHD", 4200, -2}, MoneyProtocol.to_integer(@any_bhd)) + end + + test "to_string" do + assert match?({"EUR", "42.00"}, MoneyProtocol.to_string(@any)) + assert match?({"EUR", "42.13"}, MoneyProtocol.to_string(@any_long)) + assert match?({"BHD", "42.00"}, MoneyProtocol.to_string(@any_bhd)) + end + end +end diff --git a/test/mocks/authorize_net_mock.exs b/test/mocks/authorize_net_mock.exs index c9acc67e..712602cd 100644 --- a/test/mocks/authorize_net_mock.exs +++ b/test/mocks/authorize_net_mock.exs @@ -1,304 +1,422 @@ - defmodule Gringotts.Gateways.AuthorizeNetMock do - - # purchase mock response - def successful_purchase_response do - {:ok, - %HTTPoison.Response{body: "123456OkI00001Successful.1C7HPT1YP2600365530965D6782A03246EE3BAFABE8006E32DE970XXXX0015MasterCard1This transaction has been approved.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-13182173"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "908"}, {"Date", "Thu, 21 Dec 2017 09:29:12 GMT"}, - {"Connection", "keep-alive"}], +defmodule Gringotts.Gateways.AuthorizeNetMock do + # purchase mock response + def successful_purchase_response do + {:ok, + %HTTPoison.Response{ + body: + ~s{123456OkI00001Successful.1C7HPT1YP2600365530965D6782A03246EE3BAFABE8006E32DE970XXXX0015MasterCard1This transaction has been approved.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-13182173"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "908"}, + {"Date", "Thu, 21 Dec 2017 09:29:12 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - def bad_card_purchase_response do - {:ok, - %HTTPoison.Response{body: "ErrorE00003The 'AnetApi/xml/v1/schema/AnetApiSchema.xsd:cardNumber' element is invalid - The value XXXXX is invalid according to its datatype 'String' - The actual length is less than the MinLength value.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_f2f80544-1a98-4ad7-989b-8d267ebf5043-56152-10066531"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "514"}, {"Date", "Thu, 21 Dec 2017 09:35:45 GMT"}, - {"Connection", "keep-alive"}], + def bad_card_purchase_response do + {:ok, + %HTTPoison.Response{ + body: + ~s{ErrorE00003The 'AnetApi/xml/v1/schema/AnetApiSchema.xsd:cardNumber' element is invalid - The value XXXXX is invalid according to its datatype 'String' - The actual length is less than the MinLength value.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_f2f80544-1a98-4ad7-989b-8d267ebf5043-56152-10066531"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "514"}, + {"Date", "Thu, 21 Dec 2017 09:35:45 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - def bad_amount_purchase_response do - {:ok, - %HTTPoison.Response{body: "123456ErrorE00027The transaction was unsuccessful.3P0C7C56F020A2AE2660A87637CD00B4D5C0XXXX0015MasterCard5A valid amount is required.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-13187900"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "867"}, {"Date", "Thu, 21 Dec 2017 09:44:33 GMT"}, - {"Connection", "keep-alive"}], + def bad_amount_purchase_response do + {:ok, + %HTTPoison.Response{ + body: + ~s{123456ErrorE00027The transaction was unsuccessful.3P0C7C56F020A2AE2660A87637CD00B4D5C0XXXX0015MasterCard5A valid amount is required.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-13187900"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "867"}, + {"Date", "Thu, 21 Dec 2017 09:44:33 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - # authorize mock response - def successful_authorize_response do - {:ok, - %HTTPoison.Response{body: "123456OkI00001Successful.1K6Z0ABYP260036854582A4AD079E22A271D92662CF093CED7A5D0XXXX0015MasterCard1This transaction has been approved.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15778237"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "908"}, {"Date", "Mon, 25 Dec 2017 14:17:56 GMT"}, - {"Connection", "keep-alive"}], + # authorize mock response + def successful_authorize_response do + {:ok, + %HTTPoison.Response{ + body: + ~s{123456OkI00001Successful.1K6Z0ABYP260036854582A4AD079E22A271D92662CF093CED7A5D0XXXX0015MasterCard1This transaction has been approved.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15778237"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "908"}, + {"Date", "Mon, 25 Dec 2017 14:17:56 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - def bad_card_authorize_response do - {:ok, - %HTTPoison.Response{body: "ErrorE00003The 'AnetApi/xml/v1/schema/AnetApiSchema.xsd:cardNumber' element is invalid - The value XXXXX is invalid according to its datatype 'String' - The actual length is less than the MinLength value.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_f2f80544-1a98-4ad7-989b-8d267ebf5043-56152-12660528"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "514"}, {"Date", "Mon, 25 Dec 2017 14:19:29 GMT"}, - {"Connection", "keep-alive"}], + def bad_card_authorize_response do + {:ok, + %HTTPoison.Response{ + body: + ~s{ErrorE00003The 'AnetApi/xml/v1/schema/AnetApiSchema.xsd:cardNumber' element is invalid - The value XXXXX is invalid according to its datatype 'String' - The actual length is less than the MinLength value.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_f2f80544-1a98-4ad7-989b-8d267ebf5043-56152-12660528"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "514"}, + {"Date", "Mon, 25 Dec 2017 14:19:29 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - def bad_amount_authorize_response do - {:ok, - %HTTPoison.Response{body: "123456ErrorE00027The transaction was unsuccessful.3P0C7C56F020A2AE2660A87637CD00B4D5C0XXXX0015MasterCard290There is one or more missing or invalid required fields.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15779095"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "898"}, {"Date", "Mon, 25 Dec 2017 14:22:02 GMT"}, - {"Connection", "keep-alive"}], + def bad_amount_authorize_response do + {:ok, + %HTTPoison.Response{ + body: + ~s{123456ErrorE00027The transaction was unsuccessful.3P0C7C56F020A2AE2660A87637CD00B4D5C0XXXX0015MasterCard290There is one or more missing or invalid required fields.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15779095"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "898"}, + {"Date", "Mon, 25 Dec 2017 14:22:02 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - # capture mock response + # capture mock response - def successful_capture_response do - {:ok, - %HTTPoison.Response{body: "123456OkI00001Successful.14OKD6YP6003685493160036854931348C4ECD0F764736B012C4655BFA68EF0XXXX0015MasterCard1This transaction has been approved.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15783402"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "899"}, {"Date", "Mon, 25 Dec 2017 14:39:28 GMT"}, - {"Connection", "keep-alive"}], + def successful_capture_response do + {:ok, + %HTTPoison.Response{ + body: + ~s{123456OkI00001Successful.14OKD6YP6003685493160036854931348C4ECD0F764736B012C4655BFA68EF0XXXX0015MasterCard1This transaction has been approved.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15783402"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "899"}, + {"Date", "Mon, 25 Dec 2017 14:39:28 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - def bad_id_capture do - {:ok, - %HTTPoison.Response{body: "123456ErrorE00027The transaction was unsuccessful.3P0A5280E2A6AA1290D451A24286692D1B0033A valid referenced transaction ID is required.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15784805"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "843"}, {"Date", "Mon, 25 Dec 2017 14:45:32 GMT"}, - {"Connection", "keep-alive"}], + def bad_id_capture do + {:ok, + %HTTPoison.Response{ + body: + ~s{123456ErrorE00027The transaction was unsuccessful.3P0A5280E2A6AA1290D451A24286692D1B0033A valid referenced transaction ID is required.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15784805"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "843"}, + {"Date", "Mon, 25 Dec 2017 14:45:32 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - # refund mock response - def successful_refund_response do - {:ok, - %HTTPoison.Response{body: "123456OkI00001Successful.1P6003685566160036752756169F2381B172A5AA247A01757A3E520A0XXXX0015MasterCard1This transaction has been approved.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_f2f80544-1a98-4ad7-989b-8d267ebf5043-56152-12678232"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "884"}, {"Date", "Mon, 25 Dec 2017 15:22:19 GMT"}, - {"Connection", "keep-alive"}], + # refund mock response + def successful_refund_response do + {:ok, + %HTTPoison.Response{ + body: + ~s{123456OkI00001Successful.1P6003685566160036752756169F2381B172A5AA247A01757A3E520A0XXXX0015MasterCard1This transaction has been approved.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_f2f80544-1a98-4ad7-989b-8d267ebf5043-56152-12678232"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "884"}, + {"Date", "Mon, 25 Dec 2017 15:22:19 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - def bad_card_refund do - {:ok, - %HTTPoison.Response{body: "ErrorE00003The 'AnetApi/xml/v1/schema/AnetApiSchema.xsd:cardNumber' element is invalid - The value XX is invalid according to its datatype 'String' - The actual length is less than the MinLength value.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15795999"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "511"}, {"Date", "Mon, 25 Dec 2017 15:21:20 GMT"}, - {"Connection", "keep-alive"}], + def bad_card_refund do + {:ok, + %HTTPoison.Response{ + body: + ~s{ErrorE00003The 'AnetApi/xml/v1/schema/AnetApiSchema.xsd:cardNumber' element is invalid - The value XX is invalid according to its datatype 'String' - The actual length is less than the MinLength value.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15795999"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "511"}, + {"Date", "Mon, 25 Dec 2017 15:21:20 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - def debit_less_than_refund do - {:ok, - %HTTPoison.Response{body: "123456ErrorE00027The transaction was unsuccessful.3P060036752756A5280E2A6AA1290D451A24286692D1B00XXXX0015MasterCard55The sum of credits against the referenced transaction would exceed original debit amount.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_f2f80544-1a98-4ad7-989b-8d267ebf5043-56152-12681460"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "952"}, {"Date", "Mon, 25 Dec 2017 15:39:25 GMT"}, - {"Connection", "keep-alive"}], + def debit_less_than_refund do + {:ok, + %HTTPoison.Response{ + body: + ~s{123456ErrorE00027The transaction was unsuccessful.3P060036752756A5280E2A6AA1290D451A24286692D1B00XXXX0015MasterCard55The sum of credits against the referenced transaction would exceed original debit amount.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_f2f80544-1a98-4ad7-989b-8d267ebf5043-56152-12681460"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "952"}, + {"Date", "Mon, 25 Dec 2017 15:39:25 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - # void mock response - def successful_void do - {:ok, - %HTTPoison.Response{body: "123456OkI00001Successful.1ZJPVRXP6003685521760036855217F09A215511891DCEA91B6CC52B9F4E870XXXX0015MasterCard1This transaction has been approved.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_f2f80544-1a98-4ad7-989b-8d267ebf5043-56152-12682366"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "899"}, {"Date", "Mon, 25 Dec 2017 15:43:56 GMT"}, - {"Connection", "keep-alive"}], + # void mock response + def successful_void do + {:ok, + %HTTPoison.Response{ + body: + ~s{123456OkI00001Successful.1ZJPVRXP6003685521760036855217F09A215511891DCEA91B6CC52B9F4E870XXXX0015MasterCard1This transaction has been approved.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_f2f80544-1a98-4ad7-989b-8d267ebf5043-56152-12682366"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "899"}, + {"Date", "Mon, 25 Dec 2017 15:43:56 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - def void_non_existent_id do - {:ok, - %HTTPoison.Response{body: "123456ErrorE00027The transaction was unsuccessful.3P060036855219C7C56F020A2AE2660A87637CD00B4D5C016The transaction cannot be found.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15801470"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "861"}, {"Date", "Mon, 25 Dec 2017 15:49:38 GMT"}, - {"Connection", "keep-alive"}], + def void_non_existent_id do + {:ok, + %HTTPoison.Response{ + body: + ~s{123456ErrorE00027The transaction was unsuccessful.3P060036855219C7C56F020A2AE2660A87637CD00B4D5C016The transaction cannot be found.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15801470"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "861"}, + {"Date", "Mon, 25 Dec 2017 15:49:38 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - # store mock response + # store mock response + + def successful_store_response do + {:ok, + %HTTPoison.Response{ + body: + ~s{OkI00001Successful.18139914901808649724}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15829721"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "577"}, + {"Date", "Mon, 25 Dec 2017 17:08:12 GMT"}, + {"Connection", "keep-alive"} + ], + request_url: "https://apitest.authorize.net/xml/v1/request.api", + status_code: 200 + }} + end - def successful_store_response do - {:ok, - %HTTPoison.Response{body: "OkI00001Successful.18139914901808649724", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15829721"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "577"}, {"Date", "Mon, 25 Dec 2017 17:08:12 GMT"}, - {"Connection", "keep-alive"}], + def store_without_profile_fields do + {:ok, + %HTTPoison.Response{ + body: + ~s{ErrorE00041One or more fields in the profile must contain a value.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15831457"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "408"}, + {"Date", "Mon, 25 Dec 2017 17:12:30 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end - - def store_without_profile_fields do - {:ok, - %HTTPoison.Response{body: "ErrorE00041One or more fields in the profile must contain a value.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15831457"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "408"}, {"Date", "Mon, 25 Dec 2017 17:12:30 GMT"}, - {"Connection", "keep-alive"}], + status_code: 200 + }} + end + + # unstore mock response + def successful_unstore_response do + {:ok, + %HTTPoison.Response{ + body: + ~s{OkI00001Successful.}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15833786"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "361"}, + {"Date", "Mon, 25 Dec 2017 17:21:20 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - #unstore mock response - def successful_unstore_response do - {:ok, - %HTTPoison.Response{body: "OkI00001Successful.", - headers: [{"Cache-Control", "private"}, - {"Content-Type", "application/xml; charset=utf-8"}, - {"X-OPNET-Transaction-Trace", - "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-15833786"}, - {"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, - {"Access-Control-Allow-Headers", - "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, - {"Access-Control-Allow-Credentials", "true"}, {"X-Cnection", "close"}, - {"Content-Length", "361"}, {"Date", "Mon, 25 Dec 2017 17:21:20 GMT"}, - {"Connection", "keep-alive"}], + def customer_payment_profile_success_response do + {:ok, + %HTTPoison.Response{ + body: + ~s{OkI00001Successful.181401200218086700051,1,1,(TESTMODE) This transaction has been approved.,000000,P,0,none,Test transaction for ValidateCustomerPaymentProfile.,1.00,CC,auth_only,none,,,,,,,,,,,email@example.com,,,,,,,,,0.00,0.00,0.00,FALSE,none,EA9FD49A9501D0415FE26BAEF9FD8B2C,,,,,,,,,,,,,XXXX0015,MasterCard,,,,,,,,,,,,,,,,,}, + headers: [ + {"Cache-Control", "private"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"X-OPNET-Transaction-Trace", "a2_b6b84b43-d399-4dde-bc12-fb1f8ccf4b27-51156-17537805"}, + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "PUT,OPTIONS,POST,GET"}, + {"Access-Control-Allow-Headers", + "x-requested-with,cache-control,content-type,origin,method,SOAPAction"}, + {"Access-Control-Allow-Credentials", "true"}, + {"X-Cnection", "close"}, + {"Content-Length", "828"}, + {"Date", "Thu, 28 Dec 2017 13:54:20 GMT"}, + {"Connection", "keep-alive"} + ], request_url: "https://apitest.authorize.net/xml/v1/request.api", - status_code: 200}} - end + status_code: 200 + }} + end - def network_error_response do - body = "no response error" - {:error, %{body: body, status_code: 500}} - end + def netwok_error_non_existent_domain do + {:error, %HTTPoison.Error{id: nil, reason: :nxdomain}} end +end diff --git a/test/mocks/cams_mock.exs b/test/mocks/cams_mock.exs index 294959e9..1eb50faf 100644 --- a/test/mocks/cams_mock.exs +++ b/test/mocks/cams_mock.exs @@ -1,192 +1,225 @@ defmodule Gringotts.Gateways.CamsMock do def successful_purchase do {:ok, - %HTTPoison.Response{ - body: "response=1&responsetext=SUCCESS&authcode=123456&transactionid=3916017714&avsresponse=N&cvvresponse=N&orderid=&type=sale&response_code=100", - headers: [ - {"Date", "Thu, 21 Dec 2017 12:45:16 GMT"}, - {"Server", "Apache"}, - {"Content-Length", "137"}, - {"Content-Type", "text/html; charset=UTF-8"} - ], - request_url: "https://secure.centralams.com/gw/api/transact.php", - status_code: 200}} + %HTTPoison.Response{ + body: + "response=1&responsetext=SUCCESS&authcode=123456&transactionid=3916017714&avsresponse=N&cvvresponse=N&orderid=&type=sale&response_code=100", + headers: [ + {"Date", "Thu, 21 Dec 2017 12:45:16 GMT"}, + {"Server", "Apache"}, + {"Content-Length", "137"}, + {"Content-Type", "text/html; charset=UTF-8"} + ], + request_url: "https://secure.centralams.com/gw/api/transact.php", + status_code: 200 + }} end - + def failed_purchase_with_bad_credit_card do {:ok, - %HTTPoison.Response{ - body: "response=3&responsetext=Invalid Credit Card Number REFID:3502947912&authcode=&transactionid=&avsresponse=&cvvresponse=&orderid=&type=sale&response_code=300", - headers: [ - {"Date", "Thu, 21 Dec 2017 13:20:08 GMT"}, - {"Server", "Apache"}, - {"Content-Length", "155"}, - {"Content-Type", "text/html; charset=UTF-8"} - ], - request_url: "https://secure.centralams.com/gw/api/transact.php", - status_code: 200}} - end - def failed_purchase_with_bad_money do - {:ok, - %HTTPoison.Response{ - body: "response=3&responsetext=Invalid amount REFID:3502949755&authcode=&transactionid=&avsresponse=&cvvresponse=&orderid=&type=sale&response_code=300", - headers: [ - {"Date", "Thu, 21 Dec 2017 13:50:20 GMT"}, - {"Server", "Apache"}, - {"Content-Length", "143"}, - {"Content-Type", "text/html; charset=UTF-8"} - ], - request_url: "https://secure.centralams.com/gw/api/transact.php", - status_code: 200}} + %HTTPoison.Response{ + body: + "response=3&responsetext=Invalid Credit Card Number REFID:3502947912&authcode=&transactionid=&avsresponse=&cvvresponse=&orderid=&type=sale&response_code=300", + headers: [ + {"Date", "Thu, 21 Dec 2017 13:20:08 GMT"}, + {"Server", "Apache"}, + {"Content-Length", "155"}, + {"Content-Type", "text/html; charset=UTF-8"} + ], + request_url: "https://secure.centralams.com/gw/api/transact.php", + status_code: 200 + }} end - + def with_invalid_currency do {:ok, - %HTTPoison.Response{ - body: "response=3&responsetext=The cc payment type [Visa] and/or currency [INR] is not accepted REFID:3503238709&authcode=&transactionid=&avsresponse=&cvvresponse=&orderid=&type=auth&response_code=300", - headers: [ - {"Date", "Tue, 26 Dec 2017 10:37:42 GMT"}, - {"Server", "Apache"}, - {"Content-Length", "193"}, - {"Content-Type", "text/html; charset=UTF-8"} - ], - request_url: "https://secure.centralams.com/gw/api/transact.php", - status_code: 200}} + %HTTPoison.Response{ + body: + "response=3&responsetext=The cc payment type [Visa] and/or currency [INR] is not accepted REFID:3503238709&authcode=&transactionid=&avsresponse=&cvvresponse=&orderid=&type=auth&response_code=300", + headers: [ + {"Date", "Tue, 26 Dec 2017 10:37:42 GMT"}, + {"Server", "Apache"}, + {"Content-Length", "193"}, + {"Content-Type", "text/html; charset=UTF-8"} + ], + request_url: "https://secure.centralams.com/gw/api/transact.php", + status_code: 200 + }} end - + def successful_capture do {:ok, - %HTTPoison.Response{ - body: "response=1&responsetext=SUCCESS&authcode=123456&transactionid=3921111362&avsresponse=&cvvresponse=&orderid=&type=capture&response_code=100", - headers: [ - {"Date", "Tue, 26 Dec 2017 12:16:55 GMT"}, - {"Server", "Apache"}, - {"Content-Length", "138"}, - {"Content-Type", "text/html; charset=UTF-8"} - ], - request_url: "https://secure.centralams.com/gw/api/transact.php", - status_code: 200}} + %HTTPoison.Response{ + body: + "response=1&responsetext=SUCCESS&authcode=123456&transactionid=3921111362&avsresponse=&cvvresponse=&orderid=&type=capture&response_code=100", + headers: [ + {"Date", "Tue, 26 Dec 2017 12:16:55 GMT"}, + {"Server", "Apache"}, + {"Content-Length", "138"}, + {"Content-Type", "text/html; charset=UTF-8"} + ], + request_url: "https://secure.centralams.com/gw/api/transact.php", + status_code: 200 + }} end + def successful_authorize do {:ok, - %HTTPoison.Response{ - body: "response=1&responsetext=SUCCESS&authcode=123456&transactionid=3921111362&avsresponse=N&cvvresponse=N&orderid=&type=auth&response_code=100", - headers: [ - {"Date", "Tue, 26 Dec 2017 12:16:11 GMT"}, - {"Server", "Apache"}, - {"Content-Length", "137"}, - {"Content-Type", "text/html; charset=UTF-8"} - ], - request_url: "https://secure.centralams.com/gw/api/transact.php", - status_code: 200}} + %HTTPoison.Response{ + body: + "response=1&responsetext=SUCCESS&authcode=123456&transactionid=3921111362&avsresponse=N&cvvresponse=N&orderid=&type=auth&response_code=100", + headers: [ + {"Date", "Tue, 26 Dec 2017 12:16:11 GMT"}, + {"Server", "Apache"}, + {"Content-Length", "137"}, + {"Content-Type", "text/html; charset=UTF-8"} + ], + request_url: "https://secure.centralams.com/gw/api/transact.php", + status_code: 200 + }} end + def invalid_transaction_id do {:ok, - %HTTPoison.Response{ - body: "response=3&responsetext=Transaction not found REFID:3503243979&authcode=&transactionid=3921118690&avsresponse=&cvvresponse=&orderid=&type=capture&response_code=300", - headers: [ - {"Date", "Tue, 26 Dec 2017 12:39:05 GMT"}, - {"Server", "Apache"}, - {"Content-Length", "163"}, - {"Content-Type", "text/html; charset=UTF-8"} - ], - request_url: "https://secure.centralams.com/gw/api/transact.php", - status_code: 200}} - end - def more_than_authorization_amount do + %HTTPoison.Response{ + body: + "response=3&responsetext=Transaction not found REFID:3503243979&authcode=&transactionid=3921118690&avsresponse=&cvvresponse=&orderid=&type=capture&response_code=300", + headers: [ + {"Date", "Tue, 26 Dec 2017 12:39:05 GMT"}, + {"Server", "Apache"}, + {"Content-Length", "163"}, + {"Content-Type", "text/html; charset=UTF-8"} + ], + request_url: "https://secure.centralams.com/gw/api/transact.php", + status_code: 200 + }} + end + + def more_than_authorization_amount do {:ok, - %HTTPoison.Response{ - body: "response=3&responsetext=The specified amount of 1001 exceeds the authorization amount of 1000.00 REFID:3503244462&authcode=&transactionid=3921127126&avsresponse=&cvvresponse=&orderid=&type=capture&response_code=300", - headers: [ - {"Date", "Tue, 26 Dec 2017 13:00:55 GMT"}, - {"Server", "Apache"}, - {"Content-Length", "214"}, - {"Content-Type", "text/html; charset=UTF-8"} - ], - request_url: "https://secure.centralams.com/gw/api/transact.php", - status_code: 200}} + %HTTPoison.Response{ + body: + "response=3&responsetext=The specified amount of 1001 exceeds the authorization amount of 1000.00 REFID:3503244462&authcode=&transactionid=3921127126&avsresponse=&cvvresponse=&orderid=&type=capture&response_code=300", + headers: [ + {"Date", "Tue, 26 Dec 2017 13:00:55 GMT"}, + {"Server", "Apache"}, + {"Content-Length", "214"}, + {"Content-Type", "text/html; charset=UTF-8"} + ], + request_url: "https://secure.centralams.com/gw/api/transact.php", + status_code: 200 + }} end + def successful_refund do {:ok, - %HTTPoison.Response{ - body: "response=1&responsetext=SUCCESS&authcode=&transactionid=3921158933&avsresponse=&cvvresponse=&orderid=&type=refund&response_code=100", - headers: [ - {"Date", "Tue, 26 Dec 2017 14:00:08 GMT"}, - {"Server", "Apache"}, - {"Content-Length", "131"}, - {"Content-Type", "text/html; charset=UTF-8"} - ], - request_url: "https://secure.centralams.com/gw/api/transact.php", - status_code: 200}} + %HTTPoison.Response{ + body: + "response=1&responsetext=SUCCESS&authcode=&transactionid=3921158933&avsresponse=&cvvresponse=&orderid=&type=refund&response_code=100", + headers: [ + {"Date", "Tue, 26 Dec 2017 14:00:08 GMT"}, + {"Server", "Apache"}, + {"Content-Length", "131"}, + {"Content-Type", "text/html; charset=UTF-8"} + ], + request_url: "https://secure.centralams.com/gw/api/transact.php", + status_code: 200 + }} end def more_than_purchase_amount do {:ok, - %HTTPoison.Response{ - body: "response=3&responsetext=Refund amount may not exceed the transaction balance REFID:3503249728&authcode=&transactionid=&avsresponse=&cvvresponse=&orderid=&type=refund&response_code=300", - headers: [ - {"Date", "Tue, 26 Dec 2017 14:05:31 GMT"}, - {"Server", "Apache"}, - {"Content-Length", "183"}, - {"Content-Type", "text/html; charset=UTF-8"} - ], - request_url: "https://secure.centralams.com/gw/api/transact.php", - status_code: 200}} - end + %HTTPoison.Response{ + body: + "response=3&responsetext=Refund amount may not exceed the transaction balance REFID:3503249728&authcode=&transactionid=&avsresponse=&cvvresponse=&orderid=&type=refund&response_code=300", + headers: [ + {"Date", "Tue, 26 Dec 2017 14:05:31 GMT"}, + {"Server", "Apache"}, + {"Content-Length", "183"}, + {"Content-Type", "text/html; charset=UTF-8"} + ], + request_url: "https://secure.centralams.com/gw/api/transact.php", + status_code: 200 + }} + end def successful_void do {:ok, - %HTTPoison.Response{ - body: "response=1&responsetext=Transaction Void Successful&authcode=123456&transactionid=3921178863&avsresponse=&cvvresponse=&orderid=&type=void&response_code=100", - headers: [ - {"Date", "Tue, 26 Dec 2017 14:26:05 GMT"}, - {"Server", "Apache"}, - {"Content-Length", "155"}, - {"Content-Type", "text/html; charset=UTF-8"} - ], - request_url: "https://secure.centralams.com/gw/api/transact.php", - status_code: 200}} + %HTTPoison.Response{ + body: + "response=1&responsetext=Transaction Void Successful&authcode=123456&transactionid=3921178863&avsresponse=&cvvresponse=&orderid=&type=void&response_code=100", + headers: [ + {"Date", "Tue, 26 Dec 2017 14:26:05 GMT"}, + {"Server", "Apache"}, + {"Content-Length", "155"}, + {"Content-Type", "text/html; charset=UTF-8"} + ], + request_url: "https://secure.centralams.com/gw/api/transact.php", + status_code: 200 + }} end def failed_authorized_with_bad_card do {:ok, - %HTTPoison.Response{ - body: "response=3&responsetext=Invalid Credit Card Number REFID:3503305883&authcode=&transactionid=&avsresponse=&cvvresponse=&orderid=&type=auth&response_code=300", - headers: [ - {"Date", "Wed, 27 Dec 2017 09:51:45 GMT"}, - {"Server", "Apache"}, - {"Content-Length", "155"}, - {"Content-Type", "text/html; charset=UTF-8"} - ], - request_url: "https://secure.centralams.com/gw/api/transact.php", - status_code: 200}} + %HTTPoison.Response{ + body: + "response=3&responsetext=Invalid Credit Card Number REFID:3503305883&authcode=&transactionid=&avsresponse=&cvvresponse=&orderid=&type=auth&response_code=300", + headers: [ + {"Date", "Wed, 27 Dec 2017 09:51:45 GMT"}, + {"Server", "Apache"}, + {"Content-Length", "155"}, + {"Content-Type", "text/html; charset=UTF-8"} + ], + request_url: "https://secure.centralams.com/gw/api/transact.php", + status_code: 200 + }} end def multiple_capture_on_same_transaction do {:ok, - %HTTPoison.Response{ - body: "response=3&responsetext=A capture requires that the existing transaction be an AUTH REFID:3503316182&authcode=&transactionid=3922433984&avsresponse=&cvvresponse=&orderid=&type=capture&response_code=300", - headers: [ - {"Date", "Wed, 27 Dec 2017 13:47:12 GMT"}, - {"Server", "Apache"}, - {"Content-Length", "201"}, - {"Content-Type", "text/html; charset=UTF-8"} - ], - request_url: "https://secure.centralams.com/gw/api/transact.php", - status_code: 200}} + %HTTPoison.Response{ + body: + "response=3&responsetext=A capture requires that the existing transaction be an AUTH REFID:3503316182&authcode=&transactionid=3922433984&avsresponse=&cvvresponse=&orderid=&type=capture&response_code=300", + headers: [ + {"Date", "Wed, 27 Dec 2017 13:47:12 GMT"}, + {"Server", "Apache"}, + {"Content-Length", "201"}, + {"Content-Type", "text/html; charset=UTF-8"} + ], + request_url: "https://secure.centralams.com/gw/api/transact.php", + status_code: 200 + }} end def refund_the_authorised_transaction do {:ok, - %HTTPoison.Response{ - body: "response=3&responsetext=Refund amount may not exceed the transaction balance REFID:3503316128&authcode=&transactionid=&avsresponse=&cvvresponse=&orderid=&type=refund&response_code=300", - headers: [{"Date", "Wed, 27 Dec 2017 13:45:19 GMT"}, - {"Server", "Apache"}, - {"Content-Length", "183"}, - {"Content-Type", "text/html; charset=UTF-8"} - ], - request_url: "https://secure.centralams.com/gw/api/transact.php", - status_code: 200}} + %HTTPoison.Response{ + body: + "response=3&responsetext=Refund amount may not exceed the transaction balance REFID:3503316128&authcode=&transactionid=&avsresponse=&cvvresponse=&orderid=&type=refund&response_code=300", + headers: [ + {"Date", "Wed, 27 Dec 2017 13:45:19 GMT"}, + {"Server", "Apache"}, + {"Content-Length", "183"}, + {"Content-Type", "text/html; charset=UTF-8"} + ], + request_url: "https://secure.centralams.com/gw/api/transact.php", + status_code: 200 + }} end + def validate_creditcard do + {:ok, + %HTTPoison.Response{ + body: + "response=1&responsetext=&authcode=&transactionid=3933708264&avsresponse=&cvvresponse=&orderid=&type=verify&response_code=100", + headers: [ + {"Date", "Thu, 04 Jan 2018 11:12:20 GMT"}, + {"Server", "Apache"}, + {"Content-Length", "124"}, + {"Content-Type", "text/html; charset=UTF-8"} + ], + request_url: "https://secure.centralams.com/gw/api/transact.php", + status_code: 200 + }} + end end diff --git a/test/mocks/global_collect_mock.exs b/test/mocks/global_collect_mock.exs new file mode 100644 index 00000000..ebedf90a --- /dev/null +++ b/test/mocks/global_collect_mock.exs @@ -0,0 +1,251 @@ +defmodule Gringotts.Gateways.GlobalCollectMock do + def test_for_purchase_with_valid_card do + {:ok, + %HTTPoison.Response{ + body: + ~s/{"creationOutput":{"additionalReference":"00000012260000000074","externalReference": + "000000122600000000740000100001"},"payment":{"id":"000000122600000000740000100001", + "paymentOutput":{"amountOfMoney":{"amount":500,"currencyCode":"USD"},"references": + {"paymentReference":"0"},"paymentMethod":"card","cardPaymentMethodSpecificOutput": + {"paymentProductId":1,"authorisationCode":"OK1131","fraudResults":{"fraudServiceResult": + "no-advice","avsResult":"0","cvvResult":"0"},"card":{"cardNumber":"************7977", + "expiryDate":"1218"}}},"status":"PENDING_APPROVAL","statusOutput":{"isCancellable":true, + "statusCategory":"PENDING_MERCHANT","statusCode":600,"statusCodeChangeDateTime": + "20180118135349","isAuthorized":true,"isRefundable":false}}}/, + headers: [ + {"Date", "Thu, 18 Jan 2018 12:53:49 GMT"}, + {"Server", "Apache/2.4.27 (Unix) OpenSSL/1.0.2l"}, + {"Location", + "https://api-sandbox.globalcollect.com:443/v1/1226/payments/000000122600000000740000100001"}, + {"X-Powered-By", "Servlet/3.0 JSP/2.2"}, + {"Transfer-Encoding", "chunked"}, + {"Content-Type", "application/json"} + ], + request_url: "https://api-sandbox.globalcollect.com/v1/1226/payments", + status_code: 201 + }} + end + + def test_for_purchase_with_invalid_card do + {:ok, + %HTTPoison.Response{ + body: + ~s/{"errorId" : "363899bd-acfb-4452-bbb0-741c0df6b4b8","errors" : [ {"code" : "21000120", + "requestId" : "980825","propertyName" : "cardPaymentMethodSpecificInput.card.expiryDate", + "message" : "cardPaymentMethodSpecificInput.card.expiryDate (1210) IS IN THE PAST OR NOT IN CORRECT MMYY FORMAT", + "httpStatusCode" : 400} ],"paymentResult" : {"creationOutput" : { " additionalReference" : "00000012260000000075", + "externalReference" : "000000122600000000750000100001"},"payment" : {"id" : "000000122600000000750000100001", + "paymentOutput" : {"amountOfMoney" : {"amount" : 500,"currencyCode" : "USD"},"references" : {"paymentReference" : "0"}, + "paymentMethod" : "card","cardPaymentMethodSpecificOutput" : {"paymentProductId" : 1}}, + "status" : "REJECTED","statusOutput" : {"errors" : [ {"code" : "21000120", + "requestId" : "546247","propertyName" : "cardPaymentMethodSpecificInput.card.expiryDate", + "message" : "cardPaymentMethodSpecificInput.card.expiryDate (1210) IS IN THE PAST OR NOT IN CORRECT MMYY FORMAT", + "httpStatusCode" : 400} ],"isCancellable" : false,"statusCategory" : "UNSUCCESSFUL","statusCode" : 100, + "statusCodeChangeDateTime" : "20180118135651","isAuthorized" : false,"isRefundable" : false}}}}/, + headers: [ + {"Date", "Thu, 18 Jan 2018 12:56:51 GMT"}, + {"Server", "Apache/2.4.27 (Unix) OpenSSL/1.0.2l"}, + {"X-Powered-By", "Servlet/3.0 JSP/2.2"}, + {"Connection", "close"}, + {"Transfer-Encoding", "chunked"}, + {"Content-Type", "application/json"} + ], + request_url: "https://api-sandbox.globalcollect.com/v1/1226/payments", + status_code: 400 + }} + end + + def test_for_purchase_with_invalid_amount do + {:ok, + %HTTPoison.Response{ + body: + ~s/{ "errorId" : "8c34dc0b-776c-44e3-8cd4-b36222960153","errors" : [ {"code" : "1099","id" : + "INVALID_VALUE","category" : "CONNECT_PLATFORM_ERROR","message" : + "INVALID_VALUE: '50.3' is not a valid value for field 'amount'", + "httpStatusCode" : 400 } ]}/, + headers: [ + {"Date", "Wed, 24 Jan 2018 07:16:06 GMT"}, + {"Server", "Apache/2.4.27 (Unix) OpenSSL/1.0.2l"}, + {"X-Powered-By", "Servlet/3.0 JSP/2.2"}, + {"Connection", "close"}, + {"Transfer-Encoding", "chunked"}, + {"Content-Type", "application/json"} + ], + request_url: "https://api-sandbox.globalcollect.com/v1/1226/payments", + status_code: 400 + }} + end + + def test_for_authorize_with_valid_card do + {:ok, + %HTTPoison.Response{ + body: + ~s/{"creationOutput" : {"additionalReference" : "00000012260000000065","externalReference" : + "000000122600000000650000100001"},"payment" : {"id" : "000000122600000000650000100001", + "paymentOutput" :{"amountOfMoney" : {"amount" : 500,"currencyCode" : "USD"},"references" : + {"paymentReference" : "0" },"paymentMethod" : "card","cardPaymentMethodSpecificOutput" : + {"paymentProductId" : 1,"authorisationCode" : "OK1131","fraudResults" : + {"fraudServiceResult" : "no-advice","avsResult" : "0","cvvResult" : "0"},"card" : + {"cardNumber" : "************7977","expiryDate" : "1218"}}},"status" : "PENDING_APPROVAL", + "statusOutput" : {"isCancellable" : true,"statusCategory" : "PENDING_MERCHANT","statusCode" + : 600,"statusCodeChangeDateTime" : "20180118110419","isAuthorized" : true, + "isRefundable" : false}}}/, + headers: [ + {"Date", "Thu, 18 Jan 2018 10:04:19 GMT"}, + {"Server", "Apache/2.4.27 (Unix) OpenSSL/1.0.2l"}, + {"Location", + "https://api-sandbox.globalcollect.com:443/v1/1226/payments/000000122600000000650000100001"}, + {"X-Powered-By", "Servlet/3.0 JSP/2.2"}, + {"Transfer-Encoding", "chunked"}, + {"Content-Type", "application/json"} + ], + request_url: "https://api-sandbox.globalcollect.com/v1/1226/payments", + status_code: 201 + }} + end + + def test_for_authorize_with_invalid_card do + {:ok, + %HTTPoison.Response{ + body: + ~s/{"errorId" : "dcdf5c8d-e475-4fbc-ac57-76123c1640a2","errors" : [ {"code" : "21000120", + "requestId" : "978754","propertyName" : "cardPaymentMethodSpecificInput.card.expiryDate","message": + "cardPaymentMethodSpecificInput.card.expiryDate (1210) IS IN THE PAST OR NOT IN CORRECT MMYY FORMAT", + "httpStatusCode" : 400} ],"paymentResult" : {"creationOutput" : + {"additionalReference" :"00000012260000000066","externalReference" : + "000000122600000000660000100001"},"payment" :{"id" : "000000122600000000660000100001", + "paymentOutput" : {"amountOfMoney" : {"amount" : 500,"currencyCode" : "USD"}, + "references" : {"paymentReference" : "0"},"paymentMethod" : "card", + "cardPaymentMethodSpecificOutput" : {"paymentProductId" : 1}},"status" : "REJECTED", + "statusOutput":{"errors" : [ {"code" : "21000120","requestId" : "978755","propertyName" : + "cardPaymentMethodSpecificInput.card.expiryDate","message" : + "cardPaymentMethodSpecificInput.card.expiryDate (1210) IS IN THE PAST OR NOT IN CORRECT MMYY FORMAT", + "httpStatusCode" : 400} ],"isCancellable" : false,"statusCategory" : + "UNSUCCESSFUL","statusCode" : 100,"statusCodeChangeDateTime" : "20180118111508", + "isAuthorized" : false,"isRefundable" : false}}}}/, + headers: [ + {"Date", "Thu, 18 Jan 2018 10:15:08 GMT"}, + {"Server", "Apache/2.4.27 (Unix) OpenSSL/1.0.2l"}, + {"X-Powered-By", "Servlet/3.0 JSP/2.2"}, + {"Connection", "close"}, + {"Transfer-Encoding", "chunked"}, + {"Content-Type", "application/json"} + ], + request_url: "https://api-sandbox.globalcollect.com/v1/1226/payments", + status_code: 400 + }} + end + + def test_for_authorize_with_invalid_amount do + {:ok, + %HTTPoison.Response{ + body: + ~s/{"errorId" : "1dbef568-ed86-4c8d-a3c3-74ced258d5a2","errors" : [ {"code" : "1099","id" : + "INVALID_VALUE", "category" : "CONNECT_PLATFORM_ERROR","message" : + "INVALID_VALUE: '50.3' is not a valid value for field 'amount'","httpStatusCode" : 400} ]}/, + headers: [ + {"Date", "Tue, 23 Jan 2018 11:18:11 GMT"}, + {"Server", "Apache/2.4.27 (Unix) OpenSSL/1.0.2l"}, + {"X-Powered-By", "Servlet/3.0 JSP/2.2"}, + {"Connection", "close"}, + {"Transfer-Encoding", "chunked"}, + {"Content-Type", "application/json"} + ], + request_url: "https://api-sandbox.globalcollect.com/v1/1226/payments", + status_code: 400 + }} + end + + def test_for_refund do + {:ok, + %HTTPoison.Response{ + body: + ~s/{ "errorId" : "b6ba00d2-8f11-4822-8f32-c6d0a4d8793b", "errors" : [ {"code" : "300450", + "message" : "ORDER WITHOUT REFUNDABLE PAYMENTS", "httpStatusCode" : 400 } ]}/, + headers: [ + {"Date", "Wed, 24 Jan 2018 05:33:56 GMT"}, + {"Server", "Apache/2.4.27 (Unix) OpenSSL/1.0.2l"}, + {"X-Powered-By", "Servlet/3.0 JSP/2.2"}, + {"Connection", "close"}, + {"Transfer-Encoding", "chunked"}, + {"Content-Type", "application/json"} + ], + request_url: + "https://api-sandbox.globalcollect.com/v1/1226/payments/000000122600000000870000100001/refund", + status_code: 400 + }} + end + + def test_for_capture_with_valid_paymentid do + {:ok, + %HTTPoison.Response{ + body: ~s/{ "payment" : {"id" : "000000122600000000650000100001", "paymentOutput" : { + "amountOfMoney" :{"amount" : 50,"currencyCode" : "USD"},"references" : {"paymentReference" + : "0"},"paymentMethod" : "card","cardPaymentMethodSpecificOutput" : {"paymentProductId" : + 1,"authorisationCode" : "OK1131","fraudResults" : {"fraudServiceResult" : "no-advice", + "avsResult" : "0","cvvResult" : "0"},"card" :{"cardNumber" : "************7977", + "expiryDate" : "1218"}}},"status" : "CAPTURE_REQUESTED","statusOutput" : + {"isCancellable" : true,"statusCategory" : "PENDING_CONNECT_OR_3RD_PARTY", + "statusCode" : 800,"statusCodeChangeDateTime" : "20180123140826","isAuthorized" : true, + "isRefundable" : false} }}/, + headers: [ + {"Date", "Tue, 23 Jan 2018 13:08:26 GMT"}, + {"Server", "Apache/2.4.27 (Unix) OpenSSL/1.0.2l"}, + {"X-Powered-By", "Servlet/3.0 JSP/2.2"}, + {"Transfer-Encoding", "chunked"}, + {"Content-Type", "application/json"} + ], + request_url: + "https://api-sandbox.globalcollect.com/v1/1226/payments/000000122600000000650000100001/approve", + status_code: 200 + }} + end + + def test_for_capture_with_invalid_paymentid do + {:ok, + %HTTPoison.Response{ + body: ~s/{ "errorId" : "ccb99804-0240-45b6-bb28-52aaae59d71b", "errors" : [ + {"code" : "1002","id" :"UNKNOWN_PAYMENT_ID","category" : "CONNECT_PLATFORM_ERROR", + "propertyName" : "paymentId","message": "UNKNOWN_PAYMENT_ID","httpStatusCode" :404}]}/, + headers: [ + {"Date", "Tue, 23 Jan 2018 12:25:59 GMT"}, + {"Server", "Apache/2.4.27 (Unix) OpenSSL/1.0.2l"}, + {"X-Powered-By", "Servlet/3.0 JSP/2.2"}, + {"Transfer-Encoding", "chunked"}, + {"Content-Type", "application/json"} + ], + request_url: "https://api-sandbox.globalcollect.com/v1/1226/payments/30/approve", + status_code: 404 + }} + end + + def test_for_void_with_valid_card do + {:ok, + %HTTPoison.Response{ + body: + ~s/{ "payment" : {"id" : "000000122600000000870000100001","paymentOutput" : {"amountOfMoney" + :{"amount" : 50,"currencyCode" : "USD"},"references" : {"paymentReference" : "0"}, + "paymentMethod" : "card","cardPaymentMethodSpecificOutput" : {"paymentProductId" : 1, + "authorisationCode" : "OK1131","fraudResults" : {"fraudServiceResult" : "no-advice", + "avsResult" : "0","cvvResult" : "0"},"card" :{"cardNumber" : "************7977", + "expiryDate" : "1218"}}},"status" : "CANCELLED","statusOutput":{"isCancellable" : + false,"statusCategory" : "UNSUCCESSFUL","statusCode" : 99999, + "statusCodeChangeDateTime" : "20180124064204","isAuthorized" : false,"isRefundable" : + false}}}/, + headers: [ + {"Date", "Wed, 24 Jan 2018 05:42:04 GMT"}, + {"Server", "Apache/2.4.27 (Unix) OpenSSL/1.0.2l"}, + {"X-Powered-By", "Servlet/3.0 JSP/2.2"}, + {"Transfer-Encoding", "chunked"}, + {"Content-Type", "application/json"} + ], + request_url: + "https://api-sandbox.globalcollect.com/v1/1226/payments/000000122600000000870000100001/cancel", + status_code: 200 + }} + end + + def test_for_network_failure do + {:error, %HTTPoison.Error{id: nil, reason: :nxdomain}} + end +end diff --git a/test/mocks/mercadopago_mock.exs b/test/mocks/mercadopago_mock.exs new file mode 100644 index 00000000..14ed9338 --- /dev/null +++ b/test/mocks/mercadopago_mock.exs @@ -0,0 +1,7 @@ +defmodule Gringotts.Gateways.MercadopagoMock do + # The module should include mock responses for test cases in mercadopago_test.exs. + # e.g. + # def successful_purchase do + # {:ok, %HTTPoison.Response{body: ~s[{data: "successful_purchase"}]} + # end +end diff --git a/test/mocks/trexle_mock.exs b/test/mocks/trexle_mock.exs index e169c078..73f5aa9e 100644 --- a/test/mocks/trexle_mock.exs +++ b/test/mocks/trexle_mock.exs @@ -1,239 +1,216 @@ defmodule Gringotts.Gateways.TrexleMock do - def test_for_purchase_with_valid_card do {:ok, - %HTTPoison.Response{ - body: "{\"response\":{\"token\":\"charge_3e89c6f073606ac1efe62e76e22dd7885441dc72\",\"success\":true,\"captured\":false}}", - headers: [ - {"Date", "Fri, 22 Dec 2017 11:57:28 GMT"}, - {"Content-Type", "application/json; charset=UTF-8"}, - {"ETag", "W/\"5a9f44c457a4fdd0478c82ec1af64816\""}, - {"Cache-Control", "max-age=0, private, must-revalidate"}, - {"X-Request-Id", "9b2a1d30-9bca-48f2-862e-4090766689cb"}, - {"X-Runtime", "0.777520"}, - {"Content-Length", "104"}, - {"X-Powered-By", "PleskLin"} - ], - request_url: "https://core.trexle.com/api/v1//charges", - status_code: 201 - } - } + %HTTPoison.Response{ + body: + ~s/{"response":{"token":"charge_3e89c6f073606ac1efe62e76e22dd7885441dc72","success":true,"captured":false}}/, + headers: [ + {"Date", "Fri, 22 Dec 2017 11:57:28 GMT"}, + {"Content-Type", "application/json; charset=UTF-8"}, + {"Cache-Control", "max-age=0, private, must-revalidate"}, + {"X-Request-Id", "9b2a1d30-9bca-48f2-862e-4090766689cb"}, + {"X-Runtime", "0.777520"}, + {"Content-Length", "104"}, + {"X-Powered-By", "PleskLin"} + ], + request_url: "https://core.trexle.com/api/v1//charges", + status_code: 201 + }} end def test_for_purchase_with_invalid_card do {:ok, - %HTTPoison.Response{ - body: "{\"error\":\"Payment failed\",\"detail\":\"Your card's expiration year is invalid.\"}", - headers: [ - {"Date", "Fri, 22 Dec 2017 13:20:50 GMT"}, - {"Content-Type", "application/json; charset=UTF-8"}, - {"Cache-Control", "no-cache"}, - {"X-Request-Id", "eb8100a1-8ffa-47da-9623-8d3b2af51b84"}, - {"X-Runtime", "0.445244"}, - {"Content-Length", "77"}, - {"X-Powered-By", "PleskLin"}, - {"Connection", "close"} - ], - request_url: "https://core.trexle.com/api/v1//charges", - status_code: 400 - } - } + %HTTPoison.Response{ + body: ~s/{"error":"Payment failed","detail":"Your card's expiration year is invalid."}/, + headers: [ + {"Date", "Fri, 22 Dec 2017 13:20:50 GMT"}, + {"Content-Type", "application/json; charset=UTF-8"}, + {"Cache-Control", "no-cache"}, + {"X-Request-Id", "eb8100a1-8ffa-47da-9623-8d3b2af51b84"}, + {"X-Runtime", "0.445244"}, + {"Content-Length", "77"}, + {"X-Powered-By", "PleskLin"}, + {"Connection", "close"} + ], + request_url: "https://core.trexle.com/api/v1//charges", + status_code: 400 + }} end def test_for_purchase_with_invalid_amount do {:ok, - %HTTPoison.Response{ - body: "{\"error\":\"Payment failed\",\"detail\":\"Amount must be at least 50 cents\"}", - headers: [ - {"Date", "Sat, 23 Dec 2017 18:16:33 GMT"}, - {"Content-Type", "application/json; charset=UTF-8"}, - {"Cache-Control", "no-cache"}, - {"X-Request-Id", "4ce2eea4-3ea9-4345-ac85-9bc45f22f5ac"}, - {"X-Runtime", "0.476058"}, - {"Content-Length", "70"}, - {"X-Powered-By", "PleskLin"}, - {"Connection", "close"} - ], - request_url: "https://core.trexle.com/api/v1//charges", - status_code: 400 - } - } + %HTTPoison.Response{ + body: ~s/{"error":"Payment failed","detail":"Amount must be at least 50 cents"}/, + headers: [ + {"Date", "Sat, 23 Dec 2017 18:16:33 GMT"}, + {"Content-Type", "application/json; charset=UTF-8"}, + {"Cache-Control", "no-cache"}, + {"X-Request-Id", "4ce2eea4-3ea9-4345-ac85-9bc45f22f5ac"}, + {"X-Runtime", "0.476058"}, + {"Content-Length", "70"}, + {"X-Powered-By", "PleskLin"}, + {"Connection", "close"} + ], + request_url: "https://core.trexle.com/api/v1//charges", + status_code: 400 + }} end def test_for_authorize_with_valid_card do {:ok, - %HTTPoison.Response{ - body: "{\"response\":{\"token\":\"charge_8ab2b21a2f02495f5c36b34d129c8a0e836add32\",\"success\":true,\"captured\":false}}", - headers: [ - {"Date", "Sat, 23 Dec 2017 18:33:31 GMT"}, - {"Content-Type", "application/json; charset=UTF-8"}, - {"ETag", "W/\"ec4f2df0607614f67286ac46eb994150\""}, - {"Cache-Control", "max-age=0, private, must-revalidate"}, - {"X-Request-Id", "51d28d13-81e5-41fd-b711-1b6531fdd3dd"}, - {"X-Runtime", "0.738395"}, - {"Content-Length", "104"}, - {"X-Powered-By", "PleskLin"} - ], - request_url: "https://core.trexle.com/api/v1//charges", - status_code: 201 - } - } + %HTTPoison.Response{ + body: + ~s/{"response":{"token":"charge_8ab2b21a2f02495f5c36b34d129c8a0e836add32","success":true,"captured":false}}/, + headers: [ + {"Date", "Sat, 23 Dec 2017 18:33:31 GMT"}, + {"Content-Type", "application/json; charset=UTF-8"}, + {"Cache-Control", "max-age=0, private, must-revalidate"}, + {"X-Request-Id", "51d28d13-81e5-41fd-b711-1b6531fdd3dd"}, + {"X-Runtime", "0.738395"}, + {"Content-Length", "104"}, + {"X-Powered-By", "PleskLin"} + ], + request_url: "https://core.trexle.com/api/v1//charges", + status_code: 201 + }} end def test_for_authorize_with_invalid_card do {:ok, - %HTTPoison.Response{ - body: "{\"error\":\"Payment failed\",\"detail\":\"Your card's expiration year is invalid.\"}", - headers: [ - {"Date", "Sat, 23 Dec 2017 18:25:40 GMT"}, - {"Content-Type", "application/json; charset=UTF-8"}, - {"Cache-Control", "no-cache"}, - {"X-Request-Id", "239e7054-9500-4a87-bf3b-09456d550b6d"}, - {"X-Runtime", "0.466670"}, - {"Content-Length", "77"}, - {"X-Powered-By", "PleskLin"}, - {"Connection", "close"} - ], - request_url: "https://core.trexle.com/api/v1//charges", - status_code: 400 - } - } + %HTTPoison.Response{ + body: ~s/{"error":"Payment failed","detail":"Your card's expiration year is invalid."}/, + headers: [ + {"Date", "Sat, 23 Dec 2017 18:25:40 GMT"}, + {"Content-Type", "application/json; charset=UTF-8"}, + {"Cache-Control", "no-cache"}, + {"X-Request-Id", "239e7054-9500-4a87-bf3b-09456d550b6d"}, + {"X-Runtime", "0.466670"}, + {"Content-Length", "77"}, + {"X-Powered-By", "PleskLin"}, + {"Connection", "close"} + ], + request_url: "https://core.trexle.com/api/v1//charges", + status_code: 400 + }} end def test_for_authorize_with_invalid_amount do {:ok, - %HTTPoison.Response{ - body: "{\"error\":\"Payment failed\",\"detail\":\"Amount must be at least 50 cents\"}", - headers: [ - {"Date", "Sat, 23 Dec 2017 18:40:10 GMT"}, - {"Content-Type", "application/json; charset=UTF-8"}, - {"Cache-Control", "no-cache"}, - {"X-Request-Id", "d58db806-8016-4a0e-8519-403a969fa1a7"}, - {"X-Runtime", "0.494636"}, - {"Content-Length", "70"}, - {"X-Powered-By", "PleskLin"}, - {"Connection", "close"} - ], - request_url: "https://core.trexle.com/api/v1//charges", - status_code: 400 - } - } - end - - def test_for_authorize_with_missing_ip_address do - {:ok, - %HTTPoison.Response{body: "{\"error\":\"ip_address is missing\"}", - headers: [ - {"Date", "Thu, 28 Dec 2017 12:22:43 GMT"}, - {"Content-Type", "application/json; charset=UTF-8"}, - {"Cache-Control", "no-cache"}, - {"X-Request-Id", "97bae548-a446-42e9-b792-8c505c38f4c1"}, - {"X-Runtime", "0.005652"}, {"Content-Length", "33"}, - {"X-Powered-By", "PleskLin"}, {"Connection", "close"} - ], - request_url: "https://core.trexle.com/api/v1/charges", - status_code: 500 - } - } + %HTTPoison.Response{ + body: ~s/{"error":"Payment failed","detail":"Amount must be at least 50 cents"}/, + headers: [ + {"Date", "Sat, 23 Dec 2017 18:40:10 GMT"}, + {"Content-Type", "application/json; charset=UTF-8"}, + {"Cache-Control", "no-cache"}, + {"X-Request-Id", "d58db806-8016-4a0e-8519-403a969fa1a7"}, + {"X-Runtime", "0.494636"}, + {"Content-Length", "70"}, + {"X-Powered-By", "PleskLin"}, + {"Connection", "close"} + ], + request_url: "https://core.trexle.com/api/v1//charges", + status_code: 400 + }} end def test_for_refund_with_valid_token do {:ok, - %HTTPoison.Response{ - body: "{\"response\":{\"token\":\"refund_a86a757cc6bdabab50d6ebbfcdcd4db4e04198dd\",\"success\":true,\"amount\":50,\"charge\":\"charge_cb17a0c34e870a479dfa13bd873e7ce7e090ec9b\",\"status_message\":\"Transaction approved\"}}", - headers: [ - {"Date", "Sat, 23 Dec 2017 18:55:41 GMT"}, - {"Content-Type", "application/json; charset=UTF-8"}, - {"ETag", "W/\"7410ae0b45094aadada390f5c947a58a\""}, - {"Cache-Control", "max-age=0, private, must-revalidate"}, - {"X-Request-Id", "b1c94703-7fb4-48f2-b1b4-32e3b6a87e57"}, - {"X-Runtime", "1.097186"}, - {"Content-Length", "198"}, - {"X-Powered-By", "PleskLin"} - ], - request_url: "https://core.trexle.com/api/v1//charges/charge_cb17a0c34e870a479dfa13bd873e7ce7e090ec9b/refunds", - status_code: 201 - } - } + %HTTPoison.Response{ + body: + ~s/{"response":{"token":"refund_a86a757cc6bdabab50d6ebbfcdcd4db4e04198dd","success":true,"amount":50,"charge":"charge_cb17a0c34e870a479dfa13bd873e7ce7e090ec9b","status_message":"Transaction approved"}}/, + headers: [ + {"Date", "Sat, 23 Dec 2017 18:55:41 GMT"}, + {"Content-Type", "application/json; charset=UTF-8"}, + {"Cache-Control", "max-age=0, private, must-revalidate"}, + {"X-Request-Id", "b1c94703-7fb4-48f2-b1b4-32e3b6a87e57"}, + {"X-Runtime", "1.097186"}, + {"Content-Length", "198"}, + {"X-Powered-By", "PleskLin"} + ], + request_url: + "https://core.trexle.com/api/v1//charges/charge_cb17a0c34e870a479dfa13bd873e7ce7e090ec9b/refunds", + status_code: 201 + }} end def test_for_refund_with_invalid_token do {:ok, - %HTTPoison.Response{ - body: "{\"error\":\"Refund failed\",\"detail\":\"invalid token\"}", - headers: [ - {"Date", "Sat, 23 Dec 2017 18:53:09 GMT"}, - {"Content-Type", "application/json; charset=UTF-8"}, - {"Cache-Control", "no-cache"}, - {"X-Request-Id", "276fd8f5-dc21-40be-8add-fa76aabbfc5b"}, - {"X-Runtime", "0.009374"}, - {"Content-Length", "50"}, - {"X-Powered-By", "PleskLin"}, - {"Connection", "close"} - ], - request_url: "https://core.trexle.com/api/v1//charges/34/refunds", - status_code: 400 - } - } + %HTTPoison.Response{ + body: ~s/{"error":"Refund failed","detail":"invalid token"}/, + headers: [ + {"Date", "Sat, 23 Dec 2017 18:53:09 GMT"}, + {"Content-Type", "application/json; charset=UTF-8"}, + {"Cache-Control", "no-cache"}, + {"X-Request-Id", "276fd8f5-dc21-40be-8add-fa76aabbfc5b"}, + {"X-Runtime", "0.009374"}, + {"Content-Length", "50"}, + {"X-Powered-By", "PleskLin"}, + {"Connection", "close"} + ], + request_url: "https://core.trexle.com/api/v1//charges/34/refunds", + status_code: 400 + }} end def test_for_capture_with_valid_chargetoken do {:ok, - %HTTPoison.Response{ - body: "{\"response\":{\"token\":\"charge_cb17a0c34e870a479dfa13bd873e7ce7e090ec9b\",\"success\":true,\"captured\":true,\"amount\":50,\"status_message\":\"Transaction approved\"}}", - headers: [ - {"Date", "Sat, 23 Dec 2017 18:49:50 GMT"}, - {"Content-Type", "application/json; charset=UTF-8"}, - {"ETag", "W/\"26f05a32c0d0a27b180bbe777488fd5f\""}, - {"Cache-Control", "max-age=0, private, must-revalidate"}, - {"X-Request-Id", "97ca2db6-fd4f-4a5b-ae45-01fae9c13668"}, - {"X-Runtime", "1.092051"}, - {"Content-Length", "155"}, - {"X-Powered-By", "PleskLin"} - ], - request_url: "https://core.trexle.com/api/v1//charges/charge_cb17a0c34e870a479dfa13bd873e7ce7e090ec9b/capture", - status_code: 200 - } - } + %HTTPoison.Response{ + body: + ~s/{"response":{"token":"charge_cb17a0c34e870a479dfa13bd873e7ce7e090ec9b","success":true,"captured":true,"amount":50,"status_message":"Transaction approved"}}/, + headers: [ + {"Date", "Sat, 23 Dec 2017 18:49:50 GMT"}, + {"Content-Type", "application/json; charset=UTF-8"}, + {"Cache-Control", "max-age=0, private, must-revalidate"}, + {"X-Request-Id", "97ca2db6-fd4f-4a5b-ae45-01fae9c13668"}, + {"X-Runtime", "1.092051"}, + {"Content-Length", "155"}, + {"X-Powered-By", "PleskLin"} + ], + request_url: + "https://core.trexle.com/api/v1//charges/charge_cb17a0c34e870a479dfa13bd873e7ce7e090ec9b/capture", + status_code: 200 + }} end def test_for_capture_with_invalid_chargetoken do {:ok, - %HTTPoison.Response{ - body: "{\"error\":\"Capture failed\",\"detail\":\"invalid token\"}", - headers: [ - {"Date", "Sat, 23 Dec 2017 18:47:18 GMT"}, - {"Content-Type", "application/json; charset=UTF-8"}, - {"Cache-Control", "no-cache"}, - {"X-Request-Id", "b46ecb8d-7df8-4c5f-b87f-c53fae364e79"}, - {"X-Runtime", "0.010255"}, - {"Content-Length", "51"}, - {"X-Powered-By", "PleskLin"}, - {"Connection", "close"} - ], - request_url: "https://core.trexle.com/api/v1//charges/30/capture", - status_code: 400 - } - } + %HTTPoison.Response{ + body: ~s/{"error":"Capture failed","detail":"invalid token"}/, + headers: [ + {"Date", "Sat, 23 Dec 2017 18:47:18 GMT"}, + {"Content-Type", "application/json; charset=UTF-8"}, + {"Cache-Control", "no-cache"}, + {"X-Request-Id", "b46ecb8d-7df8-4c5f-b87f-c53fae364e79"}, + {"X-Runtime", "0.010255"}, + {"Content-Length", "51"}, + {"X-Powered-By", "PleskLin"}, + {"Connection", "close"} + ], + request_url: "https://core.trexle.com/api/v1//charges/30/capture", + status_code: 400 + }} end def test_for_store_with_valid_card do {:ok, - %HTTPoison.Response{ - body: "{\"response\":{\"token\":\"token_94e333959850270460e89a86bad2246613528cbb\",\"card\":{\"token\":\"token_2a1ba29522e4a377fafa62e8e204f76ad8ba8f1e\",\"scheme\":\"master\",\"display_number\":\"XXXX-XXXX-XXXX-8210\",\"expiry_year\":2018,\"expiry_month\":1,\"cvc\":123,\"name\":\"John Doe\",\"address_line1\":\"456 My Street\",\"address_line2\":null,\"address_city\":\"Ottawa\",\"address_state\":\"ON\",\"address_postcode\":\"K1C2N6\",\"address_country\":\"CA\",\"primary\":true}}}", - headers: [ - {"Date", "Sat, 23 Dec 2017 19:32:58 GMT"}, - {"Content-Type", "application/json; charset=UTF-8"}, - {"ETag", "W/\"c4089eabe907fc2327dd565503242b58\""}, - {"Cache-Control", "max-age=0, private, must-revalidate"}, - {"X-Request-Id", "1a334b22-8e01-4e1b-8b58-90dfd0b7c12f"}, - {"X-Runtime", "0.122441"}, - {"Content-Length", "422"}, - {"X-Powered-By", "PleskLin"} - ], - request_url: "https://core.trexle.com/api/v1//customers", - status_code: 201 - } - } + %HTTPoison.Response{ + body: + ~s/{"response":{"token":"token_94e333959850270460e89a86bad2246613528cbb","card":{"token":"token_2a1ba29522e4a377fafa62e8e204f76ad8ba8f1e","scheme":"master","display_number":"XXXX-XXXX-XXXX-8210","expiry_year":2018,"expiry_month":1,"cvc":123,"name":"John Doe","address_line1":"456 My Street","address_line2":null,"address_city":"Ottawa","address_state":"ON","address_postcode":"K1C2N6","address_country":"CA","primary":true}}}/, + headers: [ + {"Date", "Sat, 23 Dec 2017 19:32:58 GMT"}, + {"Content-Type", "application/json; charset=UTF-8"}, + {"Cache-Control", "max-age=0, private, must-revalidate"}, + {"X-Request-Id", "1a334b22-8e01-4e1b-8b58-90dfd0b7c12f"}, + {"X-Runtime", "0.122441"}, + {"Content-Length", "422"}, + {"X-Powered-By", "PleskLin"} + ], + request_url: "https://core.trexle.com/api/v1//customers", + status_code: 201 + }} + end + + def test_for_network_failure do + {:error, %HTTPoison.Error{id: :some_hackney_error_id, reason: :some_hackney_error}} end end