Skip to main content

lib/zambia_mobile_networks.ex

defmodule ZambiaMobileNetworks do
  @moduledoc """
  Identify Zambian mobile networks (Airtel, MTN, Zamtel, Zed Mobile) from a phone number.

  The operator prefix is the two digits after the leading `0` or `260` country code.

  | Network    | Local              | With country code         |
  |------------|--------------------|---------------------------|
  | Airtel     | 097, 077, 057      | 26097, 26077, 26057       |
  | MTN        | 096, 076, 056      | 26096, 26076, 26056       |
  | Zamtel     | 095, 075, 055      | 26095, 26075, 26055       |
  | Zed Mobile | 098, 078\\*, 058\\* | 26098, 26078\\*, 26058\\*  |

  \\* `078`/`058` are reserved for Zed Mobile but not yet officially enabled by the
  network provider; they are detected pre-emptively.
  """

  @prefixes %{
    airtel: ~w(97 77 57),
    mtn: ~w(96 76 56),
    zamtel: ~w(95 75 55),
    # 78/58 reserved but not yet officially enabled by Zed Mobile; detected pre-emptively.
    zed: ~w(98 78 58)
  }

  @type network :: :airtel | :mtn | :zamtel | :zed

  @doc "Returns the supported networks."
  @spec networks() :: [network()]
  def networks, do: Map.keys(@prefixes)

  @doc "Operator prefixes for a network."
  @spec prefixes(network()) :: [String.t()]
  def prefixes(network), do: Map.get(@prefixes, network, [])

  @doc "Human-readable label for a network."
  @spec label(network()) :: String.t()
  def label(:airtel), do: "Airtel"
  def label(:mtn), do: "MTN"
  def label(:zamtel), do: "Zamtel"
  def label(:zed), do: "Zed Mobile"

  @doc """
  Detects the network for an MSISDN, or `nil` if unrecognised.

  Accepts local (`0XY…`) and country-code (`260XY…`, `+260 XY…`) formats.

  ## Examples

      iex> ZambiaMobileNetworks.detect("0971234567")
      :airtel

      iex> ZambiaMobileNetworks.detect("260761234567")
      :mtn

      iex> ZambiaMobileNetworks.detect("+260 95 123 4567")
      :zamtel

      iex> ZambiaMobileNetworks.detect("0981234567")
      :zed

      iex> ZambiaMobileNetworks.detect("0911234567")
      nil
  """
  @spec detect(String.t()) :: network() | nil
  def detect(msisdn) when is_binary(msisdn) do
    prefix =
      msisdn
      |> String.replace(~r/\D/, "")
      |> strip_country_code()
      |> String.slice(0, 2)

    Enum.find_value(@prefixes, fn {network, prefixes} ->
      if prefix in prefixes, do: network
    end)
  end

  def detect(_), do: nil

  @doc """
  Whether the MSISDN belongs to the given network.

  ## Examples

      iex> ZambiaMobileNetworks.supports?("0961234567", :mtn)
      true

      iex> ZambiaMobileNetworks.supports?("0961234567", :airtel)
      false
  """
  @spec supports?(String.t(), network()) :: boolean()
  def supports?(msisdn, network), do: detect(msisdn) == network

  @doc """
  Returns the network logo as a base64 `data:` URI (200x200 PNG), or `nil` for an
  unknown network.

  Pass `custom` (a file path or raw image binary) to use your own logo instead of
  the bundled one. The custom image is returned encoded as-is — it is not resized.

      iex> "data:image/png;base64," <> _ = ZambiaMobileNetworks.logo(:mtn)
  """
  @spec logo(network(), Path.t() | binary() | nil) :: String.t() | nil
  def logo(network, custom \\ nil)

  def logo(_network, custom) when is_binary(custom) do
    bin = if File.regular?(custom), do: File.read!(custom), else: custom
    "data:image/png;base64," <> Base.encode64(bin)
  end

  def logo(network, nil) when network in [:airtel, :mtn, :zamtel, :zed] do
    path = Application.app_dir(:zambia_mobile_networks, "priv/logos/#{network}.png")
    "data:image/png;base64," <> Base.encode64(File.read!(path))
  end

  def logo(_network, nil), do: nil

  defp strip_country_code("260" <> rest), do: rest
  defp strip_country_code("0" <> rest), do: rest
  defp strip_country_code(digits), do: digits
end