defmodule Pesapal do
@moduledoc """
Handles integration with Pesapal payment gateway API (v3).
This module provides functions to authenticate, register IPN webhooks,
initiate payments, and check transaction statuses with the Pesapal API.
The flow is as follows:
1. Authenticate with Pesapal using `authenticate/2`.
2. Register an IPN webhook using `register_ipn/2`.
3. Initiate a payment using `initiate_payment/6`.
4. Check the transaction status using `check_transaction_status/2`.
5. Handle the IPN notifications sent to your webhook URL.
For your IPN webhook, you need to set up a route in your application
to handle incoming POST requests from Pesapal. The IPN URL should
be publicly accessible and should be able to process the incoming
notifications. This can be a simple post endpoint in your application as follows
```elixir
defmodule AppWeb.OrderController do
use AppWeb, :controller
def create(conn, params) do
IO.inspect(params)
# Handle the IPN notification here
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(%{message: "Success"}))
end
end
# In your router.ex file, add a route for the IPN webhook
defmodule AppWeb.Router do
use AppWeb, :router
scope "/api", AppWeb do
pipe_through :api
post "/webhook", OrderController, :create
end
end
```
## Configuration
You need to set the `api_base_url` in your application configuration , as well as your consumer key and consumer secret.
You can set this in your config file:
```elixir
config :pesapal,
api_base_url: "https://cybqa.pesapal.com/pesapalv3/api",
consumer_key: "qkio1BGGYAXTu2JOfm7XSXNruoZsrqEW",
consumer_secret: "osGQ364R49cXKeOYSpaOnT++rHs="
```
For Production:
```elixir
config :pesapal,
api_base_url: "https://pay.pesapal.com/v3/api",
consumer_key: "qkio1BGGYAXTu2JOfm7XSXNruoZsrqEW",
consumer_secret: "osGQ364R49cXKeOYSpaOnT++rHs="
```
"""
@doc """
Authenticates with Pesapal API and returns an access token.
## Returns
* `{:ok, %{"token" => token, "expiryDate" => expiry_date}}` - Success with token details
* `{:error, reason}` - Error with reason
## Examples
iex> Pesapal.authenticate("your_key", "your_secret")
{:ok, %{"token" => "abc123", "expiryDate" => "2023-01-01T00:00:00Z"}}
"""
def authenticate do
consumer_key = Application.get_env(:pesapal, :consumer_key)
consumer_secret = Application.get_env(:pesapal, :consumer_secret)
url = "#{Application.get_env(:pesapal, :api_base_url)}/Auth/RequestToken"
body = %{
"consumer_key" => consumer_key,
"consumer_secret" => consumer_secret
}
post_request(url, body)
end
@doc """
Registers an Instant Payment Notification (IPN) webhook with Pesapal.
## Parameters
* `webhook_url` - The URL to receive payment notifications
* `token` - The authentication token from `authenticate/2`
## Returns
* `{:ok, %{"ipn_id" => ipn_id, ...}}` - Success with IPN details
* `{:error, reason}` - Error with reason
## Examples
iex> Pesapal.register_ipn("https://example.com/webhook", "abc123")
{:ok, %{"ipn_id" => "ipn123", "url" => "https://example.com/webhook"}}
"""
def register_ipn(webhook_url, token) do
url = "#{Application.get_env(:pesapal, :api_base_url)}/URLSetup/RegisterIPN"
body = %{
"url" => webhook_url,
"ipn_notification_type" => "POST"
}
post_request(url, body, token)
end
@doc """
Initiates a payment transaction with Pesapal.
## Parameters
* `amount` - The payment amount (numeric)
* `email` - Customer's email address
* `currency` - Currency code (e.g., "KES", "USD")
* `ipn_id` - IPN ID from `register_ipn/2`
* `callback_url` - Callback URL where customers will be redirected after successful payment
* `token` - The authentication token from `authenticate/2`
* `opts` - Optional parameters:
* `:description` - Payment description (default: "Payment")
* `:order_id` - Custom order ID (default: auto-generated)
## Returns
* `{:ok, %{"order_tracking_id" => id, "redirect_url" => url, ...}}` - Success with payment details
* `{:error, reason}` - Error with reason
## Examples
iex> Pesapal.initiate_payment(1000, "user@example.com", "KES", "ipn123", "https://example.com/callback", "abc123")
{:ok, %{"order_tracking_id" => "ord123", "redirect_url" => "https://pesapal.com/payment/..."}}
iex>Pesapal.initiate_payment(1000, "user@example.com", "KES", "ipn123", "https://example.com/callback", "abc123", [description: "Test Payment"])
{:ok, %{"order_tracking_id" => "ord123", "redirect_url" => "https://pesapal.com/payment/..."}}
"""
def initiate_payment(amount, email, currency, ipn_id, callback_url, token, opts \\ []) do
url = "#{Application.get_env(:pesapal, :api_base_url)}/Transactions/SubmitOrderRequest"
description = Keyword.get(opts, :description, "Payment")
body = %{
"id" => generate_order_id(),
"currency" => currency,
"amount" => amount,
"description" => description,
"notification_id" => ipn_id,
"callback_url" => callback_url,
"billing_address" => %{
"email_address" => email
}
}
post_request(url, body, token)
end
@doc """
Checks the status of a transaction.
## Parameters
* `order_tracking_id` - The order tracking ID from `initiate_payment/5`
* `token` - The authentication token from `authenticate/2`
## Returns
* `{:ok, %{"status" => status, "payment_method" => method, ...}}` - Success with transaction details
* `{:error, reason}` - Error with reason
## Examples
iex> Pesapal.check_transaction_status("ord123", "abc123")
{:ok, %{"status" => "COMPLETED", "payment_method" => "MPESA"}}
## Note
The status code represents the payment_status_description.
0 - INVALID
1 - COMPLETED
2 - FAILED
3 - REVERSED
"""
def check_transaction_status(order_tracking_id, token) do
url =
"#{Application.get_env(:pesapal, :api_base_url)}/Transactions/GetTransactionStatus?orderTrackingId=#{order_tracking_id}"
get_request(url, token)
end
# Private helper functions
@doc false
defp post_request(url, body, token \\ nil) do
headers = [
{"Content-Type", "application/json"},
{"Accept", "application/json"}
]
# Add authorization header if token is provided
headers = if token, do: [{"Authorization", "Bearer #{token}"} | headers], else: headers
case HTTPoison.post(url, Jason.encode!(body), headers) do
{:ok, %HTTPoison.Response{status_code: status_code, body: response_body}}
when status_code in 200..299 ->
{:ok, Jason.decode!(response_body)}
{:ok, %HTTPoison.Response{status_code: status_code, body: response_body}} ->
parsed_body = parse_error_body(response_body)
{:error, "HTTP Error #{status_code}: #{parsed_body}"}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, "Request failed: #{inspect(reason)}"}
end
end
@doc false
defp get_request(url, token) do
headers = [
{"Content-Type", "application/json"},
{"Accept", "application/json"},
{"Authorization", "Bearer #{token}"}
]
case HTTPoison.get(url, headers) do
{:ok, %HTTPoison.Response{status_code: status_code, body: response_body}}
when status_code in 200..299 ->
{:ok, Jason.decode!(response_body)}
{:ok, %HTTPoison.Response{status_code: status_code, body: response_body}} ->
parsed_body = parse_error_body(response_body)
{:error, "HTTP Error #{status_code}: #{parsed_body}"}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, "Request failed: #{inspect(reason)}"}
end
end
@doc false
defp parse_error_body(body) do
case Jason.decode(body) do
{:ok, decoded} when is_map(decoded) ->
error_message = decoded["error"] || decoded["message"] || inspect(decoded)
error_message
_ ->
body
end
end
@doc false
defp generate_order_id do
DateTime.utc_now()
|> DateTime.to_string()
|> String.replace(~r/[^0-9]/, "")
|> String.slice(0, 14)
end
end