From f81c34e6bc6f4117fc77ef88b6d0f032c7401333 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 19 Sep 2025 11:47:59 +0300 Subject: [PATCH] Major refactor: Convert to functional API and improve architecture ## Breaking Changes - Remove GenServer-based API in favor of functional approach - Replace Collection/Disbursement modules with Collections/Disbursements - Change from stateful to stateless API calls ## New Features - Add MomoapiElixir.Config module for environment variable support - Add production configuration with proper base URLs - Add comprehensive input validation with structured error responses - Add environment-specific configuration (dev/test/prod) ## Improvements - Replace deprecated Mix.Config with modern Config module - Add proper typespecs and documentation throughout - Implement configurable HTTP client for better testability - Add comprehensive error handling with {:ok, result} / {:error, reason} patterns - Remove code duplication between collections and disbursements - Add support for environment variables (MOMO_SUBSCRIPTION_KEY, etc.) ## Technical Changes - Convert from GenServer to pure functional modules - Add proper behaviour-based dependency injection - Implement compile-time HTTP client configuration - Add structured validation errors with field-level details - Update all tests to work with new functional API - Add validator tests with comprehensive coverage ## Configuration - Add config/prod.exs for production environment - Update base URLs: sandbox vs production endpoints - Add environment variable support for secure credential management --- README.md | 48 +++- config/config.exs | 9 +- config/dev.exs | 6 + config/prod.exs | 24 ++ config/test.exs | 8 +- lib/momoapi_elixir.ex | 105 +++++++- lib/momoapi_elixir/auth.ex | 91 +++++-- lib/momoapi_elixir/client.ex | 81 +++++- lib/momoapi_elixir/client_behaviour.ex | 5 +- lib/momoapi_elixir/collection.ex | 123 --------- lib/momoapi_elixir/collections.ex | 157 +++++++++++ lib/momoapi_elixir/config.ex | 145 +++++++++++ lib/momoapi_elixir/disbursement.ex | 116 --------- lib/momoapi_elixir/disbursements.ex | 155 +++++++++++ lib/momoapi_elixir/mix/tasks/provision.ex | 10 +- lib/momoapi_elixir/validator.ex | 288 ++++++++++++++++----- test/collections_test.exs | 249 ++++++++++-------- test/disbursements_test.exs | 250 ++++++++++-------- test/validator_test.exs | 301 ++++++++++++++++++++++ 19 files changed, 1601 insertions(+), 570 deletions(-) create mode 100644 config/prod.exs delete mode 100644 lib/momoapi_elixir/collection.ex create mode 100644 lib/momoapi_elixir/collections.ex create mode 100644 lib/momoapi_elixir/config.ex delete mode 100644 lib/momoapi_elixir/disbursement.ex create mode 100644 lib/momoapi_elixir/disbursements.ex create mode 100644 test/validator_test.exs diff --git a/README.md b/README.md index 085480c..dd80964 100644 --- a/README.md +++ b/README.md @@ -25,26 +25,48 @@ Your user id is 8bf4101b-ee3a-4eb1-968d-73dfc94e4011 and your API key is 6d18ef4 ``` -### Collections -The collections' api can be started with the following parameters. Note that the user Id and api key for production are provided on the MTN OVA dashboard; +### Configuration -- `subscription_key`: Primary Key for the Collections product. -- `user_id`: For sandbox, use the one generated with the `mix provision` command. -- `api_key`: For sandbox, use the one generated with the `mix provision` command. +The library supports multiple ways to configure your MTN MoMo API credentials: + +#### Option 1: Environment Variables (Recommended for Production) + +Set these environment variables: + +```bash +export MOMO_SUBSCRIPTION_KEY="your_subscription_key" +export MOMO_USER_ID="your_user_id" +export MOMO_API_KEY="your_api_key" +export MOMO_TARGET_ENVIRONMENT="production" # or "sandbox" +``` + +Then use in your code: ```elixir -alias MomoapiElixir.Collection -# Create options. Subscription key, user id and api key are required -options = %Collection.Option{ - subscription_key: "some_key", - user_id: "some_user_id", - api_key: "some_api_key", -} +# Automatically loads from environment variables +{:ok, config} = MomoapiElixir.Config.from_env() + +# Use with Collections API +{:ok, reference_id} = MomoapiElixir.Collections.request_to_pay(config, payment_body) +``` -Collection.start(options) +#### Option 2: Manual Configuration +```elixir +# Create configuration manually +config = %{ + subscription_key: "your_subscription_key", + user_id: "your_user_id", + api_key: "your_api_key", + target_environment: "sandbox" # or "production" +} + +# Use with Collections API +{:ok, reference_id} = MomoapiElixir.Collections.request_to_pay(config, payment_body) ``` +### Collections + #### Functions - `request_to_pay(body)` This operation is used to request a payment from a consumer (Payer). The payer will be asked to authorize the payment. The transaction is executed once the payer has authorized the payment. The transaction will be in status PENDING until it is authorized or declined by the payer, or it is timed out by the system. Status of the transaction can be validated by using get_transaction_status diff --git a/config/config.exs b/config/config.exs index 32c5951..2874fb6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,5 +1,8 @@ -use Mix.Config +import Config -config :momoapi_elixir, http_client: MomoapiElixir.Client +# Base configuration - environment-specific configs will override these +config :momoapi_elixir, + http_client: MomoapiElixir.Client -import_config "#{Mix.env()}.exs" +# Import environment-specific configuration +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index e69de29..a78291e 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -0,0 +1,6 @@ +import Config + +# Development-specific configuration +config :momoapi_elixir, + base_url: "https://sandbox.momodeveloper.mtn.com", + target_environment: "sandbox" \ No newline at end of file diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..470c665 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,24 @@ +import Config + +# Production configuration +config :momoapi_elixir, + base_url: "https://momoapi.mtn.com", + target_environment: "production" + +# OPTION 1: Environment Variables (Recommended) +# Set these environment variables in your production environment: +# export MOMO_SUBSCRIPTION_KEY="your_production_subscription_key" +# export MOMO_USER_ID="your_production_user_id" +# export MOMO_API_KEY="your_production_api_key" +# export MOMO_TARGET_ENVIRONMENT="production" +# +# Then use: {:ok, config} = MomoapiElixir.Config.from_env() + +# OPTION 2: Application Config (Alternative) +# Uncomment and set these if you prefer config-based credentials: +# config :momoapi_elixir, +# subscription_key: System.get_env("MOMO_SUBSCRIPTION_KEY"), +# user_id: System.get_env("MOMO_USER_ID"), +# api_key: System.get_env("MOMO_API_KEY") +# +# Then use: {:ok, config} = MomoapiElixir.Config.from_app_config() \ No newline at end of file diff --git a/config/test.exs b/config/test.exs index 62b9bbc..66e51a9 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,3 +1,7 @@ -use Mix.Config +import Config -config :momoapi_elixir, http_client: ClientMock \ No newline at end of file +# Test-specific configuration +config :momoapi_elixir, + http_client: ClientMock, + base_url: "https://sandbox.momodeveloper.mtn.com", + target_environment: "sandbox" \ No newline at end of file diff --git a/lib/momoapi_elixir.ex b/lib/momoapi_elixir.ex index 161709c..4f17ccf 100644 --- a/lib/momoapi_elixir.ex +++ b/lib/momoapi_elixir.ex @@ -1,9 +1,106 @@ defmodule MomoapiElixir do - use Application + @moduledoc """ + MTN Mobile Money API client for Elixir. - @moduledoc false + This library provides a functional interface to interact with MTN's Mobile Money API, + supporting both Collections (payments from consumers) and Disbursements (transfers to payees). - def start(_type, _args) do -# MomoapiElixir.Supervisor.start_link() + ## Quick Start + + # Option 1: Use environment variables (Recommended for production) + {:ok, config} = MomoapiElixir.Config.from_env() + + # Option 2: Manual configuration + config = %{ + subscription_key: "your_subscription_key", + user_id: "your_user_id", + api_key: "your_api_key", + target_environment: "sandbox" # or "production" + } + + # Collections - Request payment from consumer + payment = %{ + amount: "100", + currency: "UGX", + externalId: "payment_123", + payer: %{ + partyIdType: "MSISDN", + partyId: "256784123456" + }, + payerMessage: "Payment for goods", + payeeNote: "Thank you" + } + + {:ok, reference_id} = MomoapiElixir.Collections.request_to_pay(config, payment) + + # Disbursements - Transfer money to payee + transfer = %{ + amount: "50", + currency: "UGX", + externalId: "transfer_456", + payee: %{ + partyIdType: "MSISDN", + partyId: "256784123456" + } + } + + {:ok, reference_id} = MomoapiElixir.Disbursements.transfer(config, transfer) + + ## Modules + + - `MomoapiElixir.Collections` - Collections API functions + - `MomoapiElixir.Disbursements` - Disbursements API functions + - `MomoapiElixir.Auth` - Authentication utilities + - `MomoapiElixir.Validator` - Request validation utilities + """ + + # Convenience aliases for the main APIs + alias MomoapiElixir.{Collections, Disbursements} + + @type config :: %{ + subscription_key: String.t(), + user_id: String.t(), + api_key: String.t(), + target_environment: String.t() + } + + @doc """ + Request a payment from a consumer (Collections API). + + This is a convenience function that delegates to `MomoapiElixir.Collections.request_to_pay/2`. + """ + @spec request_to_pay(config(), map()) :: {:ok, String.t()} | {:error, term()} + def request_to_pay(config, body) do + Collections.request_to_pay(config, body) + end + + @doc """ + Transfer money to a payee (Disbursements API). + + This is a convenience function that delegates to `MomoapiElixir.Disbursements.transfer/2`. + """ + @spec transfer(config(), map()) :: {:ok, String.t()} | {:error, term()} + def transfer(config, body) do + Disbursements.transfer(config, body) + end + + @doc """ + Get Collections account balance. + + This is a convenience function that delegates to `MomoapiElixir.Collections.get_balance/1`. + """ + @spec get_collections_balance(config()) :: {:ok, map()} | {:error, term()} + def get_collections_balance(config) do + Collections.get_balance(config) + end + + @doc """ + Get Disbursements account balance. + + This is a convenience function that delegates to `MomoapiElixir.Disbursements.get_balance/1`. + """ + @spec get_disbursements_balance(config()) :: {:ok, map()} | {:error, term()} + def get_disbursements_balance(config) do + Disbursements.get_balance(config) end end diff --git a/lib/momoapi_elixir/auth.ex b/lib/momoapi_elixir/auth.ex index 2afc71e..ecbb952 100644 --- a/lib/momoapi_elixir/auth.ex +++ b/lib/momoapi_elixir/auth.ex @@ -1,40 +1,87 @@ defmodule MomoapiElixir.Auth do - @moduledoc false + @moduledoc """ + Authentication module for MTN Mobile Money API. - use HTTPoison.Base - @base_url Application.get_env(:momoapi_elixir, :base_url) || "https://sandbox.momodeveloper.mtn.com" + Handles token generation for both Collections and Disbursements APIs. + """ - def process_request_url(url) do - @base_url <> url + @client Application.compile_env(:momoapi_elixir, :http_client, MomoapiElixir.Client) + + @type config :: %{ + subscription_key: String.t(), + user_id: String.t(), + api_key: String.t() + } + + @type service :: :collections | :disbursements + + @doc """ + Get an access token for the specified service. + + ## Examples + + iex> config = %{subscription_key: "key", user_id: "user", api_key: "api"} + iex> MomoapiElixir.Auth.get_token(:collections, config) + {:ok, "access_token_string"} + """ + @spec get_token(service(), config()) :: {:ok, String.t()} | {:error, term()} + def get_token(:collections, config) do + authorize_service("/collection/token/", config) end - def authorise_collections(%{subscription_key: subscription_key} = config) do - basic_auth_token = create_basic_auth_token(config) - headers = [ - {"Authorization", "Basic #{basic_auth_token}"}, - {"Ocp-Apim-Subscription-Key", subscription_key} - ] - case post("/collection/token/", [], headers) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - %{"access_token" => access_token} = Poison.decode!(body) - access_token - end + def get_token(:disbursements, config) do + authorize_service("/disbursement/token/", config) end - def authorise_disbursements(%{subscription_key: subscription_key} = config) do + # Private functions + + defp authorize_service(endpoint, %{subscription_key: subscription_key} = config) do basic_auth_token = create_basic_auth_token(config) headers = [ {"Authorization", "Basic #{basic_auth_token}"}, {"Ocp-Apim-Subscription-Key", subscription_key} ] - case post("/disbursement/token/", [], headers) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - %{"access_token" => access_token} = Poison.decode!(body) - access_token + + case @client.post(endpoint, %{}, headers) do + {:ok, %{status_code: 200, body: body}} -> + case decode_token_response(body) do + {:ok, token} -> {:ok, token} + {:error, reason} -> {:error, {:token_decode_error, reason}} + end + + {:ok, %{status_code: status_code, body: body}} -> + {:error, {:auth_failed, status_code, decode_body(body)}} + + {:error, reason} -> + {:error, {:http_error, reason}} end end - def create_basic_auth_token(%{user_id: user_id, api_key: api_key}) do + defp create_basic_auth_token(%{user_id: user_id, api_key: api_key}) do Base.encode64("#{user_id}:#{api_key}") end + + defp decode_token_response(body) when is_binary(body) do + case Poison.decode(body) do + {:ok, %{"access_token" => access_token}} when is_binary(access_token) -> + {:ok, access_token} + {:ok, response} -> + {:error, {:missing_access_token, response}} + {:error, reason} -> + {:error, {:json_decode_error, reason}} + end + end + + defp decode_token_response(body) do + {:error, {:invalid_response_format, body}} + end + + defp decode_body(""), do: %{} + defp decode_body(body) when is_binary(body) do + case Poison.decode(body) do + {:ok, decoded} -> decoded + {:error, _} -> body + end + end + defp decode_body(body), do: body end \ No newline at end of file diff --git a/lib/momoapi_elixir/client.ex b/lib/momoapi_elixir/client.ex index b77cfd4..fa0e4dc 100644 --- a/lib/momoapi_elixir/client.ex +++ b/lib/momoapi_elixir/client.ex @@ -1,9 +1,80 @@ defmodule MomoapiElixir.Client do - @moduledoc false - use HTTPoison.Base - @base_url Application.get_env(:momoapi_elixir, :base_url) || "https://sandbox.momodeveloper.mtn.com" + @moduledoc """ + HTTP client for MTN Mobile Money API. - def process_request_url(url) do - @base_url <> url + Handles all HTTP communication with the MTN MoMo API endpoints. + """ + + @behaviour MomoapiElixir.ClientBehaviour + + @base_url Application.compile_env(:momoapi_elixir, :base_url, "https://sandbox.momodeveloper.mtn.com") + + @type headers :: [{String.t(), String.t()}] + @type response :: %{status_code: integer(), body: String.t(), headers: headers()} + + @doc """ + Make a POST request to the API. + + ## Examples + + iex> MomoapiElixir.Client.post("/collection/token/", %{}, [{"Content-Type", "application/json"}]) + {:ok, %{status_code: 200, body: "{...}", headers: [...]}} + """ + @spec post(String.t(), map(), headers()) :: {:ok, response()} | {:error, term()} + def post(path, body, headers) do + url = build_url(path) + encoded_body = encode_body(body) + full_headers = add_default_headers(headers) + + case HTTPoison.post(url, encoded_body, full_headers) do + {:ok, response} -> + {:ok, %{status_code: response.status_code, body: response.body, headers: response.headers}} + {:error, error} -> + {:error, {:http_error, error.reason}} + end + end + + @doc """ + Make a GET request to the API. + + ## Examples + + iex> MomoapiElixir.Client.get("/collection/v1_0/account/balance", [{"Authorization", "Bearer token"}]) + {:ok, %{status_code: 200, body: "{...}", headers: [...]}} + """ + @spec get(String.t(), headers()) :: {:ok, response()} | {:error, term()} + def get(path, headers) do + url = build_url(path) + full_headers = add_default_headers(headers) + + case HTTPoison.get(url, full_headers) do + {:ok, response} -> + {:ok, %{status_code: response.status_code, body: response.body, headers: response.headers}} + {:error, error} -> + {:error, {:http_error, error.reason}} + end + end + + # Private functions + + defp build_url(path) do + @base_url <> path + end + + defp encode_body(body) when is_map(body) do + Poison.encode!(body) + end + + defp encode_body(body) when is_binary(body) do + body + end + + defp add_default_headers(headers) do + default_headers = [ + {"Content-Type", "application/json"}, + {"Accept", "application/json"} + ] + + Enum.uniq_by(headers ++ default_headers, fn {key, _} -> key end) end end \ No newline at end of file diff --git a/lib/momoapi_elixir/client_behaviour.ex b/lib/momoapi_elixir/client_behaviour.ex index db18e1f..d796a75 100644 --- a/lib/momoapi_elixir/client_behaviour.ex +++ b/lib/momoapi_elixir/client_behaviour.ex @@ -1,5 +1,4 @@ defmodule MomoapiElixir.ClientBehaviour do - @callback post(any, any, any) :: any - @callback post(any, any) :: any - @callback get(String.t(), any) :: any + @callback post(String.t(), map(), [{String.t(), String.t()}]) :: {:ok, map()} | {:error, term()} + @callback get(String.t(), [{String.t(), String.t()}]) :: {:ok, map()} | {:error, term()} end \ No newline at end of file diff --git a/lib/momoapi_elixir/collection.ex b/lib/momoapi_elixir/collection.ex deleted file mode 100644 index b74274c..0000000 --- a/lib/momoapi_elixir/collection.ex +++ /dev/null @@ -1,123 +0,0 @@ -defmodule MomoapiElixir.Collection do - use GenServer - - defmodule Option do - @enforce_keys ~w(subscription_key user_id api_key)a - defstruct subscription_key: nil, user_id: nil, api_key: nil, callback_url: nil, target_environment: "sandbox" - end - - defmodule CollectionClient do - @client Application.get_env(:momoapi_elixir, :http_client) - - def request_to_pay(body, headers) do - body = MomoapiElixir.Validator.validate_collections(body) - @client.post("/collection/v1_0/requesttopay", Poison.encode!(body), headers) - end - - def get_balance(headers) do - @client.get("/collection/v1_0/account/balance", headers) - end - - def get_transaction_status(reference_id, headers) do - @client.get("/collection/v1_0/requesttopay/#{reference_id}", headers) - end - end - - def start(%Option{} = opts) do - GenServer.start(__MODULE__, opts, name: __MODULE__) - end - - # Client - @doc """ - This operation is used to request a payment from a consumer (Payer). The payer will be asked to authorize the payment. - The transaction will be executed once the payer has authorized the payment. The requesttopay will be in status PENDING - until the transaction is authorized or declined by the payer or it is timed out by the system. Status of the transaction - can be validated by using the GET /requesttopay/ - - %{ - amount: "10", - currency: "EUR", - externalId: "123456", - payer: %{ - partyIdType: "MSISDN", - partyId: "46733123450" - }, - payerMessage: "testing", - payeeNote: "hello" - } - - """ - def request_to_pay(body) do - GenServer.call(__MODULE__, {:request_to_pay, body}) - end - - @doc""" - Get the balance of the account - """ - def get_balance do - GenServer.call(__MODULE__, :get_balance) - end - - @doc """ - This method is used to retrieve transaction information. You can invoke it at intervals until your transaction fails or succeeds - """ - def get_transaction_status(reference_id) do - GenServer.call(__MODULE__, {:get_transaction_status, reference_id}) - end - - # Callbacks - def init(%Option{subscription_key: subscription_key, user_id: user_id, api_key: api_key}) do - token = MomoapiElixir.Auth.authorise_collections( - %{subscription_key: subscription_key, user_id: user_id, api_key: api_key} - ) - {:ok, %{subscription_key: subscription_key, token: token}} - end - - def handle_call({:request_to_pay, body}, _from, state) do - reference_id = reference_id() - headers = [ - {"Authorization", "Bearer #{state.token}"}, - {"Ocp-Apim-Subscription-Key", state.subscription_key}, - {"X-Reference-Id", reference_id}, - {"X-Target-Environment", "sandbox"} - ] - case CollectionClient.request_to_pay(body, headers) do - {:ok, %HTTPoison.Response{status_code: 202, body: _body}} -> {:reply, reference_id, state} - {:ok, %HTTPoison.Response{status_code: 500, body: body}} -> {:reply, {:error, %{code: 500, body: Poison.decode!(body)}}, state} - {:ok, %HTTPoison.Response{status_code: 500, body: ""}} -> {:reply, {:error, %{code: 500, body: ""}}, state } - {:ok, %HTTPoison.Response{status_code: 400, body: ""}} -> {:reply, {:error, %{code: 400, body: ""}}, state} - {:ok, %HTTPoison.Response{status_code: 400, body: body}} -> {:reply, {:error, %{code: 400, body: Poison.decode!(body)}}, state} - end - end - - def handle_call(:get_balance, _from, state) do - headers = [ - {"Authorization", "Bearer #{state.token}"}, - {"Ocp-Apim-Subscription-Key", state.subscription_key}, - {"X-Target-Environment", "sandbox"} - ] - case CollectionClient.get_balance(headers) do - {:ok, %HTTPoison.Response{body: body, status_code: 200}} -> {:reply, Poison.decode!(body), state} - {:ok, %HTTPoison.Response{body: body, status_code: 404}} -> {:reply, {:error, Poison.decode!(body)}, state} - {:ok, %HTTPoison.Response{body: body, status_code: 500}} -> {:reply, {:error, Poison.decode!(body)}, state} - end - end - - def handle_call({:get_transaction_status, reference_id}, _from, state) do - headers = [ - {"Authorization", "Bearer #{state.token}"}, - {"Ocp-Apim-Subscription-Key", state.subscription_key}, - {"X-Target-Environment", "sandbox"}, - {"X-Reference-Id", reference_id}, - ] - case CollectionClient.get_transaction_status(reference_id, headers) do - {:ok, %HTTPoison.Response{body: body, status_code: 200}} -> {:reply, Poison.decode!(body), state} - end - - end - - defp reference_id do - UUID.uuid4() - end - -end diff --git a/lib/momoapi_elixir/collections.ex b/lib/momoapi_elixir/collections.ex new file mode 100644 index 0000000..0ae3f2d --- /dev/null +++ b/lib/momoapi_elixir/collections.ex @@ -0,0 +1,157 @@ +defmodule MomoapiElixir.Collections do + @moduledoc """ + Collections API for MTN Mobile Money. + + This module provides functions to request payments from consumers, + check transaction status, and get account balance. + """ + + alias MomoapiElixir.{Auth, Validator} + + @client Application.compile_env(:momoapi_elixir, :http_client, MomoapiElixir.Client) + + @type config :: %{ + subscription_key: String.t(), + user_id: String.t(), + api_key: String.t(), + target_environment: String.t() + } + + @type payment_request :: %{ + amount: String.t(), + currency: String.t(), + externalId: String.t(), + payer: %{ + partyIdType: String.t(), + partyId: String.t() + }, + payerMessage: String.t(), + payeeNote: String.t() + } + + @doc """ + Request a payment from a consumer (Payer). + + The payer will be asked to authorize the payment. The transaction will be + executed once the payer has authorized the payment. + + ## Examples + + iex> config = %{subscription_key: "key", user_id: "user", api_key: "api", target_environment: "sandbox"} + iex> payment = %{amount: "100", currency: "UGX", externalId: "123", payer: %{partyIdType: "MSISDN", partyId: "256784123456"}, payerMessage: "Payment", payeeNote: "Note"} + iex> MomoapiElixir.Collections.request_to_pay(config, payment) + {:ok, "reference-id-uuid"} + """ + @spec request_to_pay(config(), payment_request()) :: {:ok, String.t()} | {:error, term()} + def request_to_pay(config, body) do + with {:ok, validated_body} <- Validator.validate_collections(body), + {:ok, token} <- Auth.get_token(:collections, config), + {:ok, reference_id} <- generate_reference_id(), + headers <- build_headers(token, config, reference_id), + {:ok, response} <- @client.post("/collection/v1_0/requesttopay", validated_body, headers) do + handle_payment_response(response, reference_id) + end + end + + @doc """ + Get the balance of the account. + + ## Examples + + iex> config = %{subscription_key: "key", user_id: "user", api_key: "api", target_environment: "sandbox"} + iex> MomoapiElixir.Collections.get_balance(config) + {:ok, %{"availableBalance" => "1000", "currency" => "UGX"}} + """ + @spec get_balance(config()) :: {:ok, map()} | {:error, term()} + def get_balance(config) do + with {:ok, token} <- Auth.get_token(:collections, config), + headers <- build_headers(token, config), + {:ok, response} <- @client.get("/collection/v1_0/account/balance", headers) do + handle_balance_response(response) + end + end + + @doc """ + Retrieve transaction information using the reference ID. + + You can invoke this at intervals until the transaction fails or succeeds. + + ## Examples + + iex> config = %{subscription_key: "key", user_id: "user", api_key: "api", target_environment: "sandbox"} + iex> MomoapiElixir.Collections.get_transaction_status(config, "ref-id") + {:ok, %{"status" => "SUCCESSFUL", "amount" => "100"}} + """ + @spec get_transaction_status(config(), String.t()) :: {:ok, map()} | {:error, term()} + def get_transaction_status(config, reference_id) do + with {:ok, token} <- Auth.get_token(:collections, config), + headers <- build_headers(token, config, reference_id), + {:ok, response} <- @client.get("/collection/v1_0/requesttopay/#{reference_id}", headers) do + handle_transaction_response(response) + end + end + + # Private functions + + defp generate_reference_id do + {:ok, UUID.uuid4()} + end + + defp build_headers(token, config, reference_id \\ nil) do + base_headers = [ + {"Authorization", "Bearer #{token}"}, + {"Ocp-Apim-Subscription-Key", config.subscription_key}, + {"X-Target-Environment", config.target_environment || "sandbox"} + ] + + case reference_id do + nil -> base_headers + id -> [{"X-Reference-Id", id} | base_headers] + end + end + + defp handle_payment_response({:ok, %{status_code: 202}}, reference_id) do + {:ok, reference_id} + end + + defp handle_payment_response({:ok, %{status_code: status_code, body: body}}, _reference_id) do + {:error, %{status_code: status_code, body: decode_body(body)}} + end + + defp handle_payment_response({:error, reason}, _reference_id) do + {:error, reason} + end + + defp handle_balance_response({:ok, %{status_code: 200, body: body}}) do + {:ok, decode_body(body)} + end + + defp handle_balance_response({:ok, %{status_code: status_code, body: body}}) do + {:error, %{status_code: status_code, body: decode_body(body)}} + end + + defp handle_balance_response({:error, reason}) do + {:error, reason} + end + + defp handle_transaction_response({:ok, %{status_code: 200, body: body}}) do + {:ok, decode_body(body)} + end + + defp handle_transaction_response({:ok, %{status_code: status_code, body: body}}) do + {:error, %{status_code: status_code, body: decode_body(body)}} + end + + defp handle_transaction_response({:error, reason}) do + {:error, reason} + end + + defp decode_body(""), do: %{} + defp decode_body(body) when is_binary(body) do + case Poison.decode(body) do + {:ok, decoded} -> decoded + {:error, _} -> body + end + end + defp decode_body(body), do: body +end \ No newline at end of file diff --git a/lib/momoapi_elixir/config.ex b/lib/momoapi_elixir/config.ex new file mode 100644 index 0000000..714c766 --- /dev/null +++ b/lib/momoapi_elixir/config.ex @@ -0,0 +1,145 @@ +defmodule MomoapiElixir.Config do + @moduledoc """ + Configuration helpers for MTN Mobile Money API. + + Provides utilities to build configuration from environment variables + and application config for secure credential management. + """ + + @type config :: %{ + subscription_key: String.t(), + user_id: String.t(), + api_key: String.t(), + target_environment: String.t() + } + + @doc """ + Build configuration from environment variables. + + Reads the following environment variables: + - `MOMO_SUBSCRIPTION_KEY` - Your MTN MoMo subscription key + - `MOMO_USER_ID` - Your MTN MoMo user ID + - `MOMO_API_KEY` - Your MTN MoMo API key + - `MOMO_TARGET_ENVIRONMENT` - "sandbox" or "production" (defaults to application config) + + ## Examples + + # Set environment variables first: + # export MOMO_SUBSCRIPTION_KEY="your_subscription_key" + # export MOMO_USER_ID="your_user_id" + # export MOMO_API_KEY="your_api_key" + # export MOMO_TARGET_ENVIRONMENT="production" + + iex> MomoapiElixir.Config.from_env() + {:ok, %{ + subscription_key: "your_subscription_key", + user_id: "your_user_id", + api_key: "your_api_key", + target_environment: "production" + }} + + # If environment variables are missing: + iex> MomoapiElixir.Config.from_env() + {:error, {:missing_env_vars, [:subscription_key, :user_id]}} + """ + @spec from_env() :: {:ok, config()} | {:error, {:missing_env_vars, [atom()]}} + def from_env do + env_vars = %{ + subscription_key: System.get_env("MOMO_SUBSCRIPTION_KEY"), + user_id: System.get_env("MOMO_USER_ID"), + api_key: System.get_env("MOMO_API_KEY"), + target_environment: System.get_env("MOMO_TARGET_ENVIRONMENT") + } + + # Use application config as fallback for target_environment + target_environment = env_vars.target_environment || + Application.get_env(:momoapi_elixir, :target_environment, "sandbox") + + config = %{ + subscription_key: env_vars.subscription_key, + user_id: env_vars.user_id, + api_key: env_vars.api_key, + target_environment: target_environment + } + + case validate_config(config) do + {:ok, config} -> {:ok, config} + {:error, missing} -> {:error, {:missing_env_vars, missing}} + end + end + + @doc """ + Build configuration from application config. + + Reads from your application's configuration files. + + ## Examples + + # In config/prod.exs: + config :momoapi_elixir, + subscription_key: "your_subscription_key", + user_id: "your_user_id", + api_key: "your_api_key", + target_environment: "production" + + iex> MomoapiElixir.Config.from_app_config() + {:ok, %{ + subscription_key: "your_subscription_key", + user_id: "your_user_id", + api_key: "your_api_key", + target_environment: "production" + }} + """ + @spec from_app_config() :: {:ok, config()} | {:error, {:missing_config, [atom()]}} + def from_app_config do + config = %{ + subscription_key: Application.get_env(:momoapi_elixir, :subscription_key), + user_id: Application.get_env(:momoapi_elixir, :user_id), + api_key: Application.get_env(:momoapi_elixir, :api_key), + target_environment: Application.get_env(:momoapi_elixir, :target_environment, "sandbox") + } + + case validate_config(config) do + {:ok, config} -> {:ok, config} + {:error, missing} -> {:error, {:missing_config, missing}} + end + end + + @doc """ + Create configuration manually. + + ## Examples + + iex> MomoapiElixir.Config.new("sub_key", "user_id", "api_key", "production") + {:ok, %{ + subscription_key: "sub_key", + user_id: "user_id", + api_key: "api_key", + target_environment: "production" + }} + """ + @spec new(String.t(), String.t(), String.t(), String.t()) :: {:ok, config()} + def new(subscription_key, user_id, api_key, target_environment \\ "sandbox") do + {:ok, %{ + subscription_key: subscription_key, + user_id: user_id, + api_key: api_key, + target_environment: target_environment + }} + end + + # Private functions + + defp validate_config(config) do + required_fields = [:subscription_key, :user_id, :api_key] + missing_fields = Enum.filter(required_fields, fn field -> + value = Map.get(config, field) + is_nil(value) or value == "" + end) + + case missing_fields do + [] -> {:ok, config} + missing -> {:error, missing} + end + end +end \ No newline at end of file diff --git a/lib/momoapi_elixir/disbursement.ex b/lib/momoapi_elixir/disbursement.ex deleted file mode 100644 index f6513de..0000000 --- a/lib/momoapi_elixir/disbursement.ex +++ /dev/null @@ -1,116 +0,0 @@ -defmodule MomoapiElixir.Disbursement do - use GenServer - - defmodule Option do - @enforce_keys ~w(subscription_key user_id api_key)a - defstruct subscription_key: nil, user_id: nil, api_key: nil, callback_url: nil, target_environment: "sandbox" - end - - defmodule DisbursementClient do - @client Application.get_env(:momoapi_elixir, :http_client) - - def transfer(body, headers) do - body = MomoapiElixir.Validator.validate_disbursements(body) - @client.post("/disbursement/v1_0/transfer", Poison.encode!(body), headers) - end - - def get_balance(headers) do - @client.get("/disbursement/v1_0/account/balance", headers) - end - - def get_transaction_status(reference_id, headers) do - @client.get("/disbursement/v1_0/transfer/#{reference_id}", headers) - end - end - - def start(%Option{} = opts) do - GenServer.start(__MODULE__, opts, name: __MODULE__) - end - - @doc """ - %{ - amount: "100", - currency: "EUR", - externalId: "947354", - payee: %{ - partyIdType: "MSISDN", - partyId: "+256776564739" - }, - payerMessage: "testing", - payeeNote: "hello" - } - """ - - def transfer(body) do - body = MomoapiElixir.Validator.validate_disbursements(body) - GenServer.call(__MODULE__, {:transfer, body}) - end - - def get_balance do - GenServer.call(__MODULE__, :get_balance) - end - - def get_transaction_status(reference_id) do - GenServer.call(__MODULE__, {:get_transaction_status, reference_id}) - end - - # Callbacks - def init(%Option{subscription_key: subscription_key, user_id: user_id, api_key: api_key}) do - token = MomoapiElixir.Auth.authorise_disbursements( - %{subscription_key: subscription_key, user_id: user_id, api_key: api_key} - ) - {:ok, %{subscription_key: subscription_key, token: token}} - end - - def handle_call({:transfer, body}, _from, state) do - reference_id = reference_id() - headers = [ - {"Authorization", "Bearer #{state.token}"}, - {"Ocp-Apim-Subscription-Key", state.subscription_key}, - {"X-Reference-Id", reference_id}, - {"X-Target-Environment", "sandbox"} - ] - case DisbursementClient.transfer(body, headers) do - {:ok, %HTTPoison.Response{status_code: 202, body: _body}} -> - {:reply, reference_id, state} - {:ok, %HTTPoison.Response{status_code: 500, body: body}} -> - {:reply, {:error, %{code: 500, body: Poison.decode!(body)}}, state} - {:ok, %HTTPoison.Response{status_code: 500, body: ""}} -> - {:reply, {:error, %{code: 500, body: ""}}, state} - {:ok, %HTTPoison.Response{status_code: 400, body: ""}} -> - {:reply, {:error, %{code: 400, body: ""}}, state} - {:ok, %HTTPoison.Response{status_code: 400, body: body}} -> - {:reply, {:error, %{code: 400, body: Poison.decode!(body)}}, state} - end - end - - def handle_call(:get_balance, _from, state) do - headers = [ - {"Authorization", "Bearer #{state.token}"}, - {"Ocp-Apim-Subscription-Key", state.subscription_key}, - {"X-Target-Environment", "sandbox"} - ] - case DisbursementClient.get_balance(headers) do - {:ok, %HTTPoison.Response{body: body, status_code: 200}} -> {:reply, Poison.decode!(body), state} - {:ok, %HTTPoison.Response{body: body, status_code: 404}} -> {:reply, {:error, Poison.decode!(body)}, state} - {:ok, %HTTPoison.Response{body: body, status_code: 500}} -> {:reply, {:error, Poison.decode!(body)}, state} - {:ok, %HTTPoison.Response{body: body, status_code: 503}} -> {:reply, {:error, Poison.decode!(body)}, state} - end - end - - def handle_call({:get_transaction_status, reference_id}, _from, state) do - headers = [ - {"Authorization", "Bearer #{state.token}"}, - {"Ocp-Apim-Subscription-Key", state.subscription_key}, - {"X-Target-Environment", "sandbox"}, - {"X-Reference-Id", reference_id}, - ] - case DisbursementClient.get_transaction_status(reference_id, headers) do - {:ok, %HTTPoison.Response{body: body, status_code: 200}} -> {:reply, Poison.decode!(body), state} - end - end - - defp reference_id do - UUID.uuid4() - end -end \ No newline at end of file diff --git a/lib/momoapi_elixir/disbursements.ex b/lib/momoapi_elixir/disbursements.ex new file mode 100644 index 0000000..51efaff --- /dev/null +++ b/lib/momoapi_elixir/disbursements.ex @@ -0,0 +1,155 @@ +defmodule MomoapiElixir.Disbursements do + @moduledoc """ + Disbursements API for MTN Mobile Money. + + This module provides functions to transfer money to payees, + check transaction status, and get account balance. + """ + + alias MomoapiElixir.{Auth, Client, Validator} + + @type config :: %{ + subscription_key: String.t(), + user_id: String.t(), + api_key: String.t(), + target_environment: String.t() + } + + @type transfer_request :: %{ + amount: String.t(), + currency: String.t(), + externalId: String.t(), + payee: %{ + partyIdType: String.t(), + partyId: String.t() + }, + payerMessage: String.t(), + payeeNote: String.t() + } + + @doc """ + Transfer money to a payee account. + + Used to transfer an amount from the owner's account to a payee account. + Returns a reference ID which can be used to check the transaction status. + + ## Examples + + iex> config = %{subscription_key: "key", user_id: "user", api_key: "api", target_environment: "sandbox"} + iex> transfer = %{amount: "100", currency: "UGX", externalId: "123", payee: %{partyIdType: "MSISDN", partyId: "256784123456"}} + iex> MomoapiElixir.Disbursements.transfer(config, transfer) + {:ok, "reference-id-uuid"} + """ + @spec transfer(config(), transfer_request()) :: {:ok, String.t()} | {:error, term()} + def transfer(config, body) do + with {:ok, validated_body} <- Validator.validate_disbursements(body), + {:ok, token} <- Auth.get_token(:disbursements, config), + {:ok, reference_id} <- generate_reference_id(), + headers <- build_headers(token, config, reference_id), + {:ok, response} <- Client.post("/disbursement/v1_0/transfer", validated_body, headers) do + handle_transfer_response(response, reference_id) + end + end + + @doc """ + Get the balance of the disbursements account. + + ## Examples + + iex> config = %{subscription_key: "key", user_id: "user", api_key: "api", target_environment: "sandbox"} + iex> MomoapiElixir.Disbursements.get_balance(config) + {:ok, %{"availableBalance" => "1000", "currency" => "UGX"}} + """ + @spec get_balance(config()) :: {:ok, map()} | {:error, term()} + def get_balance(config) do + with {:ok, token} <- Auth.get_token(:disbursements, config), + headers <- build_headers(token, config), + {:ok, response} <- Client.get("/disbursement/v1_0/account/balance", headers) do + handle_balance_response(response) + end + end + + @doc """ + Retrieve transaction information using the reference ID. + + You can invoke this at intervals until the transaction fails or succeeds. + + ## Examples + + iex> config = %{subscription_key: "key", user_id: "user", api_key: "api", target_environment: "sandbox"} + iex> MomoapiElixir.Disbursements.get_transaction_status(config, "ref-id") + {:ok, %{"status" => "SUCCESSFUL", "amount" => "100"}} + """ + @spec get_transaction_status(config(), String.t()) :: {:ok, map()} | {:error, term()} + def get_transaction_status(config, reference_id) do + with {:ok, token} <- Auth.get_token(:disbursements, config), + headers <- build_headers(token, config, reference_id), + {:ok, response} <- Client.get("/disbursement/v1_0/transfer/#{reference_id}", headers) do + handle_transaction_response(response) + end + end + + # Private functions + + defp generate_reference_id do + {:ok, UUID.uuid4()} + end + + defp build_headers(token, config, reference_id \\ nil) do + base_headers = [ + {"Authorization", "Bearer #{token}"}, + {"Ocp-Apim-Subscription-Key", config.subscription_key}, + {"X-Target-Environment", config.target_environment || "sandbox"} + ] + + case reference_id do + nil -> base_headers + id -> [{"X-Reference-Id", id} | base_headers] + end + end + + defp handle_transfer_response({:ok, %{status_code: 202}}, reference_id) do + {:ok, reference_id} + end + + defp handle_transfer_response({:ok, %{status_code: status_code, body: body}}, _reference_id) do + {:error, %{status_code: status_code, body: decode_body(body)}} + end + + defp handle_transfer_response({:error, reason}, _reference_id) do + {:error, reason} + end + + defp handle_balance_response({:ok, %{status_code: 200, body: body}}) do + {:ok, decode_body(body)} + end + + defp handle_balance_response({:ok, %{status_code: status_code, body: body}}) do + {:error, %{status_code: status_code, body: decode_body(body)}} + end + + defp handle_balance_response({:error, reason}) do + {:error, reason} + end + + defp handle_transaction_response({:ok, %{status_code: 200, body: body}}) do + {:ok, decode_body(body)} + end + + defp handle_transaction_response({:ok, %{status_code: status_code, body: body}}) do + {:error, %{status_code: status_code, body: decode_body(body)}} + end + + defp handle_transaction_response({:error, reason}) do + {:error, reason} + end + + defp decode_body(""), do: %{} + defp decode_body(body) when is_binary(body) do + case Poison.decode(body) do + {:ok, decoded} -> decoded + {:error, _} -> body + end + end + defp decode_body(body), do: body +end \ No newline at end of file diff --git a/lib/momoapi_elixir/mix/tasks/provision.ex b/lib/momoapi_elixir/mix/tasks/provision.ex index 233bf62..2758232 100644 --- a/lib/momoapi_elixir/mix/tasks/provision.ex +++ b/lib/momoapi_elixir/mix/tasks/provision.ex @@ -1,7 +1,7 @@ defmodule Mix.Tasks.Provision do use Mix.Task - @base_url "https://sandbox.momodeveloper.mtn.com" + @base_url Application.compile_env(:momoapi_elixir, :base_url, "https://sandbox.momodeveloper.mtn.com") @shortdoc "Creates the user id and user api key." def run([subscription_key, webhook_host]) do @@ -17,10 +17,10 @@ defmodule Mix.Tasks.Provision do {"Ocp-Apim-Subscription-Key", subscription_key} ] ) do - {:ok, %HTTPoison.Response{status_code: 409}} -> + {:ok, response} when response.status_code == 409 -> Mix.shell().info("Duplicate reference id") - {:ok, %HTTPoison.Response{status_code: 201}} -> + {:ok, response} when response.status_code == 201 -> case HTTPoison.post( @base_url <> "/v1_0/apiuser/#{reference_id}/apikey", [], @@ -28,8 +28,8 @@ defmodule Mix.Tasks.Provision do {"Ocp-Apim-Subscription-Key", subscription_key} ] ) do - {:ok, %HTTPoison.Response{status_code: 201, body: body}} -> - {:ok, %{"apiKey" => api_key}} = Poison.decode(body) + {:ok, key_response} when key_response.status_code == 201 -> + {:ok, %{"apiKey" => api_key}} = Poison.decode(key_response.body) Mix.shell().info("Your user id is #{reference_id} and your API key is #{api_key}") end diff --git a/lib/momoapi_elixir/validator.ex b/lib/momoapi_elixir/validator.ex index 9865510..026442b 100644 --- a/lib/momoapi_elixir/validator.ex +++ b/lib/momoapi_elixir/validator.ex @@ -1,90 +1,250 @@ defmodule MomoapiElixir.Validator do + @moduledoc """ + Validation module for MTN Mobile Money API requests. - def validate_collections( - %{ - amount: amount - } - ) when is_nil(amount) or amount == "" do - raise "Amount is required" + Provides comprehensive validation for collections and disbursements requests, + returning structured error information instead of raising exceptions. + """ + + @type validation_error :: %{ + field: atom(), + message: String.t(), + value: any() + } + + @type validation_result :: {:ok, map()} | {:error, [validation_error()]} + + @doc """ + Validate a collections payment request. + + ## Examples + + iex> valid_request = %{ + ...> amount: "100", + ...> currency: "UGX", + ...> externalId: "123", + ...> payer: %{partyIdType: "MSISDN", partyId: "256784123456"}, + ...> payerMessage: "Payment", + ...> payeeNote: "Note" + ...> } + iex> MomoapiElixir.Validator.validate_collections(valid_request) + {:ok, %{amount: "100", currency: "UGX", ...}} + + iex> invalid_request = %{amount: "", currency: "UGX"} + iex> MomoapiElixir.Validator.validate_collections(invalid_request) + {:error, [%{field: :amount, message: "Amount is required", value: ""}]} + """ + @spec validate_collections(map()) :: validation_result() + def validate_collections(body) when body == %{} do + {:error, [%{field: :body, message: "Request body cannot be empty", value: %{}}]} end - def validate_collections( - %{ - currency: currency - } - ) when is_nil(currency) or currency == "" do - raise "Currency is required" + def validate_collections(body) do + body + |> validate_required_fields(:collections) + |> validate_amount() + |> validate_currency() + |> validate_external_id() + |> validate_payer() + |> validate_messages() + |> return_validation_result(body) end - def validate_collections( - %{ - payer: %{ - partyId: party_id - }, - } - ) when is_nil(party_id) or party_id == "" do - raise "Party id is required" + @doc """ + Validate a disbursements transfer request. + + ## Examples + + iex> valid_request = %{ + ...> amount: "100", + ...> currency: "UGX", + ...> externalId: "123", + ...> payee: %{partyIdType: "MSISDN", partyId: "256784123456"} + ...> } + iex> MomoapiElixir.Validator.validate_disbursements(valid_request) + {:ok, %{amount: "100", currency: "UGX", ...}} + """ + @spec validate_disbursements(map()) :: validation_result() + def validate_disbursements(body) when body == %{} do + {:error, [%{field: :body, message: "Request body cannot be empty", value: %{}}]} end - def validate_collections( - %{ - payer: %{ - partyIdType: party_id_type - }, - } - ) when is_nil(party_id_type) or party_id_type == "" do - raise "Party id type is required" + def validate_disbursements(body) do + body + |> validate_required_fields(:disbursements) + |> validate_amount() + |> validate_currency() + |> validate_external_id() + |> validate_payee() + |> validate_messages() + |> return_validation_result(body) end - def validate_collections(body) when body == %{} do - raise "Body is empty" + # Private validation functions + + defp validate_required_fields(body, :collections) do + required_fields = [:amount, :currency, :externalId, :payer] + validate_fields_present(body, required_fields, []) end - def validate_collections(body) do - body + defp validate_required_fields(body, :disbursements) do + required_fields = [:amount, :currency, :externalId, :payee] + validate_fields_present(body, required_fields, []) end - def validate_disbursements( - %{ - amount: amount - } - ) when is_nil(amount) or amount == "" do - raise "Amount is required" + defp validate_fields_present(_body, [], errors), do: errors + + defp validate_fields_present(body, [field | rest], errors) do + case Map.get(body, field) do + nil -> + error = %{field: field, message: "#{field} is required", value: nil} + validate_fields_present(body, rest, [error | errors]) + + value when value == "" -> + error = %{field: field, message: "#{field} cannot be empty", value: value} + validate_fields_present(body, rest, [error | errors]) + + _value -> + validate_fields_present(body, rest, errors) + end end - def validate_disbursements( - %{ - currency: currency - } - ) when is_nil(currency) or currency == "" do - raise "Currency is required" + defp validate_amount(errors) when is_list(errors), do: errors + + defp validate_amount(body) do + case Map.get(body, :amount) do + nil -> [] + "" -> [] + amount when is_binary(amount) -> + case Float.parse(amount) do + {float_value, ""} when float_value > 0 -> [] + {_float_value, ""} -> [%{field: :amount, message: "Amount must be positive", value: amount}] + _ -> + # Try integer parse as fallback + case Integer.parse(amount) do + {int_value, ""} when int_value > 0 -> [] + {_int_value, ""} -> [%{field: :amount, message: "Amount must be positive", value: amount}] + _ -> [%{field: :amount, message: "Amount must be a valid number", value: amount}] + end + end + amount -> + [%{field: :amount, message: "Amount must be a string", value: amount}] + end end - def validate_disbursements( - %{ - payee: %{ - partyId: party_id - }, - } - ) when is_nil(party_id) or party_id == "" do - raise "Party id is required" + defp validate_currency(errors) when is_list(errors), do: errors + + defp validate_currency(body) do + case Map.get(body, :currency) do + nil -> [] + "" -> [] + currency when is_binary(currency) -> + if String.length(currency) == 3 and String.match?(currency, ~r/^[A-Z]{3}$/) do + [] + else + [%{field: :currency, message: "Currency must be a 3-letter ISO code (e.g., UGX, EUR)", value: currency}] + end + currency -> + [%{field: :currency, message: "Currency must be a string", value: currency}] + end end - def validate_disbursements( - %{ - payee: %{ - partyIdType: party_id_type - }, - } - ) when is_nil(party_id_type) or party_id_type == "" do - raise "Party id type is required" + defp validate_external_id(errors) when is_list(errors), do: errors + + defp validate_external_id(body) do + case Map.get(body, :externalId) do + nil -> [] + "" -> [%{field: :externalId, message: "External ID cannot be empty", value: ""}] + external_id when is_binary(external_id) -> [] + external_id -> [%{field: :externalId, message: "External ID must be a string", value: external_id}] + end end - def validate_disbursements(body) when body == %{} do - raise "Body is empty" + defp validate_payer(errors) when is_list(errors), do: errors + + defp validate_payer(body) do + case Map.get(body, :payer) do + nil -> [] + payer when is_map(payer) -> + validate_party(payer, :payer) + payer -> + [%{field: :payer, message: "Payer must be a map", value: payer}] + end end - def validate_disbursements(body) do - body + defp validate_payee(errors) when is_list(errors), do: errors + + defp validate_payee(body) do + case Map.get(body, :payee) do + nil -> [] + payee when is_map(payee) -> + validate_party(payee, :payee) + payee -> + [%{field: :payee, message: "Payee must be a map", value: payee}] + end + end + + defp validate_party(party, party_type) do + errors = [] + + errors = case Map.get(party, :partyIdType) do + nil -> [%{field: :"#{party_type}.partyIdType", message: "Party ID type is required", value: nil} | errors] + "" -> [%{field: :"#{party_type}.partyIdType", message: "Party ID type cannot be empty", value: ""} | errors] + type when type in ["MSISDN", "EMAIL", "PARTY_CODE"] -> errors + type -> [%{field: :"#{party_type}.partyIdType", message: "Party ID type must be one of: MSISDN, EMAIL, PARTY_CODE", value: type} | errors] + end + + case Map.get(party, :partyId) do + nil -> [%{field: :"#{party_type}.partyId", message: "Party ID is required", value: nil} | errors] + "" -> [%{field: :"#{party_type}.partyId", message: "Party ID cannot be empty", value: ""} | errors] + party_id when is_binary(party_id) -> validate_party_id_format(party_id, Map.get(party, :partyIdType), party_type, errors) + party_id -> [%{field: :"#{party_type}.partyId", message: "Party ID must be a string", value: party_id} | errors] + end + end + + defp validate_party_id_format(party_id, "MSISDN", party_type, errors) do + # Basic MSISDN validation - should be digits, optionally starting with + + if String.match?(party_id, ~r/^\+?[0-9]{10,15}$/) do + errors + else + [%{field: :"#{party_type}.partyId", message: "MSISDN must be 10-15 digits, optionally starting with +", value: party_id} | errors] + end + end + + defp validate_party_id_format(party_id, "EMAIL", party_type, errors) do + # Basic email validation + if String.match?(party_id, ~r/^[^\s]+@[^\s]+\.[^\s]+$/) do + errors + else + [%{field: :"#{party_type}.partyId", message: "Invalid email format", value: party_id} | errors] + end end + + defp validate_party_id_format(_party_id, _type, _party_type, errors) do + # For PARTY_CODE or other types, we accept any non-empty string + errors + end + + defp validate_messages(errors) when is_list(errors), do: errors + + defp validate_messages(body) do + errors = [] + + errors = case Map.get(body, :payerMessage) do + nil -> errors + message when is_binary(message) and byte_size(message) <= 160 -> errors + message when is_binary(message) -> [%{field: :payerMessage, message: "Payer message cannot exceed 160 characters", value: message} | errors] + message -> [%{field: :payerMessage, message: "Payer message must be a string", value: message} | errors] + end + + case Map.get(body, :payeeNote) do + nil -> errors + note when is_binary(note) and byte_size(note) <= 160 -> errors + note when is_binary(note) -> [%{field: :payeeNote, message: "Payee note cannot exceed 160 characters", value: note} | errors] + note -> [%{field: :payeeNote, message: "Payee note must be a string", value: note} | errors] + end + end + + defp return_validation_result([], body), do: {:ok, body} + defp return_validation_result(errors, _body), do: {:error, Enum.reverse(errors)} end \ No newline at end of file diff --git a/test/collections_test.exs b/test/collections_test.exs index ce7d6f3..a87f3a5 100644 --- a/test/collections_test.exs +++ b/test/collections_test.exs @@ -2,137 +2,176 @@ defmodule MomoapiElixir.CollectionsTest do use ExUnit.Case, async: true import Mox + alias MomoapiElixir.Collections + # Make sure mocks are verified when the test exits setup :verify_on_exit! - describe "Collections" do - test "makes the correct request" do - reference_id = UUID.uuid4() - body = %{ - amount: "10", - currency: "EUR", - externalId: "123456", - payer: %{ - partyIdType: "MSISDN", - partyId: "46733123450" - }, - payerMessage: "testing", - payeeNote: "hello" - } + @valid_config %{ + subscription_key: "test_subscription_key", + user_id: "test_user_id", + api_key: "test_api_key", + target_environment: "sandbox" + } + + @valid_payment %{ + amount: "100", + currency: "UGX", + externalId: "payment_123", + payer: %{ + partyIdType: "MSISDN", + partyId: "256784123456" + }, + payerMessage: "Payment for goods", + payeeNote: "Thank you" + } + + describe "request_to_pay/2" do + test "returns reference_id on successful payment request" do + # Mock auth token request ClientMock - |> expect(:post, fn _url, _body, _headers -> reference_id end) - response = MomoapiElixir.Collection.CollectionClient.request_to_pay(body, []) - assert reference_id == response - end + |> expect(:post, fn "/collection/token/", _body, _headers -> + {:ok, %{status_code: 200, body: "{\"access_token\": \"test_token\"}"}} + end) - test "raises an error when the amount is missing" do - body = %{ - amount: "", - currency: "EUR", - externalId: "123456", - payer: %{ - partyIdType: "MSISDN", - partyId: "46733123450" - }, - payerMessage: "testing", - payeeNote: "hello" - } + # Mock payment request + ClientMock + |> expect(:post, fn "/collection/v1_0/requesttopay", _body, headers -> + # Verify headers are correct + assert {"Authorization", "Bearer test_token"} in headers + assert {"Ocp-Apim-Subscription-Key", "test_subscription_key"} in headers + assert {"X-Target-Environment", "sandbox"} in headers + + # Check that X-Reference-Id is present + reference_id_header = Enum.find(headers, fn {key, _} -> key == "X-Reference-Id" end) + assert reference_id_header != nil + + {:ok, %{status_code: 202, body: ""}} + end) - assert_raise RuntimeError, "Amount is required", fn -> - MomoapiElixir.Collection.CollectionClient.request_to_pay(body, []) - end + result = Collections.request_to_pay(@valid_config, @valid_payment) + + assert {:ok, reference_id} = result + assert is_binary(reference_id) + assert String.length(reference_id) > 0 end - test "raises an error when the currency is missing" do - body = %{ - amount: "10", - currency: "", - externalId: "123456", - payer: %{ - partyIdType: "MSISDN", - partyId: "46733123450" - }, - payerMessage: "testing", - payeeNote: "hello" + test "returns error on validation failure" do + invalid_payment = %{ + amount: "", # Invalid: empty amount + currency: "USD", + externalId: "123" + # Missing required payer field } - assert_raise RuntimeError, "Currency is required", fn -> - MomoapiElixir.Collection.CollectionClient.request_to_pay(body, []) - end + result = Collections.request_to_pay(@valid_config, invalid_payment) + + assert {:error, validation_errors} = result + assert is_list(validation_errors) + + # Should have errors for amount and payer + error_fields = Enum.map(validation_errors, & &1.field) + assert :amount in error_fields + assert :payer in error_fields end - test "raises an error when the party id is missing" do - body = %{ - amount: "10", - currency: "EUR", - externalId: "123456", - payer: %{ - partyIdType: "MSISDN", - partyId: "" - }, - payerMessage: "testing", - payeeNote: "hello" - } - assert_raise RuntimeError, "Party id is required", fn -> - MomoapiElixir.Collection.CollectionClient.request_to_pay(body, []) - end + test "returns error on auth failure" do + ClientMock + |> expect(:post, fn "/collection/token/", _body, _headers -> + {:ok, %{status_code: 401, body: "{\"error\": \"unauthorized\"}"}} + end) + + result = Collections.request_to_pay(@valid_config, @valid_payment) + + assert {:error, {:auth_failed, 401, %{"error" => "unauthorized"}}} = result end - test "raises an error when the party id type is missing" do - body = %{ - amount: "10", - currency: "EUR", - externalId: "123456", - payer: %{ - partyIdType: "", - partyId: "256784275529" - }, - payerMessage: "testing", - payeeNote: "hello" - } - assert_raise RuntimeError, "Party id type is required", fn -> - MomoapiElixir.Collection.CollectionClient.request_to_pay(body, []) - end + test "returns error on payment request failure" do + # Mock successful auth + ClientMock + |> expect(:post, fn "/collection/token/", _body, _headers -> + {:ok, %{status_code: 200, body: "{\"access_token\": \"test_token\"}"}} + end) + + # Mock failed payment request + ClientMock + |> expect(:post, fn "/collection/v1_0/requesttopay", _body, _headers -> + {:ok, %{status_code: 400, body: "{\"error\": \"bad_request\"}"}} + end) + + result = Collections.request_to_pay(@valid_config, @valid_payment) + + assert {:error, %{status_code: 400, body: %{"error" => "bad_request"}}} = result end + end + + describe "get_balance/1" do + test "returns balance on success" do + expected_balance = %{"availableBalance" => "1000", "currency" => "UGX"} + + # Mock auth + ClientMock + |> expect(:post, fn "/collection/token/", _body, _headers -> + {:ok, %{status_code: 200, body: "{\"access_token\": \"test_token\"}"}} + end) + + # Mock balance request + ClientMock + |> expect(:get, fn "/collection/v1_0/account/balance", headers -> + assert {"Authorization", "Bearer test_token"} in headers + {:ok, %{status_code: 200, body: Poison.encode!(expected_balance)}} + end) - test "raises an error when the body is empty" do - assert_raise RuntimeError, "Body is empty", fn -> - MomoapiElixir.Collection.CollectionClient.request_to_pay(%{}, []) - end + result = Collections.get_balance(@valid_config) + + assert {:ok, ^expected_balance} = result end - test "test makes correct request to get balance" do + test "returns error on failure" do + # Mock auth + ClientMock + |> expect(:post, fn "/collection/token/", _body, _headers -> + {:ok, %{status_code: 200, body: "{\"access_token\": \"test_token\"}"}} + end) + + # Mock failed balance request ClientMock - |> expect(:get, fn _url, _headers -> %{"availableBalance" => "25", "currency" => "EUR"} end) + |> expect(:get, fn "/collection/v1_0/account/balance", _headers -> + {:ok, %{status_code: 404, body: "{\"error\": \"not_found\"}"}} + end) - response = MomoapiElixir.Collection.CollectionClient.get_balance([]) - assert response == %{"availableBalance" => "25", "currency" => "EUR"} + result = Collections.get_balance(@valid_config) + + assert {:error, %{status_code: 404, body: %{"error" => "not_found"}}} = result end + end - test "test makes correct request to get transaction status" do + describe "get_transaction_status/2" do + test "returns transaction data on success" do reference_id = UUID.uuid4() - expected_response = %{ - "amount" => "10", - "currency" => "EUR", - "externalId" => "123456", - "payeeNote" => "hello", - "payer" => %{ - "partyId" => "46733123450", - "partyIdType" => "MSISDN" - }, - "payerMessage" => "testing", - "reason" => "INTERNAL_PROCESSING_ERROR", - "status" => "FAILED" + expected_transaction = %{ + "amount" => "100", + "currency" => "UGX", + "status" => "SUCCESSFUL" } + # Mock auth ClientMock - |> expect( - :get, - fn _url, _headers -> expected_response - end - ) - response = MomoapiElixir.Collection.CollectionClient.get_transaction_status(reference_id, []) - assert expected_response == response + |> expect(:post, fn "/collection/token/", _body, _headers -> + {:ok, %{status_code: 200, body: "{\"access_token\": \"test_token\"}"}} + end) + + # Mock transaction status request + ClientMock + |> expect(:get, fn "/collection/v1_0/requesttopay/" <> ^reference_id, headers -> + assert {"Authorization", "Bearer test_token"} in headers + assert {"X-Reference-Id", ^reference_id} in headers + {:ok, %{status_code: 200, body: Poison.encode!(expected_transaction)}} + end) + + result = Collections.get_transaction_status(@valid_config, reference_id) + + assert {:ok, ^expected_transaction} = result end end end \ No newline at end of file diff --git a/test/disbursements_test.exs b/test/disbursements_test.exs index ba1c71a..ab26203 100644 --- a/test/disbursements_test.exs +++ b/test/disbursements_test.exs @@ -2,138 +2,178 @@ defmodule MomoapiElixir.DisbursementsTest do use ExUnit.Case, async: true import Mox + alias MomoapiElixir.Disbursements + # Make sure mocks are verified when the test exits setup :verify_on_exit! - describe "Disbursements" do - test "makes the correct request" do - reference_id = UUID.uuid4() - body = %{ - amount: "10", - currency: "EUR", - externalId: "947354", - payee: %{ - partyIdType: "MSISDN", - partyId: "+256776564739" - }, - payerMessage: "testing", - payeeNote: "hello" - } + @valid_config %{ + subscription_key: "test_subscription_key", + user_id: "test_user_id", + api_key: "test_api_key", + target_environment: "sandbox" + } + + @valid_transfer %{ + amount: "50", + currency: "UGX", + externalId: "transfer_456", + payee: %{ + partyIdType: "MSISDN", + partyId: "256784123456" + }, + payerMessage: "Transfer payment", + payeeNote: "Money transfer" + } + + describe "transfer/2" do + test "returns reference_id on successful transfer request" do + # Mock auth token request ClientMock - |> expect(:post, fn _url, _body, _headers -> reference_id end) - response = MomoapiElixir.Disbursement.DisbursementClient.transfer(body, []) - assert reference_id == response - end + |> expect(:post, fn "/disbursement/token/", _body, _headers -> + {:ok, %{status_code: 200, body: "{\"access_token\": \"test_token\"}"}} + end) - test "raises an error when the amount is missing" do - body = %{ - amount: "", - currency: "EUR", - externalId: "947354", - payee: %{ - partyIdType: "MSISDN", - partyId: "+256776564739" - }, - payerMessage: "testing", - payeeNote: "hello" - } + # Mock transfer request + ClientMock + |> expect(:post, fn "/disbursement/v1_0/transfer", _body, headers -> + # Verify headers are correct + assert {"Authorization", "Bearer test_token"} in headers + assert {"Ocp-Apim-Subscription-Key", "test_subscription_key"} in headers + assert {"X-Target-Environment", "sandbox"} in headers + + # Check that X-Reference-Id is present + reference_id_header = Enum.find(headers, fn {key, _} -> key == "X-Reference-Id" end) + assert reference_id_header != nil + + {:ok, %{status_code: 202, body: ""}} + end) - assert_raise RuntimeError, "Amount is required", fn -> - MomoapiElixir.Disbursement.DisbursementClient.transfer(body, []) - end + result = Disbursements.transfer(@valid_config, @valid_transfer) + + assert {:ok, reference_id} = result + assert is_binary(reference_id) + assert String.length(reference_id) > 0 end - test "raises an error when the currency is missing" do - body = %{ - amount: "10", - currency: "", - externalId: "947354", - payee: %{ - partyIdType: "MSISDN", - partyId: "+256776564739" - }, - payerMessage: "testing", - payeeNote: "hello" + test "returns error on validation failure" do + invalid_transfer = %{ + amount: "0", # Invalid: zero amount + currency: "invalid", # Invalid currency format + externalId: "123" + # Missing required payee field } - assert_raise RuntimeError, "Currency is required", fn -> - MomoapiElixir.Disbursement.DisbursementClient.transfer(body, []) - end + result = Disbursements.transfer(@valid_config, invalid_transfer) + + assert {:error, validation_errors} = result + assert is_list(validation_errors) + + # Should have errors for amount, currency, and payee + error_fields = Enum.map(validation_errors, & &1.field) + assert :amount in error_fields + assert :currency in error_fields + assert :payee in error_fields end - test "raises an error when the party id is missing" do - body = %{ - amount: "10", - currency: "EUR", - externalId: "123456", - payee: %{ - partyIdType: "MSISDN", - partyId: "" - }, - payerMessage: "testing", - payeeNote: "hello" - } - assert_raise RuntimeError, "Party id is required", fn -> - MomoapiElixir.Disbursement.DisbursementClient.transfer(body, []) - end + test "returns error on auth failure" do + ClientMock + |> expect(:post, fn "/disbursement/token/", _body, _headers -> + {:ok, %{status_code: 401, body: "{\"error\": \"unauthorized\"}"}} + end) + + result = Disbursements.transfer(@valid_config, @valid_transfer) + + assert {:error, {:auth_failed, 401, %{"error" => "unauthorized"}}} = result end + test "returns error on transfer request failure" do + # Mock successful auth + ClientMock + |> expect(:post, fn "/disbursement/token/", _body, _headers -> + {:ok, %{status_code: 200, body: "{\"access_token\": \"test_token\"}"}} + end) - test "raises an error when the party id type is missing" do - body = %{ - amount: "10", - currency: "EUR", - externalId: "123456", - payee: %{ - partyIdType: "", - partyId: "256784275529" - }, - payerMessage: "testing", - payeeNote: "hello" - } - assert_raise RuntimeError, "Party id type is required", fn -> - MomoapiElixir.Disbursement.DisbursementClient.transfer(body, []) - end + # Mock failed transfer request + ClientMock + |> expect(:post, fn "/disbursement/v1_0/transfer", _body, _headers -> + {:ok, %{status_code: 500, body: "{\"error\": \"internal_server_error\"}"}} + end) + + result = Disbursements.transfer(@valid_config, @valid_transfer) + + assert {:error, %{status_code: 500, body: %{"error" => "internal_server_error"}}} = result end + end + + describe "get_balance/1" do + test "returns balance on success" do + expected_balance = %{"availableBalance" => "5000", "currency" => "UGX"} + + # Mock auth + ClientMock + |> expect(:post, fn "/disbursement/token/", _body, _headers -> + {:ok, %{status_code: 200, body: "{\"access_token\": \"test_token\"}"}} + end) + + # Mock balance request + ClientMock + |> expect(:get, fn "/disbursement/v1_0/account/balance", headers -> + assert {"Authorization", "Bearer test_token"} in headers + {:ok, %{status_code: 200, body: Poison.encode!(expected_balance)}} + end) - test "raises an error when the body is empty" do - assert_raise RuntimeError, "Body is empty", fn -> - MomoapiElixir.Disbursement.DisbursementClient.transfer(%{}, []) - end + result = Disbursements.get_balance(@valid_config) + + assert {:ok, ^expected_balance} = result end - test "test makes correct request to get balance" do + test "returns error on service unavailable" do + # Mock auth + ClientMock + |> expect(:post, fn "/disbursement/token/", _body, _headers -> + {:ok, %{status_code: 200, body: "{\"access_token\": \"test_token\"}"}} + end) + + # Mock failed balance request ClientMock - |> expect(:get, fn _url, _headers -> %{"availableBalance" => "25", "currency" => "EUR"} end) + |> expect(:get, fn "/disbursement/v1_0/account/balance", _headers -> + {:ok, %{status_code: 503, body: "{\"error\": \"service_unavailable\"}"}} + end) - response = MomoapiElixir.Disbursement.DisbursementClient.get_balance([]) - assert response == %{"availableBalance" => "25", "currency" => "EUR"} + result = Disbursements.get_balance(@valid_config) + + assert {:error, %{status_code: 503, body: %{"error" => "service_unavailable"}}} = result end + end - test "test makes correct request to get transaction status" do + describe "get_transaction_status/2" do + test "returns transaction data on success" do reference_id = UUID.uuid4() - expected_response = %{ - "amount" => "10", - "currency" => "EUR", - "externalId" => "123456", - "payeeNote" => "hello", - "payee" => %{ - "partyId" => "46733123450", - "partyIdType" => "MSISDN" - }, - "payerMessage" => "testing", - "reason" => "INTERNAL_PROCESSING_ERROR", - "status" => "FAILED" + expected_transaction = %{ + "amount" => "50", + "currency" => "UGX", + "status" => "SUCCESSFUL", + "externalId" => "transfer_456" } + # Mock auth ClientMock - |> expect( - :get, - fn _url, _headers -> expected_response - end - ) - response = MomoapiElixir.Disbursement.DisbursementClient.get_transaction_status(reference_id, []) - assert expected_response == response + |> expect(:post, fn "/disbursement/token/", _body, _headers -> + {:ok, %{status_code: 200, body: "{\"access_token\": \"test_token\"}"}} + end) + + # Mock transaction status request + ClientMock + |> expect(:get, fn "/disbursement/v1_0/transfer/" <> ^reference_id, headers -> + assert {"Authorization", "Bearer test_token"} in headers + assert {"X-Reference-Id", ^reference_id} in headers + {:ok, %{status_code: 200, body: Poison.encode!(expected_transaction)}} + end) + + result = Disbursements.get_transaction_status(@valid_config, reference_id) + + assert {:ok, ^expected_transaction} = result end end end \ No newline at end of file diff --git a/test/validator_test.exs b/test/validator_test.exs new file mode 100644 index 0000000..906bf6e --- /dev/null +++ b/test/validator_test.exs @@ -0,0 +1,301 @@ +defmodule MomoapiElixir.ValidatorTest do + use ExUnit.Case, async: true + + alias MomoapiElixir.Validator + + describe "validate_collections/1" do + test "validates successful collections request" do + valid_request = %{ + amount: "100", + currency: "UGX", + externalId: "payment_123", + payer: %{ + partyIdType: "MSISDN", + partyId: "256784123456" + }, + payerMessage: "Payment for goods", + payeeNote: "Thank you" + } + + assert {:ok, ^valid_request} = Validator.validate_collections(valid_request) + end + + test "returns error for empty body" do + assert {:error, [error]} = Validator.validate_collections(%{}) + assert error.field == :body + assert error.message == "Request body cannot be empty" + end + + test "returns error for missing required fields" do + invalid_request = %{ + currency: "UGX" + # Missing amount, externalId, payer + } + + assert {:error, errors} = Validator.validate_collections(invalid_request) + error_fields = Enum.map(errors, & &1.field) + + assert :amount in error_fields + assert :externalId in error_fields + assert :payer in error_fields + end + + test "returns error for empty required fields" do + invalid_request = %{ + amount: "", + currency: "", + externalId: "", + payer: %{} + } + + assert {:error, errors} = Validator.validate_collections(invalid_request) + error_fields = Enum.map(errors, & &1.field) + + assert :amount in error_fields + assert :currency in error_fields + assert :externalId in error_fields + end + + test "validates amount format and positivity" do + # Test invalid amount formats + invalid_amounts = ["0", "-10", "abc", "10.5.5", ""] + + for invalid_amount <- invalid_amounts do + request = %{ + amount: invalid_amount, + currency: "UGX", + externalId: "123", + payer: %{partyIdType: "MSISDN", partyId: "256784123456"} + } + + assert {:error, errors} = Validator.validate_collections(request) + assert Enum.any?(errors, &(&1.field == :amount)) + end + + # Test valid amounts + valid_amounts = ["1", "100", "1000.50", "0.01"] + + for valid_amount <- valid_amounts do + request = %{ + amount: valid_amount, + currency: "UGX", + externalId: "123", + payer: %{partyIdType: "MSISDN", partyId: "256784123456"} + } + + assert {:ok, _} = Validator.validate_collections(request) + end + end + + test "validates currency format" do + # Invalid currencies + invalid_currencies = ["ug", "UGXX", "123", "ugx"] + + for invalid_currency <- invalid_currencies do + request = %{ + amount: "100", + currency: invalid_currency, + externalId: "123", + payer: %{partyIdType: "MSISDN", partyId: "256784123456"} + } + + assert {:error, errors} = Validator.validate_collections(request) + assert Enum.any?(errors, &(&1.field == :currency)) + end + + # Valid currencies + valid_currencies = ["UGX", "EUR", "USD"] + + for valid_currency <- valid_currencies do + request = %{ + amount: "100", + currency: valid_currency, + externalId: "123", + payer: %{partyIdType: "MSISDN", partyId: "256784123456"} + } + + assert {:ok, _} = Validator.validate_collections(request) + end + end + + test "validates payer details" do + # Missing payer fields + request_missing_party_id = %{ + amount: "100", + currency: "UGX", + externalId: "123", + payer: %{partyIdType: "MSISDN"} + } + + assert {:error, errors} = Validator.validate_collections(request_missing_party_id) + assert Enum.any?(errors, &(&1.field == :"payer.partyId")) + + # Invalid party ID type + request_invalid_type = %{ + amount: "100", + currency: "UGX", + externalId: "123", + payer: %{partyIdType: "INVALID", partyId: "123456789"} + } + + assert {:error, errors} = Validator.validate_collections(request_invalid_type) + assert Enum.any?(errors, &(&1.field == :"payer.partyIdType")) + + # Invalid MSISDN format + request_invalid_msisdn = %{ + amount: "100", + currency: "UGX", + externalId: "123", + payer: %{partyIdType: "MSISDN", partyId: "abc"} + } + + assert {:error, errors} = Validator.validate_collections(request_invalid_msisdn) + assert Enum.any?(errors, &(&1.field == :"payer.partyId")) + + # Valid MSISDN formats + valid_msisdns = ["256784123456", "+256784123456", "46733123450"] + + for msisdn <- valid_msisdns do + request = %{ + amount: "100", + currency: "UGX", + externalId: "123", + payer: %{partyIdType: "MSISDN", partyId: msisdn} + } + + assert {:ok, _} = Validator.validate_collections(request) + end + end + + test "validates message length" do + long_message = String.duplicate("a", 161) + + request_long_payer_message = %{ + amount: "100", + currency: "UGX", + externalId: "123", + payer: %{partyIdType: "MSISDN", partyId: "256784123456"}, + payerMessage: long_message + } + + assert {:error, errors} = Validator.validate_collections(request_long_payer_message) + assert Enum.any?(errors, &(&1.field == :payerMessage)) + + request_long_payee_note = %{ + amount: "100", + currency: "UGX", + externalId: "123", + payer: %{partyIdType: "MSISDN", partyId: "256784123456"}, + payeeNote: long_message + } + + assert {:error, errors} = Validator.validate_collections(request_long_payee_note) + assert Enum.any?(errors, &(&1.field == :payeeNote)) + end + + test "validates email format for payer" do + # Invalid email + request_invalid_email = %{ + amount: "100", + currency: "UGX", + externalId: "123", + payer: %{partyIdType: "EMAIL", partyId: "invalid-email"} + } + + assert {:error, errors} = Validator.validate_collections(request_invalid_email) + assert Enum.any?(errors, &(&1.field == :"payer.partyId")) + + # Valid email + request_valid_email = %{ + amount: "100", + currency: "UGX", + externalId: "123", + payer: %{partyIdType: "EMAIL", partyId: "user@example.com"} + } + + assert {:ok, _} = Validator.validate_collections(request_valid_email) + end + end + + describe "validate_disbursements/1" do + test "validates successful disbursements request" do + valid_request = %{ + amount: "50", + currency: "UGX", + externalId: "transfer_456", + payee: %{ + partyIdType: "MSISDN", + partyId: "256784123456" + } + } + + assert {:ok, ^valid_request} = Validator.validate_disbursements(valid_request) + end + + test "returns error for missing payee field" do + invalid_request = %{ + amount: "50", + currency: "UGX", + externalId: "transfer_456" + # Missing payee + } + + assert {:error, errors} = Validator.validate_disbursements(invalid_request) + error_fields = Enum.map(errors, & &1.field) + assert :payee in error_fields + end + + test "validates payee details similar to payer" do + # Invalid payee party ID type + request_invalid_type = %{ + amount: "50", + currency: "UGX", + externalId: "transfer_456", + payee: %{partyIdType: "INVALID", partyId: "123456789"} + } + + assert {:error, errors} = Validator.validate_disbursements(request_invalid_type) + assert Enum.any?(errors, &(&1.field == :"payee.partyIdType")) + + # Valid payee with EMAIL + request_valid_email = %{ + amount: "50", + currency: "UGX", + externalId: "transfer_456", + payee: %{partyIdType: "EMAIL", partyId: "recipient@example.com"} + } + + assert {:ok, _} = Validator.validate_disbursements(request_valid_email) + end + + test "handles PARTY_CODE type gracefully" do + request_party_code = %{ + amount: "50", + currency: "UGX", + externalId: "transfer_456", + payee: %{partyIdType: "PARTY_CODE", partyId: "SOME_CODE_123"} + } + + assert {:ok, _} = Validator.validate_disbursements(request_party_code) + end + end + + describe "error structure" do + test "returns structured errors with field, message, and value" do + invalid_request = %{ + amount: "invalid", + currency: "xyz" + } + + assert {:error, errors} = Validator.validate_collections(invalid_request) + + for error <- errors do + assert Map.has_key?(error, :field) + assert Map.has_key?(error, :message) + assert Map.has_key?(error, :value) + assert is_atom(error.field) + assert is_binary(error.message) + end + end + end +end \ No newline at end of file