Skip to main content

lib/airtel_money.ex

defmodule AirtelMoney do
  @moduledoc """
  Airtel Money SDK for Elixir.

  Provides a clean interface for interacting with Airtel Money APIs including:
  - Collections (receiving payments)
  - Disbursements (sending payments)
  - Transaction status queries
  - Balance queries
  - Webhook verification

  ## Configuration

  Configure the SDK in your `config/config.exs`:

      config :airtel_money,
        client_id: "your_client_id",
        client_secret: "your_client_secret",
        country: "CD",
        currency: "CDF",
        environment: :sandbox

  ## Usage

  ### Collect a payment

      {:ok, result} = AirtelMoney.collect(%{
        amount: "1000",
        msisdn: "2439xxxxxxx",
        reference: "INV-001"
      })

  ### Disburse a payment

      {:ok, result} = AirtelMoney.disburse(%{
        amount: "5000",
        msisdn: "2439xxxxxxx",
        reference: "PAY-001"
      })

  ### Query transaction status

      {:ok, status} = AirtelMoney.transaction_status("TXN123")

  ### Query balance

      {:ok, balance} = AirtelMoney.balance()

  ### Verify webhook

      case AirtelMoney.verify_webhook(payload, signature) do
        :ok -> # Valid webhook
        {:error, :invalid_signature} -> # Invalid signature
      end
  """

  @doc """
  Collects a payment from a customer.

  ## Parameters

  * `params` - A map with:
    * `:amount` - Amount to collect (string)
    * `:msisdn` - Customer phone number (string)
    * `:reference` - Transaction reference (string)
    * `:id_type` - Optional ID type (default: "MSISDN")
    * `:id_number` - Optional ID number
    * `:callback_url` - Optional callback URL

  ## Returns

  * `{:ok, map()}` - Successful collection
  * `{:error, AirtelMoney.Error.t()}` - Failed collection
  """
  @spec collect(map()) :: {:ok, map()} | {:error, AirtelMoney.Error.t()}
  defdelegate collect(params), to: AirtelMoney.Collections, as: :collect

  @doc """
  Disburses a payment to a customer.

  ## Parameters

  * `params` - A map with:
    * `:amount` - Amount to disburse (string)
    * `:msisdn` - Recipient phone number (string)
    * `:reference` - Transaction reference (string)
    * `:id_type` - Optional ID type (default: "MSISDN")
    * `:id_number` - Optional ID number
    * `:callback_url` - Optional callback URL

  ## Returns

  * `{:ok, map()}` - Successful disbursement
  * `{:error, AirtelMoney.Error.t()}` - Failed disbursement
  """
  @spec disburse(map()) :: {:ok, map()} | {:error, AirtelMoney.Error.t()}
  defdelegate disburse(params), to: AirtelMoney.Disbursements, as: :disburse

  @doc """
  Queries the status of a transaction.

  ## Parameters

  * `transaction_id` - The transaction ID to query

  ## Returns

  * `{:ok, map()}` - Transaction status
  * `{:error, AirtelMoney.Error.t()}` - Failed query
  """
  @spec transaction_status(String.t()) :: {:ok, map()} | {:error, AirtelMoney.Error.t()}
  defdelegate transaction_status(transaction_id), to: AirtelMoney.Transactions, as: :status

  @doc """
  Queries the account balance.

  ## Returns

  * `{:ok, map()}` - Account balance
  * `{:error, AirtelMoney.Error.t()}` - Failed query
  """
  @spec balance() :: {:ok, map()} | {:error, AirtelMoney.Error.t()}
  defdelegate balance(), to: AirtelMoney.Balance, as: :query

  @doc """
  Verifies a webhook signature.

  ## Parameters

  * `payload` - The raw webhook payload (string)

  ## Returns

  * `:ok` - Valid signature
  * `{:error, :invalid_signature}` - Invalid signature
  * `{:error, :missing_hash}` - Hash not found in payload
  * `{:error, :webhook_secret_not_configured}` - Webhook secret not configured

  ## Note

  The hash is extracted from the JSON body and verified using HMAC SHA256 with Base64 encoding,
  as per the official Airtel Money API documentation.
  """
  @spec verify_webhook(String.t()) :: :ok | {:error, atom()}
  def verify_webhook(payload) do
    config = AirtelMoney.Config.get!()
    secret = Map.get(config, :webhook_secret)

    if secret do
      case AirtelMoney.Webhooks.Parser.extract_hash(payload) do
        {:ok, hash} ->
          AirtelMoney.Webhooks.Verifier.verify(payload, hash, secret)

        {:error, reason} ->
          {:error, reason}
      end
    else
      {:error, :webhook_secret_not_configured}
    end
  end

  @doc """
  Parses a webhook payload.

  ## Parameters

  * `payload` - The raw JSON payload (string)

  ## Returns

  * `{:ok, map()}` - Parsed payload
  * `{:error, :invalid_json}` - Invalid JSON
  """
  @spec parse_webhook(String.t()) :: {:ok, map()} | {:error, :invalid_json}
  defdelegate parse_webhook(payload), to: AirtelMoney.Webhooks.Parser, as: :parse
end