README.md

# ExPaymob

[![Hex.pm](https://img.shields.io/hexpm/v/ex_paymob.svg)](https://hex.pm/packages/ex_paymob)
[![Hex Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/ex_paymob)
[![License](https://img.shields.io/hexpm/l/ex_paymob.svg)](https://github.com/petershoukry/ex_paymob/blob/main/LICENSE)

Elixir client for the [Paymob](https://paymob.com) payment gateway. Supports Egypt, UAE, KSA, and Oman regions.

## Features

- **Payment Intentions** - Create and manage payments via the V1 API
- **Transaction Management** - Retrieve, inquire, refund, void, and capture
- **Subscriptions** - Plan and subscription CRUD
- **Webhook Verification** - HMAC-SHA512 with timing-safe comparison
- **Phoenix Integration** - Plug for webhook endpoints with automatic verification
- **Multi-Region** - Egypt, UAE, KSA, Oman with per-request overrides
- **Swappable HTTP Client** - Default Req adapter, bring your own via behaviour
- **Igniter Installer** - One command Phoenix setup

## Installation

Add `ex_paymob` to your dependencies:

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

For Phoenix projects, run the installer after adding the dependency:

```bash
mix deps.get
mix ex_paymob.install
```

## Configuration

```elixir
# config/config.exs
config :ex_paymob,
  region: :egypt  # :egypt | :uae | :ksa | :oman

# config/runtime.exs
config :ex_paymob,
  secret_key: System.get_env("PAYMOB_SECRET_KEY"),
  public_key: System.get_env("PAYMOB_PUBLIC_KEY"),
  hmac_secret: System.get_env("PAYMOB_HMAC_SECRET")
```

Every option can be overridden per-request:

```elixir
ExPaymob.Intention.create(params, secret_key: "sk_other", region: :uae)
```

### Configuration Resolution

Values are resolved in order (first match wins):

1. Per-request keyword opts
2. Application environment (`config :ex_paymob, key: value`)
3. System environment (`PAYMOB_SECRET_KEY`, `PAYMOB_PUBLIC_KEY`, `PAYMOB_HMAC_SECRET`)

### Regions

| Region | Base URL |
|--------|----------|
| `:egypt` (default) | `https://accept.paymob.com` |
| `:uae` | `https://uae.paymob.com` |
| `:ksa` | `https://ksa.paymob.com` |
| `:oman` | `https://oman.paymob.com` |

## Usage

### Payment Intentions

```elixir
# Create a payment intention
{:ok, intention} = ExPaymob.Intention.create(%{
  amount: 10000,
  currency: "EGP",
  payment_methods: [integration_id],
  billing_data: %{
    first_name: "John",
    last_name: "Doe",
    email: "john@example.com",
    phone_number: "+201234567890"
  },
  items: []
})

# Build checkout redirect URL
url = ExPaymob.Intention.checkout_url(intention["client_secret"], "pk_public_key")

# Update an intention
{:ok, updated} = ExPaymob.Intention.update(intention["client_secret"], %{amount: 20000})
```

### Transactions

```elixir
# Retrieve by ID
{:ok, txn} = ExPaymob.Transaction.retrieve("12345")

# Inquire by merchant order ID
{:ok, txn} = ExPaymob.Transaction.inquire(%{merchant_order_id: "order_123"})
```

### Refund, Void, and Capture

```elixir
# Refund (partial or full)
{:ok, _} = ExPaymob.Refund.create("transaction_id", 5000)

# Void a transaction
{:ok, _} = ExPaymob.Void.create("transaction_id")

# Capture an authorized transaction
{:ok, _} = ExPaymob.Capture.create("transaction_id", 10000)
```

### Subscriptions

```elixir
# Create a plan
{:ok, plan} = ExPaymob.SubscriptionPlan.create(%{
  name: "Premium",
  amount: 5000,
  currency: "EGP",
  interval: "month"
})

# Manage plans
{:ok, _} = ExPaymob.SubscriptionPlan.suspend(plan["id"])
{:ok, _} = ExPaymob.SubscriptionPlan.resume(plan["id"])
{:ok, plans} = ExPaymob.SubscriptionPlan.list()

# Create a subscription
{:ok, sub} = ExPaymob.Subscription.create(%{plan_id: plan["id"]})
{:ok, _} = ExPaymob.Subscription.suspend(sub["id"])
{:ok, _} = ExPaymob.Subscription.resume(sub["id"])
```

### Error Handling

All API calls return `{:ok, map()}` or `{:error, %ExPaymob.Error{}}`:

```elixir
case ExPaymob.Intention.create(params) do
  {:ok, intention} ->
    intention["client_secret"]

  {:error, %ExPaymob.Error{source: :paymob, status: 422, message: message}} ->
    Logger.error("Validation error: #{message}")

  {:error, %ExPaymob.Error{source: :network, message: message}} ->
    Logger.error("Network error: #{message}")
end
```

Error sources: `:paymob` (API errors), `:network` (transport failures), `:internal` (decode errors).

## Webhook Verification

Paymob signs transaction callbacks with HMAC-SHA512. ExPaymob verifies these signatures using timing-safe comparison.

### Standalone Verification

```elixir
# Verify and parse in one step
{:ok, event} = ExPaymob.Webhook.verify_and_parse(payload, hmac_from_query)

# Or verify separately
:ok = ExPaymob.Webhook.verify_hmac(payload, hmac, hmac_secret: "secret")

# Subscription webhooks use a different format
:ok = ExPaymob.Webhook.verify_subscription_hmac(payload, hmac)
```

### Phoenix Webhook Endpoint

**Step 1:** Configure raw body caching in your endpoint (before `Plug.Parsers`):

```elixir
# lib/my_app_web/endpoint.ex
plug Plug.Parsers,
  parsers: [:json],
  pass: ["application/json"],
  json_decoder: Jason,
  body_reader: {ExPaymob.Plug.RawBodyReader, :read_body, []}
```

**Step 2:** Add the webhook route in your router:

```elixir
# lib/my_app_web/router.ex
scope "/webhooks" do
  pipe_through :api

  forward "/paymob", ExPaymob.Plug.WebhookPlug,
    handler: MyAppWeb.PaymobWebhookHandler
end
```

**Step 3:** Implement your handler:

```elixir
defmodule MyAppWeb.PaymobWebhookHandler do
  def handle_event(conn, event) do
    # event is the "obj" map from the webhook payload
    if event["success"] do
      # Payment succeeded - update your order, send confirmation, etc.
    else
      # Payment failed
    end

    # Return conn. The plug sends 200 automatically if you don't send a response.
    conn
  end
end
```

The plug handles HMAC verification automatically — returns 401 for invalid signatures, 400 for malformed requests.

## Custom HTTP Client

Replace the default Req adapter by implementing `ExPaymob.HttpClient`:

```elixir
defmodule MyApp.CustomHttpClient do
  @behaviour ExPaymob.HttpClient

  @impl true
  def request(method, url, headers, body, opts) do
    # Your HTTP implementation
    # Must return {:ok, status, resp_headers, resp_body} | {:error, reason}
  end
end
```

```elixir
# Global
config :ex_paymob, http_client: MyApp.CustomHttpClient

# Per-request
ExPaymob.Intention.create(params, http_client: MyApp.CustomHttpClient)
```

## Testing

For testing, use [Mox](https://hex.pm/packages/mox) with the `ExPaymob.HttpClient` behaviour:

```elixir
# test/test_helper.exs
Mox.defmock(ExPaymob.HttpClientMock, for: ExPaymob.HttpClient)
Application.put_env(:ex_paymob, :http_client, ExPaymob.HttpClientMock)

# test/my_test.exs
import Mox

test "handles successful payment" do
  expect(ExPaymob.HttpClientMock, :request, fn :post, _url, _headers, _body, _opts ->
    {:ok, 200, [], Jason.encode!(%{"id" => "123", "client_secret" => "cs_test"})}
  end)

  assert {:ok, %{"id" => "123"}} = ExPaymob.Intention.create(%{amount: 1000})
end
```

## License

MIT - see [LICENSE](LICENSE) for details.