defmodule KopoKopo do @moduledoc """ Client helpers for Kopo Kopo payments. The client supports OAuth client credentials, M-PESA STK Push incoming payments, status verification, and callback signature validation. ## Configuration config :kopokopo, base_url: "https://api.kopokopo.com", client_id: System.fetch_env!("KOPOKOPO_CLIENT_ID"), client_secret: System.fetch_env!("KOPOKOPO_CLIENT_SECRET"), api_key: System.fetch_env!("KOPOKOPO_API_KEY"), till_number: System.fetch_env!("KOPOKOPO_TILL_NUMBER"), callback_url: "https://example.com/payments/kopokopo/callback", user_agent: "my_app/1.0" For local sandbox work, set `base_url` to `https://sandbox.kopokopo.com`. """ @default_base_url "https://sandbox.kopokopo.com" @default_user_agent "kopokopo/0.1.0" defstruct [:status, :url, :body] @type t :: %__MODULE__{status: pos_integer() | nil, url: String.t() | nil, body: term()} @type payment_result :: {:ok, map()} | {:pending, map()} | {:error, term()} @doc """ Converts an error reason returned by this client into displayable text. """ def error_text(%__MODULE__{body: body}) when is_binary(body), do: body def error_text(%__MODULE__{status: status, body: body}), do: "HTTP #{status}: #{inspect(body)}" def error_text(reason) when is_binary(reason), do: reason def error_text(reason), do: inspect(reason) @doc """ Initiates an M-PESA STK Push payment. Expected fields are: * `:reference` * `:amount` * `:phone` * `:email` * `:name` Returns `{:ok, %{location: location, reference: reference}}` when Kopo Kopo accepts the payment request. """ @spec initiate_payment(map()) :: {:ok, map()} | {:error, term()} def initiate_payment(%{ reference: reference, amount: amount, phone: phone, email: email, name: name }) do with {:ok, token} <- access_token() do body = incoming_payment_body(%{ reference: reference, amount: amount, phone: phone, email: email, name: name }) case Req.post("#{base_url()}/api/v2/incoming_payments", headers: json_headers(token), json: body, receive_timeout: 30_000 ) do {:ok, %{status: 201, headers: headers}} -> {:ok, %{location: header(headers, "location"), reference: reference}} {:ok, %{status: status, body: body}} -> {:error, error_message(status, "#{base_url()}/api/v2/incoming_payments", body)} {:error, reason} -> {:error, inspect(reason)} end end end @doc """ Sends a small STK Push test prompt. This uses configured credentials and till details, so it can trigger a real M-PESA prompt when pointed at production. """ @spec test_prompt(String.t(), String.t() | number()) :: {:ok, map()} | {:error, term()} def test_prompt(phone \\ "0740769596", amount \\ "1") do reference = "TEST-#{System.system_time(:second)}" initiate_payment(%{ reference: reference, amount: amount, phone: phone, email: "payments-test@example.com", name: "Kopo Kopo Test" }) end @doc """ Checks whether configured credentials can obtain an OAuth access token. """ @spec auth_check() :: {:ok, String.t()} | {:error, term()} def auth_check, do: access_token() @doc """ Checks credentials against a temporary base URL. """ @spec auth_check(String.t()) :: {:ok, String.t()} | {:error, term()} def auth_check(base_url) when is_binary(base_url) do with_temporary_base_url(base_url, &access_token/0) end @doc """ Sends a test prompt against a temporary base URL. """ @spec test_prompt(String.t(), String.t(), String.t() | number()) :: {:ok, map()} | {:error, term()} def test_prompt(base_url, phone, amount) when is_binary(base_url) do with_temporary_base_url(base_url, fn -> test_prompt(phone, amount) end) end @doc """ Returns the outgoing STK Push payload without making a network request. """ @spec debug_prompt_payload(String.t(), String.t() | number()) :: map() def debug_prompt_payload(phone \\ "0740769596", amount \\ "1") do reference = "TEST-#{System.system_time(:second)}" %{ url: "#{base_url()}/api/v2/incoming_payments", callback_url: callback_url(), body: incoming_payment_body(%{ reference: reference, amount: amount, phone: phone, email: "payments-test@example.com", name: "Kopo Kopo Test" }) } end @doc """ Verifies a payment status URL returned by `initiate_payment/1`. """ @spec verify(String.t()) :: payment_result() def verify(location) when is_binary(location) and location != "" do with {:ok, token} <- access_token() do case Req.get(location, headers: json_headers(token), receive_timeout: 30_000) do {:ok, %{status: 200, body: %{"data" => data}}} -> payment_result(data) {:ok, %{status: status, body: body}} -> {:error, error_message(status, location, body)} {:error, reason} -> {:error, inspect(reason)} end end end def verify(_), do: {:error, "Missing Kopo Kopo payment request URL."} @doc """ Normalizes Kopo Kopo payment status response data into `:ok`, `:pending`, or `:error` tuples. """ @spec payment_result(map()) :: payment_result() def payment_result(%{"attributes" => %{"status" => "Success"}} = data), do: {:ok, data} def payment_result(%{"attributes" => %{"status" => status}} = data) when status in ["Pending", "Received", "Initiated"] do {:pending, data} end def payment_result(%{"attributes" => %{"status" => status, "event" => event}}) do {:error, event_error(event) || "Payment status: #{status}"} end def payment_result(_), do: {:error, "Unexpected Kopo Kopo payment response."} @doc """ Validates a callback body against the `x-kopokopo-signature` header. """ @spec valid_signature?(binary(), binary()) :: boolean() def valid_signature?(body, signature) when is_binary(body) and is_binary(signature) do expected_signature = :crypto.mac(:hmac, :sha256, api_key(), body) |> Base.encode16(case: :lower) secure_compare(String.downcase(signature), expected_signature) end def valid_signature?(_body, _signature), do: false @doc """ Returns the configured callback URL. If `:callback_url` is configured, it is used directly. Otherwise the URL is built from `:site_url` and `:callback_path`. """ @spec callback_url() :: String.t() def callback_url do configured = Application.get_env(:kopokopo, :callback_url) if configured do configured else Path.join(site_url(), callback_path()) end end defp access_token do body = %{ "client_id" => client_id(), "client_secret" => client_secret(), "grant_type" => "client_credentials" } case Req.post("#{base_url()}/oauth/token", headers: [ {"Content-Type", "application/x-www-form-urlencoded"}, {"User-Agent", user_agent()} ], form: body, receive_timeout: 30_000 ) do {:ok, %{status: 200, body: %{"access_token" => token}}} -> {:ok, token} {:ok, %{status: status, body: body}} -> {:error, error_message(status, "#{base_url()}/oauth/token", body)} {:error, reason} -> {:error, inspect(reason)} end end defp json_headers(token) do [ {"Accept", "application/json"}, {"Content-Type", "application/json"}, {"Authorization", "Bearer #{token}"}, {"User-Agent", user_agent()} ] end defp incoming_payment_body(%{ reference: reference, amount: amount, phone: phone, email: email, name: name }) do %{ "payment_channel" => "M-PESA STK Push", "till_number" => till_number(), "subscriber" => %{ "first_name" => first_name(name), "last_name" => last_name(name), "phone_number" => normalize_phone(phone), "email" => email }, "amount" => %{ "currency" => "KES", "value" => format_amount(amount) }, "metadata" => %{ "reference" => reference, "customer_email" => email, "notes" => "Order #{reference}" }, "_links" => %{ "callback_url" => callback_url() } } end defp with_temporary_base_url(base_url, fun) do previous = Application.get_env(:kopokopo, :base_url) Application.put_env(:kopokopo, :base_url, base_url) try do fun.() after if previous do Application.put_env(:kopokopo, :base_url, previous) else Application.delete_env(:kopokopo, :base_url) end end end defp header(headers, name) do headers |> Enum.find_value(fn {key, values} -> if String.downcase(to_string(key)) == name do List.wrap(values) |> List.first() end end) end defp error_message(%{"error_message" => message}), do: message defp error_message(%{"message" => message}), do: message defp error_message(%{"error_description" => message}), do: message defp error_message(%{"error" => error}), do: error defp error_message(body) when is_binary(body), do: body defp error_message(body), do: inspect(body) defp error_message(status, url, body) do %__MODULE__{status: status, url: url, body: error_message(body)} end defp event_error(%{"errors" => errors}) when is_list(errors), do: Enum.join(errors, ", ") defp event_error(%{"errors" => error}) when is_binary(error), do: error defp event_error(_), do: nil defp first_name(name), do: name |> String.split() |> List.first() || "Customer" defp last_name(name) do case String.split(name) do [_] -> "Customer" parts -> List.last(parts) end end defp normalize_phone("+" <> _ = phone), do: phone defp normalize_phone("0" <> rest), do: "+254#{rest}" defp normalize_phone("254" <> _ = phone), do: "+#{phone}" defp normalize_phone(phone), do: phone defp format_amount(amount) when is_float(amount), do: :erlang.float_to_binary(amount, decimals: 2) defp format_amount(amount), do: to_string(amount) defp secure_compare(left, right) when byte_size(left) == byte_size(right) do secure_compare(left, right, 0) == 0 end defp secure_compare(_left, _right), do: false defp secure_compare(<>, <>, acc) do secure_compare(left, right, Bitwise.bor(acc, Bitwise.bxor(x, y))) end defp secure_compare(<<>>, <<>>, acc), do: acc defp base_url, do: Application.get_env(:kopokopo, :base_url, @default_base_url) defp client_id, do: Application.fetch_env!(:kopokopo, :client_id) defp client_secret, do: Application.fetch_env!(:kopokopo, :client_secret) defp api_key, do: Application.fetch_env!(:kopokopo, :api_key) defp till_number, do: Application.fetch_env!(:kopokopo, :till_number) defp site_url, do: Application.get_env(:kopokopo, :site_url, "https://example.com") defp callback_path do Application.get_env(:kopokopo, :callback_path, "/api/payments/kopokopo/callback") end defp user_agent, do: Application.get_env(:kopokopo, :user_agent, @default_user_agent) end