Skip to main content

README.md

# AirtelMoney

An Elixir SDK for Airtel Money APIs, providing a clean and idiomatic interface for collections, disbursements, transaction queries, and webhooks.

## Features

- **Collections** - Receive payments from customers (USSD Push)
- **Disbursements** - Send payments to customers with PIN encryption
- **Transfer Status** - Check disbursement transfer status
- **Transaction Status** - Query collection transaction status
- **Balance Queries** - Check account balance
- **OAuth Token Management** - Automatic token handling and refresh
- **PIN Encryption** - RSA encryption for disbursement PINs
- **MSISDN Validation** - Phone number format validation
- **Webhook Verification** - HMAC SHA256 signature verification
- **Telemetry** - Built-in telemetry events for monitoring
- **OTP Supervision** - Robust supervision tree for production use
- **Sandbox & Production** - Support for both environments

## Installation

Add `airtel_money` to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:airtel_money, "~> 0.1.0"}
  ]
end
```

Run:

```bash
mix deps.get
```

## Configuration

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

```elixir
config :airtel_money,
  client_id: "your_client_id",
  client_secret: "your_client_secret",
  country: "CD",
  currency: "CDF",
  environment: :sandbox,
  webhook_secret: "your_webhook_secret" # Optional, for webhook verification
```

### Configuration Options

- `:client_id` (required) - Your Airtel Money client ID
- `:client_secret` (required) - Your Airtel Money client secret
- `:country` (required) - Country code (e.g., "CD" for Democratic Republic of Congo)
- `:currency` (required) - Currency code (e.g., "CDF" for Congolese Franc)
- `:environment` (optional) - `:sandbox` or `:production` (default: `:sandbox`)
- `:host` (optional) - Custom API host (overrides default)
- `:timeout` (optional) - HTTP request timeout in milliseconds (default: 15000)
- `:pool_size` (optional) - Connection pool size (default: 10)
- `:webhook_secret` (optional) - Webhook signature secret for verification
- `:rsa_public_key` (optional) - RSA public key for PIN encryption (required for disbursements in production)

## Usage

### Start the Application

The SDK uses OTP supervision. Ensure the application is started:

```elixir
# In your application.ex
children = [
  AirtelMoney.Application
]
```

### Collect a Payment

```elixir
case AirtelMoney.collect(%{
  amount: "1000",
  msisdn: "2439xxxxxxx",
  reference: "INV-001"
}) do
  {:ok, result} ->
    IO.inspect(result)

  {:error, %AirtelMoney.Error{message: message}} ->
    IO.puts("Error: #{message}")
end
```

### Disburse a Payment

```elixir
# For production, you need to encrypt the PIN first
case AirtelMoney.Encryption.encrypt_pin("1234") do
  {:ok, encrypted_pin} ->
    case AirtelMoney.disburse(%{
      amount: "5000",
      msisdn: "2439xxxxxxx",
      reference: "PAY-001",
      pin: encrypted_pin
    }) do
      {:ok, result} ->
        IO.inspect(result)

      {:error, %AirtelMoney.Error{message: message}} ->
        IO.puts("Error: #{message}")
    end

  {:error, reason} ->
    IO.puts("PIN encryption failed: #{reason}")
end
```

### Query Transaction Status

```elixir
case AirtelMoney.transaction_status("TXN123") do
  {:ok, status} ->
    IO.inspect(status)

  {:error, %AirtelMoney.Error{message: message}} ->
    IO.puts("Error: #{message}")
end
```

### Query Balance

```elixir
case AirtelMoney.balance() do
  {:ok, balance} ->
    IO.inspect(balance)

  {:error, %AirtelMoney.Error{message: message}} ->
    IO.puts("Error: #{message}")
end
```

### Validate MSISDN

```elixir
case AirtelMoney.Utils.validate_msisdn("243900000000") do
  :ok ->
    IO.puts("Valid MSISDN")

  {:error, reason} ->
    IO.puts("Invalid MSISDN: #{reason}")
end
```

### Fetch RSA Public Key

```elixir
case AirtelMoney.Encryption.fetch_public_key() do
  {:ok, public_key} ->
    IO.puts("Public key fetched successfully")
    # Store this key in your config for future use

  {:error, error} ->
    IO.puts("Failed to fetch public key: #{error.message}")
end
```

## Webhooks

The SDK supports both authenticated and unauthenticated webhooks as per the official Airtel Money API documentation.

### Webhook Payload Format

Airtel sends transaction status updates to your callback URL with the following format:

```json
{
  "transaction": {
    "id": "BBZMiscxy",
    "message": "Paid KES 5,000 to TECHNOLOGIES LIMITED",
    "status_code": "TS",
    "airtel_money_id": "MP210603.1234.L06941"
  },
  "hash": "zITVAAGYSlzl1WkUQJn81kbpT5drH3koffT8jCkcJJA="
}
```

Status codes:
- `TS` - Transaction Success
- `TF` - Transaction Failed

### Using the Plug (Phoenix)

**With Authentication (Recommended for Production):**

```elixir
pipeline :webhooks do
  plug AirtelMoney.WebhookPlug
end

scope "/webhooks" do
  pipe_through :webhooks
  post "/airtel", WebhookController, :handle
end
```

**Without Authentication (For Testing):**

```elixir
pipeline :webhooks do
  plug AirtelMoney.WebhookPlug, require_auth: false
end

scope "/webhooks" do
  pipe_through :webhooks
  post "/airtel", WebhookController, :handle
end
```

The plug will:
- Verify the webhook signature (if `require_auth: true`)
- Parse the JSON payload
- Assign the parsed data to `conn.assigns[:airtel_webhook]`
- Return 401 if verification fails (when authentication is enabled)

### Webhook Controller Example

```elixir
defmodule MyAppWeb.WebhookController do
  use MyAppWeb, :controller

  def handle(conn, _params) do
    # The plug already verified the signature and parsed the payload
    webhook_data = conn.assigns[:airtel_webhook]
    
    case webhook_data do
      %{transaction: %{id: txn_id, status_code: status_code}} ->
        # Update your database based on transaction status
        if status_code == "TS" do
          # Transaction successful - update order, send confirmation
        end
        
        if status_code == "TF" do
          # Transaction failed - notify customer, handle retry
        end
      
      _ ->
        IO.puts("Unknown webhook format")
    end
    
    send_resp(conn, 200, "OK")
  end
end
```

### Manual Webhook Verification

```elixir
# Verify webhook signature (hash is extracted from JSON body automatically)
case AirtelMoney.verify_webhook(payload) do
  :ok ->
    # Signature is valid, process webhook
    {:ok, webhook_data} = AirtelMoney.parse_webhook(payload)
    # Handle webhook_data
    
  {:error, :invalid_signature} ->
    # Invalid signature
  {:error, :missing_hash} ->
    # Hash not found in payload
  {:error, :webhook_secret_not_configured} ->
    # Webhook secret not configured
end
```

## Telemetry

The SDK emits telemetry events for monitoring:

- `[:airtel_money, :success]` - Successful API request
- `[:airtel_money, :failure]` - Failed API request

Attach handlers to monitor events:

```elixir
:telemetry.attach(
  "airtel-money-handler",
  [:airtel_money, :success],
  &handle_event/4,
  nil
)

def handle_event([:airtel_money, event], measurements, metadata, _config) do
  IO.puts("Event: #{event}, Duration: #{measurements.duration}ms")
end
```

## Error Handling

All API functions return `{:ok, result}` or `{:error, %AirtelMoney.Error{}}`.

```elixir
%AirtelMoney.Error{
  code: "ERR001",
  message: "Invalid request",
  status: 400
}
```

## Testing

Run tests:

```bash
mix test
```

Run tests with coverage:

```bash
mix test.ci
```

## Development

### Linting

```bash
mix lint
```

### Setup

```bash
mix setup
```

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

## License

This project is licensed under the MIT License.

## Documentation

Full documentation is available at [HexDocs](https://hexdocs.pm/airtel_money).