diff --git a/.vscode/settings.json b/.vscode/settings.json index 599e28a51f..1241cb4504 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,5 +14,8 @@ }, "css.validate": false, "less.validate": false, - "scss.validate": false + "scss.validate": false, + "cSpell.words": [ + "emailaddress" + ] } diff --git a/core/.vscode/settings.json b/core/.vscode/settings.json new file mode 100644 index 0000000000..db696dda48 --- /dev/null +++ b/core/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "cSpell.words": [ + "emailaddress", + "idempotency", + "Nestru", + "uuid" + ] +} \ No newline at end of file diff --git a/core/config/config.exs b/core/config/config.exs index 426e1ce404..c2434a3757 100644 --- a/core/config/config.exs +++ b/core/config/config.exs @@ -166,3 +166,11 @@ unless is_nil(bundle) do end import_config "#{config_env()}.exs" + +if Mix.env() == :dev do + config :mix_test_watch, + clear: true + + config :mix_test_watch, + exclude: [~r/\.#/, ~r{priv/repo/migrations}, ~r/assets\/.*/] +end diff --git a/core/config/runtime.exs b/core/config/runtime.exs index 67bbe94a5f..9bd1542acc 100644 --- a/core/config/runtime.exs +++ b/core/config/runtime.exs @@ -120,6 +120,11 @@ if config_env() == :prod do config :logger, level: System.get_env("LOG_LEVEL", "info") |> String.to_existing_atom() + config :core, + opp_client_options: [ + auth: {:bearer, System.fetch_env!("OPP_API_KEY")} + ] + if sentry_dsn = System.get_env("SENTRY_DSN") do config :sentry, dsn: sentry_dsn, diff --git a/core/lib/opp_client.ex b/core/lib/opp_client.ex new file mode 100644 index 0000000000..a0cf600b53 --- /dev/null +++ b/core/lib/opp_client.ex @@ -0,0 +1,175 @@ +defmodule OPPClient do + use OPPClient.Helper + + defmodule OPPResponses do + end + + @base_url "https://api-sandbox.onlinebetaalplatform.nl/v1" + + # @api_key "79eeea74cb5685779ac17f5758ddc5e0" + + def new(opts \\ []) do + [ + base_url: @base_url + ] + |> Keyword.merge(opts) + |> Req.new() + end + + def_req(:post, "/merchants", + type: [ + type: {:in, ["consumer", "business"]} + ], + country: [ + # TODO: Download ISO list on build: + # https://raw.githubusercontent.com/lukes/ISO-3166-Countries-with-Regional-Codes/master/all/all.json + type: {:in, ["nld"]}, + required: true + ], + emailaddress: [ + type: :string, + required: true + ], + notify_url: [ + type: :string, + required: true + ] + ) + + def_req(:get, "/merchants/{{merchant_uuid}}") + + def_req(:post, "/transactions", + merchant_uid: [type: :string, required: true], + locale: [type: {:in, ["nl", "en", "fr", "de"]}], + total_price: [type: :pos_integer, required: true], + products: [ + type: + {:list, + {:map, + [ + name: [type: :string, required: true], + quantity: [type: :pos_integer, required: true], + price: [type: :pos_integer, required: true] + ]}}, + required: true + ], + return_url: [type: :string, required: true], + notify_url: [type: :string, required: true], + metadata: [type: :map] + ) + + def_req(:post, "/merchants/{{merchant_uid}}/withdrawals", + amount: [type: :pos_integer, required: true], + currency: [type: :string], + partner_fee: [type: :pos_integer], + notify_url: [type: :string, required: true], + description: [type: :string, required: true], + reference: [type: :string], + metadata: [type: :map] + ) + + def_req(:post, "/charges", + type: [type: {:in, ["balance"]}, required: true], + amount: [type: :pos_integer, required: true], + currency: [type: :string], + description: [type: :string], + payout_description: [type: :string], + to_owner_uid: [type: :string, required: true], + from_owner_uid: [type: :string, required: true], + metadata: [type: :string] + ) + + # create + # retrieve + # update + # delete + + # type string Merchant type. One of: + # consumerbusiness + # country string Country code of the merchant, + # use ISO 3166-1 alpha-3 country code. + # locale string The language in which the text on the verification screens is being displayed and tickets are sent. Default is en + # One of: + # nl en fr de + # name_first string First name of the merchant. ( CONSUMER ONLY! ) + # name_last string Last name of the merchant. ( CONSUMER ONLY! ) + # is_pep boolean Whether or not the merchant is a PEP. This will mark the contact that is automatically created as a PEP. ( CONSUMER ONLY! ) + # coc_nr string Chamber of Commerce number of the merchant. + # up to 45 characters + # nullable + # ( BUSINESS ONLY! ) + # vat_nr string Value added tax identification number. + # up to 45 characters + # ( BUSINESS ONLY! ) + # legal_name string (Business) Name of the merchant. + # up to 45 characters + # legal_entity string Business entity of the merchant. One of the legal_entity_code from the legal entity list ( BUSINESS ONLY! ) + # trading_names array Array with one or more trading names. + + # name + # string + + # Trading name. + # emailaddress string Email address of the merchant. + # Must be unique for every merchant. + # phone string Phone number of the merchant. + # settlement_interval string The settlement interval of the merchant. Default is set contractually. Can only be provided after agreement with OPP. + # One of: + # daily weekly monthly yearly continuous + # notify_url string URL to which the notifications of the merchant will be sent. + # return_url string URL to which the merchant will be directed when closing the verification screens. + # metadata object with key-value pairs Additional data that belongs to the merchant object. + # addresses array Address array of the merchant with name/value pairs. + + # 200 OK Success + # 400 Bad Request Missing parameter(s) + # 401 Unauthorized Invalid or revoked API key + # 404 Not Found Resource doesn't exist + # 409 Conflict Conflict due to concurrent request + # 410 Gone Resource doesn't exist anymore + # 50X Server Errors Temporary problem on our side + + # api_key (env var) + + # API Status + + # Example request - Status check + + # curl https://api-sandbox.onlinebetaalplatform.nl/status \ + # -H "Authorization: Bearer {{api_key}}" + + # Example response + + # { + # "status": "online", + # "date": 1611321273 + # } + + # Idempotency-Key: {key} + + # Pagination + + # Example request - Retrieve page 2 of the transactions list: + + # curl https://api-sandbox.onlinebetaalplatform.nl/v1/transactions?page=2&perpage=10 \ + # -H "Authorization: Bearer {{api_key}}" + + # Example response: + + # { + # "livemode": true, + # "object": "list", + # "url": "/v1/transactions", + # "has_more": true, + # "total_item_count": 259, + # "items_per_page": 10, + # "current_page": 2, + # "last_page": 26, + # "data": [] + # } + + # When retrieving lists of objects, OPP creates pages to keep the transferred objects small. Use the pagination functionality to navigate when sorting through many results. The pages can be switched by adding the following parameters to the GET call: + # Parameter Description + # page integer The number of the current page. + # perpage integer The limit of objects to be returned. Limit can range between 1 and 100 items. +end diff --git a/core/lib/opp_client/get_merchants_by_merchant_uuid.json b/core/lib/opp_client/get_merchants_by_merchant_uuid.json new file mode 100644 index 0000000000..650e86e9f3 --- /dev/null +++ b/core/lib/opp_client/get_merchants_by_merchant_uuid.json @@ -0,0 +1,78 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "https://docs.onlinepaymentplatform.com/#create-merchant", + "type": "object", + "properties": { + "uid": { + "type": "string", + "minLength": 1 + }, + "object": { + "type": "string", + "minLength": 1 + }, + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "status": { + "type": "string", + "minLength": 1 + }, + "compliance": { + "type": "object", + "properties": { + "level": { + "type": "number" + }, + "status": { + "type": "string", + "minLength": 1 + }, + "overview_url": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "level", + "status", + "overview_url" + ] + }, + "type": { + "type": "string", + "minLength": 1 + }, + "coc_nr": { + "type": "object", + "properties": {} + }, + "name": { + "type": "string", + "minLength": 1 + }, + "phone": { + "type": "string", + "minLength": 1 + }, + "country": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "uid", + "object", + "created", + "updated", + "status", + "compliance", + "type", + "name", + "phone", + "country" + ] +} \ No newline at end of file diff --git a/core/lib/opp_client/helper.ex b/core/lib/opp_client/helper.ex new file mode 100644 index 0000000000..25f39ee3bd --- /dev/null +++ b/core/lib/opp_client/helper.ex @@ -0,0 +1,96 @@ +defmodule OPPClient.Helper do + @path_param_re ~r/\{\{(.*)\}\}/ + + defmacro __using__(_opts) do + quote do + def get(client, path, params \\ []) + def post(client, path, params \\ []) + + import OPPClient.Helper + end + end + + defmacro def_req(method, path, schema \\ []) do + response_schema_name = get_response_schema_name(method, path) + positional_args = get_positional_args(path) + + schema = get_finalized_schema(schema, method, positional_args) + + response_schema_path = Path.join(__DIR__, "#{response_schema_name}.json") + + response_schema = + response_schema_path + |> File.read!() + |> Jason.decode!() + |> ExJsonSchema.Schema.resolve() + + quote do + # Ensures recompilation on schema file changes + @external_resource unquote(Path.relative_to_cwd(response_schema_path)) + + def unquote(method)(client, unquote(path), params) do + unquote(__MODULE__).request( + client, + unquote(method), + unquote(path), + unquote(positional_args), + unquote(Macro.escape(schema)), + unquote(Macro.escape(response_schema)), + params + ) + end + end + end + + def request(client, method, path, positional_args, schema, response_json_schema, params) do + path_with_args = + Regex.replace(@path_param_re, path, fn _, param -> + Keyword.fetch!(params, String.to_atom(param)) + end) + + {idempotency_key, body_params} = + params + |> Keyword.drop(positional_args) + |> Keyword.pop(:idempotency_key, []) + + request = + %{client | method: method} + |> Req.Request.put_header("idempotency-key", idempotency_key) + + with {:ok, _} <- NimbleOptions.validate(params, schema), + {:ok, response} <- + Req.request(request, + url: path_with_args, + json: Map.new(body_params) + ), + :ok <- ExJsonSchema.Validator.validate(response_json_schema, response.body) do + {:ok, response.body} + end + end + + def get_response_schema_name(method, path) do + name = + String.trim(path, "/") + |> String.replace(@path_param_re, "by_\\1", global: true) + |> String.replace("/", "_") + + "#{method}_#{name}" |> String.to_atom() + end + + def get_positional_args(path) do + Regex.scan(@path_param_re, path) + |> Enum.map(fn [_, param] -> String.to_atom(param) end) + end + + def get_finalized_schema(schema, method, positional_args) do + schema = + schema ++ Enum.map(positional_args, fn arg -> {arg, [type: :string, required: true]} end) + + # Require idempotency key on mutation + if method == :post do + [{:idempotency_key, [type: :string, required: true]} | schema] + else + schema + end + end +end diff --git a/core/lib/opp_client/post_charges.json b/core/lib/opp_client/post_charges.json new file mode 100644 index 0000000000..29aefc3633 --- /dev/null +++ b/core/lib/opp_client/post_charges.json @@ -0,0 +1,77 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "", + "type": "object", + "properties": { + "uid": { + "type": "string", + "minLength": 1 + }, + "object": { + "type": "string", + "minLength": 1 + }, + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "settled": { + "type": "number" + }, + "type": { + "type": "string", + "minLength": 1 + }, + "from_merchant_uid": { + "type": "string", + "minLength": 1 + }, + "from_profile_uid": { + "type": "string", + "minLength": 1 + }, + "to_merchant_uid": { + "type": "string", + "minLength": 1 + }, + "to_profile_uid": { + "type": "string", + "minLength": 1 + }, + "amount": { + "type": "number" + }, + "description": { + "type": "string", + "minLength": 1 + }, + "metadata": { + "type": "array", + "items": { + "properties": {} + } + }, + "currency": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "uid", + "object", + "created", + "updated", + "settled", + "type", + "from_merchant_uid", + "from_profile_uid", + "to_merchant_uid", + "to_profile_uid", + "amount", + "description", + "metadata", + "currency" + ] +} \ No newline at end of file diff --git a/core/lib/opp_client/post_merchants.json b/core/lib/opp_client/post_merchants.json new file mode 100644 index 0000000000..650e86e9f3 --- /dev/null +++ b/core/lib/opp_client/post_merchants.json @@ -0,0 +1,78 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "https://docs.onlinepaymentplatform.com/#create-merchant", + "type": "object", + "properties": { + "uid": { + "type": "string", + "minLength": 1 + }, + "object": { + "type": "string", + "minLength": 1 + }, + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "status": { + "type": "string", + "minLength": 1 + }, + "compliance": { + "type": "object", + "properties": { + "level": { + "type": "number" + }, + "status": { + "type": "string", + "minLength": 1 + }, + "overview_url": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "level", + "status", + "overview_url" + ] + }, + "type": { + "type": "string", + "minLength": 1 + }, + "coc_nr": { + "type": "object", + "properties": {} + }, + "name": { + "type": "string", + "minLength": 1 + }, + "phone": { + "type": "string", + "minLength": 1 + }, + "country": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "uid", + "object", + "created", + "updated", + "status", + "compliance", + "type", + "name", + "phone", + "country" + ] +} \ No newline at end of file diff --git a/core/lib/opp_client/post_merchants_by_merchant_uid_withdrawals.json b/core/lib/opp_client/post_merchants_by_merchant_uid_withdrawals.json new file mode 100644 index 0000000000..bbac60b484 --- /dev/null +++ b/core/lib/opp_client/post_merchants_by_merchant_uid_withdrawals.json @@ -0,0 +1,177 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "", + "type": "object", + "properties": { + "livemode": { + "type": "boolean" + }, + "uid": { + "type": "string", + "minLength": 1 + }, + "object": { + "type": "string", + "minLength": 1 + }, + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "completed": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": {} + } + ] + }, + "execution": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "number" + } + ] + }, + "expected": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "minLength": 1 + }, + "status_reason": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + }, + "statuses": { + "type": "array", + "items": { + "properties": {} + } + }, + "reference": { + "type": "string", + "minLength": 1 + }, + "amount": { + "type": "number" + }, + "currency": { + "type": "string", + "minLength": 1 + }, + "receiver": { + "type": "string", + "minLength": 1 + }, + "receiver_details": { + "type": "object", + "properties": { + "object": { + "type": "string", + "minLength": 1 + }, + "receiver_name": { + "type": "string", + "minLength": 1 + }, + "receiver_account": { + "type": "string", + "minLength": 1 + }, + "receiver_iban": { + "type": "string", + "minLength": 1 + }, + "receiver_bic": { + "type": "string", + "minLength": 1 + }, + "receiver_type": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "object", + "receiver_name", + "receiver_account", + "receiver_iban", + "receiver_sort_code", + "receiver_bic", + "receiver_type" + ] + }, + "description": { + "type": "string", + "minLength": 1 + }, + "notify_url": { + "type": "string", + "minLength": 1 + }, + "metadata": { + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string", + "minLength": 1 + } + } + } + }, + "fees": { + "type": "object", + "properties": {} + } + }, + "required": [ + "livemode", + "uid", + "object", + "created", + "updated", + "completed", + "execution", + "expected", + "status", + "status_reason", + "statuses", + "reference", + "amount", + "currency", + "receiver", + "receiver_details", + "description", + "notify_url", + "metadata", + "fees" + ] +} \ No newline at end of file diff --git a/core/lib/opp_client/post_transactions.json b/core/lib/opp_client/post_transactions.json new file mode 100644 index 0000000000..f84014a93c --- /dev/null +++ b/core/lib/opp_client/post_transactions.json @@ -0,0 +1,174 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "", + "type": "object", + "properties": { + "uid": { + "type": "string", + "minLength": 1 + }, + "object": { + "type": "string", + "minLength": 1 + }, + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "merchant_uid": { + "type": "string", + "minLength": 1 + }, + "profile_uid": { + "type": "string", + "minLength": 1 + }, + "has_checkout": { + "type": "boolean" + }, + "payment_flow": { + "type": "string", + "minLength": 1 + }, + "amount": { + "type": "number" + }, + "return_url": { + "type": "string", + "minLength": 1 + }, + "redirect_url": { + "type": "string", + "minLength": 1 + }, + "notify_url": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": [ + "created", + "pending", + "planned", + "completed", + "reserved", + "cancelled", + "failed", + "expired", + "refunded", + "chargeback" + ] + }, + "metadata": { + "type": "array", + "items": { + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string", + "minLength": 1 + } + } + } + }, + "statuses": { + "type": "array", + "items": { + "required": [ + "uid", + "object", + "created", + "updated", + "status" + ], + "properties": { + "uid": { + "type": "string", + "minLength": 1 + }, + "object": { + "type": "string", + "minLength": 1 + }, + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "status": { + "type": "string", + "minLength": 1 + } + } + } + }, + "order": { + "type": "array", + "items": { + "properties": {} + } + }, + "fees": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "partner_gateway_fee": { + "type": "number" + }, + "transaction_fee": { + "type": "number" + }, + "merchant_gateway_fee": { + "type": "number" + }, + "merchant_transaction_fee": { + "type": "number" + }, + "payable_amount": { + "type": "number" + } + } + } + ] + } + }, + "required": [ + "uid", + "object", + "created", + "updated", + "completed", + "merchant_uid", + "profile_uid", + "has_checkout", + "payment_method", + "payment_flow", + "payment_details", + "amount", + "return_url", + "redirect_url", + "notify_url", + "status", + "metadata", + "statuses", + "order", + "escrow", + "fees", + "refunds" + ] +} \ No newline at end of file diff --git a/core/mix.exs b/core/mix.exs index f363ef73e7..14a1c130dc 100644 --- a/core/mix.exs +++ b/core/mix.exs @@ -35,6 +35,9 @@ defmodule Core.MixProject do # :race_conditions, :no_opaque ] + ], + preferred_cli_env: [ + "test.watch": :test ] ] end @@ -98,6 +101,7 @@ defmodule Core.MixProject do {:kadabra, "~> 0.6.0"}, {:oban, "~> 2.13.3"}, {:nimble_parsec, "~> 1.2"}, + {:nimble_options, "~> 1.1"}, {:typed_struct, "~> 0.2.1"}, {:logger_json, "~> 4.3"}, {:statistics, "~> 0.6.2"}, @@ -105,6 +109,9 @@ defmodule Core.MixProject do {:sentry, "~> 8.0"}, {:libcluster, "~> 3.3"}, {:mime, "~> 2.0"}, + {:ex_json_schema, "~> 0.10.2"}, + {:nestru, "~> 1.0"}, + {:req, "~> 0.4.0"}, # i18n {:ex_cldr, "~> 2.25"}, {:ex_cldr_numbers, "~> 2.23"}, @@ -115,6 +122,8 @@ defmodule Core.MixProject do # Optional, but recommended for SSL validation with :httpc adapter {:ssl_verify_fun, "~> 1.1"}, # Dev and test deps + {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false}, + {:ex_unit_notifier, "~> 1.2", only: :test}, {:file_system, "~> 0.2", only: [:dev, :test]}, {:bypass, "~> 2.1", only: :test}, {:mox, "~> 1.0", only: :test}, diff --git a/core/mix.lock b/core/mix.lock index 96dced2e67..5e2fb59819 100644 --- a/core/mix.lock +++ b/core/mix.lock @@ -43,11 +43,14 @@ "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.30.1", "9acd7adb30079057ba606d73ffdaccb86020b07b734f98a229b5674032181668", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.35", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, ">= 2.14.2", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8a81b63d595a589e72c8b629653755c8b88edd6405a657eba236477f7a85939a"}, "ex_cldr_plugs": {:hex, :ex_cldr_plugs, "1.2.1", "fa8339e0be9be6296edfcacafc1ecddda80c88b33ef689c77f757e48b7e4b935", [:mix], [{:ex_cldr, "~> 2.29", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c99fa57e265e1746262d09f2f6612164c9706b612a546854f75122c7c14c72c9"}, "ex_doc": {:hex, :ex_doc, "0.29.3", "f07444bcafb302db86e4f02d8bbcd82f2e881a0dcf4f3e4740e4b8128b9353f7", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3dc6787d7b08801ec3b51e9bd26be5e8826fbf1a17e92d1ebc252e1a1c75bfe1"}, + "ex_json_schema": {:hex, :ex_json_schema, "0.10.2", "7c4b8c1481fdeb1741e2ce66223976edfb9bccebc8014f6aec35d4efe964fb71", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "37f43be60f8407659d4d0155a7e45e7f406dab1f827051d3d35858a709baf6a6"}, "ex_phone_number": {:hex, :ex_phone_number, "0.3.0", "eb2d56320c71663c9620775c9b3f0c819bb92a015a88607fac6b41b118d9edf9", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "194ffd0a03340049a12abb41370c9820260db7cd288e9581e1b200aa01f28a5c"}, + "ex_unit_notifier": {:hex, :ex_unit_notifier, "1.3.0", "1d82aa6d2fb44e6f0f219142661a46e13dcba833e150e1395190d2e0fb721990", [:mix], [], "hexpm", "55fffd6062e8d962fc44e8b06fa30a87dc7251ee2a69f520781a3bb29858c365"}, "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, "exsync": {:hex, :exsync, "0.2.4", "5cdc824553e0f4c4bf60018a9a6bbd5d3b51f93ef8401a0d8545f93127281d03", [:mix], [{:file_system, "~> 0.2", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f7622d8bb98abbe473aa066ae46f91afdf7a5346b8b89728404f7189d2e80896"}, "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, "floki": {:hex, :floki, "0.34.2", "5fad07ef153b3b8ec110b6b155ec3780c4b2c4906297d0b4be1a7162d04a7e02", [:mix], [], "hexpm", "26b9d50f0f01796bc6be611ca815c5e0de034d2128e39cc9702eee6b66a4d1c8"}, "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, "gettext": {:hex, :gettext, "0.22.1", "e7942988383c3d9eed4bdc22fc63e712b655ae94a672a27e4900e3d4a2c43581", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "ad105b8dab668ee3f90c0d3d94ba75e9aead27a62495c101d94f2657a190ac5d"}, @@ -71,10 +74,14 @@ "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"}, + "mint": {:hex, :mint, "1.6.0", "88a4f91cd690508a04ff1c3e28952f322528934be541844d54e0ceb765f01d5e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "3c5ae85d90a5aca0a49c0d8b67360bbe407f3b54f1030a111047ff988e8fefaa"}, + "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, "mox": {:hex, :mox, "1.0.2", "dc2057289ac478b35760ba74165b4b3f402f68803dd5aecd3bfd19c183815d64", [:mix], [], "hexpm", "f9864921b3aaf763c8741b5b8e6f908f44566f1e427b2630e89e9a73b981fef2"}, - "nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"}, + "nestru": {:hex, :nestru, "1.0.1", "f02321db91b898da3d598c274f2ccba2c41ec5c50c942eabe900474dbfe4bce3", [:mix], [], "hexpm", "e4fbbd6d64b1c8cb37ef590a891f0b6b17b0b880c1c5ce2ac98de02c0ad7417e"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_ownership": {:hex, :nimble_ownership, "0.3.1", "99d5244672fafdfac89bfad3d3ab8f0d367603ce1dc4855f86a1c75008bce56f", [:mix], [], "hexpm", "4bf510adedff0449a1d6e200e43e57a814794c8b5b6439071274d248d272a549"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "oban": {:hex, :oban, "2.13.6", "a0cb1bce3bd393770512231fb5a3695fa19fd3af10d7575bf73f837aee7abf43", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c1c5eb16f377b3cbbf2ea14be24d20e3d91285af9d1ac86260b7c2af5464887"}, "parallel_stream": {:hex, :parallel_stream, "1.1.0", "f52f73eb344bc22de335992377413138405796e0d0ad99d995d9977ac29f1ca9", [:mix], [], "hexpm", "684fd19191aedfaf387bbabbeb8ff3c752f0220c8112eb907d797f4592d6e871"}, "parent": {:hex, :parent, "0.12.1", "495c4386f06de0df492e0a7a7199c10323a55e9e933b27222060dd86dccd6d62", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2ab589ef1f37bfcedbfb5ecfbab93354972fb7391201b8907a866dadd20b39d1"}, @@ -97,6 +104,7 @@ "progress_bar": {:hex, :progress_bar, "2.0.1", "7b40200112ae533d5adceb80ff75fbe66dc753bca5f6c55c073bfc122d71896d", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "2519eb58a2f149a3a094e729378256d8cb6d96a259ec94841bd69fdc71f18f87"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "remote_ip": {:hex, :remote_ip, "1.1.0", "cb308841595d15df3f9073b7c39243a1dd6ca56e5020295cb012c76fbec50f2d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "616ffdf66aaad6a72fc546dabf42eed87e2a99e97b09cbd92b10cc180d02ed74"}, + "req": {:hex, :req, "0.4.14", "103de133a076a31044e5458e0f850d5681eef23dfabf3ea34af63212e3b902e2", [:mix], [{:aws_signature, "~> 0.3.2", [hex: :aws_signature, repo: "hexpm", optional: true]}, {:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:nimble_ownership, "~> 0.2.0 or ~> 0.3.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2ddd3d33f9ab714ced8d3c15fd03db40c14dbf129003c4a3eb80fac2cc0b1b08"}, "sentry": {:hex, :sentry, "8.1.0", "8d235b62fce5f8e067ea1644e30939405b71a5e1599d9529ff82899d11d03f2b", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "f9fc7641ef61e885510f5e5963c2948b9de1de597c63f781e9d3d6c9c8681ab4"}, "socket": {:hex, :socket, "0.3.13", "98a2ab20ce17f95fb512c5cadddba32b57273e0d2dba2d2e5f976c5969d0c632", [:mix], [], "hexpm", "f82ea9833ef49dde272e6568ab8aac657a636acb4cf44a7de8a935acb8957c2e"}, "sourceror": {:hex, :sourceror, "0.12.2", "2ae55efd149193572e0eb723df7c7a1bda9ab33c43373c82642931dbb2f4e428", [:mix], [], "hexpm", "7ad74ade6fb079c71f29fae10c34bcf2323542d8c51ee1bcd77a546cfa89d59c"}, diff --git a/core/test/opp_client_test.exs b/core/test/opp_client_test.exs new file mode 100644 index 0000000000..0be25f3b04 --- /dev/null +++ b/core/test/opp_client_test.exs @@ -0,0 +1,259 @@ +defmodule BankingClientTest do + use ExUnit.Case, async: true + + setup do + client = OPPClient.new(plug: {Req.Test, OPPClient}, base_url: "http://example.com/test") + {:ok, client: client} + end + + describe "post /merchant" do + test "creates and returns merchant", %{client: client} do + response_json = %{ + "compliance" => %{ + "level" => 100, + "overview_url" => Faker.Internet.url(), + "requirements" => [], + "status" => "verified" + }, + "country" => "nld", + "created" => 1_554_113_700, + "name" => Faker.Person.name(), + "object" => "merchant", + "phone" => "31601234567", + "status" => "pending", + "type" => "consumer", + "uid" => "{{merchant_uid}}", + "updated" => 1_554_113_700 + } + + Req.Test.stub(OPPClient, fn conn -> + assert %{method: "POST", request_path: "/test/merchants", req_headers: req_headers} = conn + assert Enum.any?(req_headers, fn {header, _} -> header == "idempotency-key" end) + + Req.Test.json(conn, response_json) + end) + + assert {:ok, ^response_json} = + OPPClient.post( + client, + "/merchants", + idempotency_key: "test", + type: "consumer", + country: "nld", + emailaddress: "test@example.com", + notify_url: "" + ) + end + end + + describe "get /merchants_by_merchant_uuid" do + test "returns merchant", %{client: client} do + response_json = %{ + "compliance" => %{ + "level" => 100, + "overview_url" => Faker.Internet.url(), + "requirements" => [], + "status" => "verified" + }, + "country" => "nld", + "created" => 1_554_113_700, + "name" => Faker.Person.name(), + "object" => "merchant", + "phone" => "31601234567", + "status" => "pending", + "type" => "consumer", + "uid" => "{{merchant_uid}}", + "updated" => 1_554_113_700 + } + + Req.Test.stub(OPPClient, fn conn -> + assert %{method: "GET", request_path: "/test/merchants/123"} = conn + Req.Test.json(conn, response_json) + end) + + assert {:ok, ^response_json} = + OPPClient.get(client, "/merchants/{{merchant_uuid}}", merchant_uuid: "123") + end + end + + describe "post /transactions" do + test "creates and returns transaction", %{client: client} do + response_json = %{ + "amount" => 250, + "completed" => nil, + "created" => 1_613_741_183, + "escrow" => nil, + "fees" => %{}, + "has_checkout" => false, + "merchant_uid" => "{{merchant_uid}}", + "metadata" => [%{"key" => "external_id", "value" => "2015486"}], + "notify_url" => "https://platform.example.com/notify/", + "object" => "transaction", + "order" => [], + "payment_details" => [], + "payment_flow" => "direct", + "payment_method" => nil, + "profile_uid" => "{{profile_uid}}", + "redirect_url" => + "https://sandbox.onlinebetaalplatform.nl/nl/6bfa1c3e1d1d/betalen/verzendgegevens/{{transaction_uid}}?vc=db2242295ee6565a7b2c8b69632ff530", + "refunds" => %{}, + "return_url" => "https://platform.example.com/return/", + "status" => "created", + "statuses" => [ + %{ + "created" => 1_613_741_183, + "object" => "status", + "status" => "created", + "uid" => "sta_8b03f99bbd54", + "updated" => 1_613_741_183 + } + ], + "uid" => "{{transaction_uid}}", + "updated" => 1_613_741_183 + } + + Req.Test.stub(OPPClient, fn conn -> + assert %{method: "POST", request_path: "/test/transactions", req_headers: req_headers} = + conn + + assert Enum.any?(req_headers, fn {header, _} -> header == "idempotency-key" end) + + Req.Test.json(conn, response_json) + end) + + assert {:ok, ^response_json} = + OPPClient.post( + client, + "/transactions", + idempotency_key: "test", + merchant_uid: Faker.UUID.v4(), + locale: "nl", + total_price: 10, + products: [ + %{ + name: Faker.Pokemon.name(), + quantity: 1, + price: 10 + } + ], + return_url: Faker.Internet.url(), + notify_url: Faker.Internet.url() + ) + end + end + + describe "post /merchants/{{merchant_uid}}/withdrawals" do + test "creates and returns withdrawal", %{client: client} do + merchant_uid = Faker.UUID.v4() + + response_json = %{ + "amount" => -100, + "completed" => nil, + "created" => 1_651_220_718, + "currency" => "EUR", + "description" => "Withdrawal", + "execution" => nil, + "expected" => "next_day", + "fees" => %{}, + "livemode" => false, + "metadata" => [%{"key" => "external_id", "value" => "2015486"}], + "notify_url" => "https://platform.example.com/notify/", + "object" => "withdrawal", + "receiver" => "self", + "receiver_details" => %{ + "object" => "withdrawal_receiver", + "receiver_account" => "NL53***********370", + "receiver_bic" => "INGBNL2A", + "receiver_iban" => "NL53***********370", + "receiver_name" => "Hr E G H Küppers en/of MW M.J. Küppers-Veeneman", + "receiver_sort_code" => nil, + "receiver_type" => "bank" + }, + "reference" => "withdrawal-ABC123", + "status" => "pending", + "status_reason" => nil, + "statuses" => [ + %{ + "created" => 1_651_220_718, + "object" => "status", + "status" => "created", + "uid" => "wds_03a387e14549", + "updated" => 1_651_220_718 + }, + %{ + "created" => 1_651_220_718, + "object" => "status", + "status" => "pending", + "uid" => "wds_5fdd7c529fc5", + "updated" => 1_651_220_718 + } + ], + "uid" => "{{withdrawal_uid}}", + "updated" => 1_651_220_718 + } + + Req.Test.stub(OPPClient, fn conn -> + expected_path = "/test/merchants/#{merchant_uid}/withdrawals" + assert %{method: "POST", request_path: ^expected_path, req_headers: req_headers} = conn + + assert Enum.any?(req_headers, fn {header, _} -> header == "idempotency-key" end) + + Req.Test.json(conn, response_json) + end) + + assert {:ok, ^response_json} = + OPPClient.post( + client, + "/merchants/{{merchant_uid}}/withdrawals", + idempotency_key: "test", + merchant_uid: merchant_uid, + amount: 11, + notify_url: Faker.Internet.url(), + description: Faker.Lorem.sentence() + ) + end + end + + describe "post /charges" do + test "creates and returns charge", %{client: client} do + merchant_uid = Faker.UUID.v4() + + response_json = %{ + "amount" => 100, + "created" => 1_637_674_410, + "currency" => "EUR", + "description" => "Moving funds", + "from_merchant_uid" => "{{merchant_uid}}", + "from_profile_uid" => "{{profile_uid}}", + "metadata" => [], + "object" => "charge", + "settled" => 1_637_674_410, + "to_merchant_uid" => "{{merchant_uid}}", + "to_profile_uid" => "{{profile_uid}}", + "type" => "balance", + "uid" => "{{charge_uid}}", + "updated" => 1_637_674_410 + } + + Req.Test.stub(OPPClient, fn conn -> + expected_path = "/test/charges" + assert %{method: "POST", request_path: ^expected_path, req_headers: req_headers} = conn + + assert Enum.any?(req_headers, fn {header, _} -> header == "idempotency-key" end) + + Req.Test.json(conn, response_json) + end) + + assert {:ok, ^response_json} = + OPPClient.post( + client, + "/charges", + idempotency_key: "test", + type: "balance", + amount: 12, + to_owner_uid: Faker.UUID.v4(), + from_owner_uid: Faker.UUID.v4() + ) + end + end +end diff --git a/core/test/test_helper.exs b/core/test/test_helper.exs index 43a493d6b6..2e992567eb 100644 --- a/core/test/test_helper.exs +++ b/core/test/test_helper.exs @@ -1,3 +1,4 @@ +ExUnit.configure(formatters: [ExUnit.CLIFormatter, ExUnitNotifier]) ExUnit.start() Ecto.Adapters.SQL.Sandbox.mode(Core.Repo, :manual)