Packages

A small Elixir client for Kopo Kopo OAuth, M-PESA STK Push prompts, payment verification, and callback signature validation.

Current section

Files

Jump to
kopokopo lib kopokopo.ex
Raw

lib/kopokopo.ex

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(<<x, left::binary>>, <<y, right::binary>>, 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