README.md

# EsimAccess

Elixir client for the [eSIM Access](https://esimaccess.com) wholesale eSIM reseller API.

Order, query, top up, suspend, and cancel eSIM profiles across 185+ countries.
Includes typed structs for all responses, a webhook handler with Plug integration
for Phoenix, and comprehensive test coverage.

## Installation

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

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

## Quick Start

```elixir
# 1. Create a config with your access code
config = EsimAccess.new(access_code: "your_access_code")

# 2. Check your balance
{:ok, %{balance: balance}} = EsimAccess.Balance.query(config)
# balance is value * 10,000 -- so 100_000 = $10.00

# 3. Browse available packages
{:ok, packages} = EsimAccess.Packages.list(config, %{location_code: "JP"})

# 4. Order an eSIM
{:ok, order} = EsimAccess.Orders.create(config, %{
  transaction_id: "txn_#{System.os_time(:millisecond)}",
  package_info_list: [
    %{package_code: "JP_1_7", count: 1}
  ]
})

# 5. Query the allocated profile (may take up to 30s)
{:ok, {profiles, _pager}} = EsimAccess.Profiles.query(config, %{
  order_no: order.order_no,
  pager: %{page_num: 1, page_size: 50}
})

profile = hd(profiles)
# profile.ac        -> LPA activation code
# profile.qr_code_url -> QR code image URL
# profile.iccid     -> eSIM ICCID
```

## Configuration

Create a config struct with `EsimAccess.new/1`. The struct is passed as the
first argument to every API call -- no global state, no application config.

```elixir
# Production
config = EsimAccess.new(access_code: "your_access_code")

# Custom base URL (for testing/proxying)
config = EsimAccess.new(
  access_code: "your_access_code",
  base_url: "https://your-proxy.example.com"
)
```

Get your access code from the
[eSIM Access developer console](https://console.esimaccess.com/developer/index).

## API Reference

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

### Packages

```elixir
# All packages
{:ok, packages} = EsimAccess.Packages.list(config)

# Filter by country (Alpha-2 ISO code)
{:ok, packages} = EsimAccess.Packages.list(config, %{location_code: "US"})

# Regional or global packages
{:ok, packages} = EsimAccess.Packages.list(config, %{location_code: "!RG"})
{:ok, packages} = EsimAccess.Packages.list(config, %{location_code: "!GL"})

# Day Pass plans only
{:ok, packages} = EsimAccess.Packages.list(config, %{data_type: "2"})

# Top-up packages for a specific ICCID
{:ok, packages} = EsimAccess.Packages.list(config, %{
  type: "TOPUP",
  iccid: "89852..."
})
```

### Orders

```elixir
# Single order
{:ok, order} = EsimAccess.Orders.create(config, %{
  transaction_id: "unique_txn_id",
  package_info_list: [%{package_code: "JP_1_7", count: 1}]
})

# With price verification (fails if price changed)
{:ok, order} = EsimAccess.Orders.create(config, %{
  transaction_id: "unique_txn_id",
  amount: 15000,
  package_info_list: [
    %{package_code: "JP_1_7", count: 1, price: 15000}
  ]
})

# Daily plan with specific number of days
{:ok, order} = EsimAccess.Orders.create(config, %{
  transaction_id: "unique_txn_id",
  package_info_list: [
    %{package_code: "SG_1_Daily", count: 1, period_num: 5}
  ]
})
```

### Profiles

```elixir
# Query by order number
{:ok, {profiles, pager}} = EsimAccess.Profiles.query(config, %{
  order_no: "B23051616050537",
  pager: %{page_num: 1, page_size: 50}
})

# Query by ICCID
{:ok, {profiles, pager}} = EsimAccess.Profiles.query(config, %{
  iccid: "89852246280001113119",
  pager: %{page_num: 1, page_size: 50}
})

# Cancel unused profile (refundable)
{:ok, _} = EsimAccess.Profiles.cancel(config, %{esim_tran_no: "23120118156818"})

# Suspend / unsuspend data service
{:ok, _} = EsimAccess.Profiles.suspend(config, %{esim_tran_no: "23120118156818"})
{:ok, _} = EsimAccess.Profiles.unsuspend(config, %{esim_tran_no: "23120118156818"})

# Revoke profile (non-refundable)
{:ok, _} = EsimAccess.Profiles.revoke(config, %{esim_tran_no: "23120118156818"})
```

### Top Up

```elixir
{:ok, result} = EsimAccess.TopUp.create(config, %{
  esim_tran_no: "23072017992029",
  package_code: "TOPUP_JC172",
  transaction_id: "topup_txn_001"
})
# result.expired_time  -> new expiry
# result.total_volume  -> new total data (bytes)
```

### Balance

```elixir
{:ok, %{balance: balance}} = EsimAccess.Balance.query(config)
# balance is value * 10,000 (100_000 = $10.00)
```

### Usage

```elixir
# Check usage for up to 10 eSIMs (updated every 2-3 hours)
{:ok, usages} = EsimAccess.Usage.check(config, ["25030303480009"])
# usages -> [%{esim_tran_no, data_usage, total_data, last_update_time}]
```

### SMS

```elixir
{:ok, _} = EsimAccess.Sms.send(config, %{
  esim_tran_no: "23072017992029",
  message: "Your verification code is 123456"
})
```

### Locations

```elixir
{:ok, locations} = EsimAccess.Locations.list(config)
# type 1 = single country, type 2 = multi-country region
```

### Webhooks

```elixir
# Set webhook URL
{:ok, _} = EsimAccess.Webhook.save(config, "https://your-app.com/webhooks/esim")

# Query current webhook
{:ok, %{"webhook" => url}} = EsimAccess.Webhook.query(config)
```

## Webhook Handling

The library provides typed event structs, a handler behaviour, and a Plug for
receiving eSIM Access webhook notifications in Phoenix.

### Six Event Types

| Event | Description |
|-------|-------------|
| `OrderStatus` | Order fulfilled -- profiles ready for download |
| `EsimStatus` | eSIM lifecycle changes (in use, expired, etc.) |
| `SmdpEvent` | Low-level SM-DP+ profile state transitions |
| `DataUsage` | Data consumption threshold alerts (50%, 90%) |
| `ValidityUsage` | Validity period expiry warnings (1 day left) |
| `CheckHealth` | Connectivity check on initial webhook setup |

### 1. Define a Handler

```elixir
defmodule MyApp.EsimWebhookHandler do
  @behaviour EsimAccess.Webhooks.Handler

  alias EsimAccess.Webhooks.Event

  @impl true
  def handle_event(%Event.OrderStatus{order_status: "GOT_RESOURCE"} = event) do
    # Profiles allocated -- fetch ICCID and QR code
    MyApp.Esim.fetch_profiles(event.order_no, event.transaction_id)
    :ok
  end

  @impl true
  def handle_event(%Event.DataUsage{remain_threshold: threshold} = event) do
    if threshold <= 0.1 do
      MyApp.Notifications.send_low_data_warning(event.transaction_id)
    end
    :ok
  end

  @impl true
  def handle_event(%Event.ValidityUsage{remain: 1} = event) do
    MyApp.Notifications.send_expiry_warning(event.transaction_id)
    :ok
  end

  @impl true
  def handle_event(_event), do: :ok
end
```

### 2. Add the Plug to Your Router

```elixir
# In your Phoenix router
forward "/webhooks/esim", EsimAccess.Webhooks.Plug,
  handler: MyApp.EsimWebhookHandler
```

Or with IP verification:

```elixir
forward "/webhooks/esim", EsimAccess.Webhooks.Plug,
  handler: MyApp.EsimWebhookHandler,
  verify_ip: true
```

### 3. Or Parse Events Manually in a Controller

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

  def handle(conn, params) do
    case EsimAccess.Webhooks.Event.parse(params) do
      {:ok, event} ->
        MyApp.EsimWebhookHandler.handle_event(event)
        send_resp(conn, 200, "ok")

      {:error, _reason} ->
        send_resp(conn, 400, "invalid event")
    end
  end
end
```

## Conventions

### Prices

All prices are expressed as **value * 10,000**. For example, `10000` = $1.00 USD.

### Data Volumes

All data volumes are in **bytes**. Common conversions:

| Value | Bytes |
|-------|-------|
| 100 MB | 104,857,600 |
| 1 GB | 1,073,741,824 |
| 5 GB | 5,368,709,120 |

### Identifiers

Most profile operations accept either `iccid` or `esim_tran_no`. Prefer
`esim_tran_no` because ICCIDs can be reused across profiles.

### Rate Limits

The API enforces a limit of **8 requests per second**. The client includes
automatic retry with backoff for transient errors.

## Error Handling

All API functions return `{:error, %EsimAccess.Error{}}` on failure:

```elixir
case EsimAccess.Orders.create(config, params) do
  {:ok, order} ->
    # success

  {:error, %EsimAccess.Error{error_code: "200007"}} ->
    # insufficient balance

  {:error, %EsimAccess.Error{error_code: code, error_message: msg}} ->
    Logger.error("eSIM API error [#{code}]: #{msg}")
end
```

See `EsimAccess.Error` module docs for the full error code reference.

## Testing

```bash
# Unit tests (no API key needed)
mix test

# Integration tests (requires access code)
ESIM_ACCESS_CODE=your_code mix test --include integration
```

## License

MIT