lib/shippex.ex

defmodule Shippex do
  @moduledoc """
  Module documentation for `Shippex`.
  """

  alias Shippex.{Address, Carrier, Config, Rate, Service, Shipment, Transaction}

  @type response() :: %{code: String.t(), message: String.t()}

  @doc """
  Fetches rates for a given `shipment`. Possible options:

    * `carriers` - Fetches rates for *all* services for the given carriers
    * `services` - Fetches rates only for the given services

  These may be used in combination. To fetch rates for *all* UPS services, as
  well as USPS Priority, for example:

      Shippex.fetch_rates(shipment, carriers: :ups, services: [:usps_priority])

  If no options are provided, Shippex will fetch rates for every service from
  every available carrier.
  """
  @spec fetch_rates(Shipment.t(), Keyword.t()) :: [{atom, Rate.t()}]
  def fetch_rates(%Shipment{} = shipment, opts \\ []) do
    # Convert the atom to a list if necessary.
    carriers = Keyword.get(opts, :carriers)
    services = Keyword.get(opts, :services)

    carriers =
      if is_nil(carriers) and is_nil(services) do
        Shippex.carriers()
      else
        cond do
          is_nil(carriers) ->
            []

          is_atom(carriers) ->
            [carriers]

          is_list(carriers) ->
            carriers

          true ->
            raise """
            #{inspect(carriers)} is an invalid carrier or list of carriers.
            Try using an atom. For example:

                Shippex.fetch_rates(shipment, carriers: :usps)
            """
        end
      end

    services =
      case services do
        nil ->
          []

        service when is_atom(service) ->
          [service]

        services when is_list(services) ->
          services

        services ->
          raise """
          #{inspect(services)} is an invalid service or list of services.
          Try using an atom. For example:

              Shippex.fetch_rates(shipment, services: :usps_priority)
          """
      end
      |> Enum.reject(&(Service.get(&1).carrier in carriers))

    carrier_tasks =
      Enum.map(carriers, fn carrier ->
        Task.async(fn ->
          Carrier.module(carrier).fetch_rates(shipment)
        end)
      end)

    service_tasks =
      Enum.map(services, fn service ->
        Task.async(fn ->
          fetch_rate(shipment, service)
        end)
      end)

    rates =
      (carrier_tasks ++ service_tasks)
      |> Task.yield_many(5000)
      |> Enum.map(fn {task, rates} ->
        rates || Task.shutdown(task, :brutal_kill)
      end)
      |> Enum.filter(fn
        {:ok, _} -> true
        _ -> false
      end)
      |> Enum.map(fn {:ok, rates} -> rates end)
      |> List.flatten()
      |> Enum.reject(fn
        {atom, _} -> atom not in [:ok, :error]
        _ -> true
      end)

    oks = Enum.filter(rates, &(elem(&1, 0) == :ok))
    errors = Enum.filter(rates, &(elem(&1, 0) == :error))

    Enum.sort(oks, fn r1, r2 ->
      {:ok, r1} = r1
      {:ok, r2} = r2

      r1.price < r2.price
    end) ++ errors
  end

  @doc """
  Fetches the rate for `shipment` for a specific `Service`. The `service` module
  contains the `Carrier` and selected delivery speed. You can also pass in the
  ID of the service.

      Shippex.fetch_rate(shipment, service)
  """
  @spec fetch_rate(Shipment.t(), atom() | Service.t()) :: {atom, Rate.t()}
  def fetch_rate(%Shipment{} = shipment, service) when is_atom(service) do
    service = Service.get(service)
    fetch_rate(shipment, service)
  end

  def fetch_rate(%Shipment{} = shipment, %Service{carrier: carrier} = service) do
    case Carrier.module(carrier).fetch_rate(shipment, service) do
      [rate] -> rate
      {_, _} = rate -> rate
    end
  end

  @doc """
  Fetches the label for `shipment` for a specific `Service`. The `service`
  module contains the `Carrier` and selected delivery speed.

      Shippex.create_transaction(shipment, service)
  """
  @spec create_transaction(Shipment.t(), Service.t()) ::
          {:ok, Transaction.t()} | {:error, response}
  def create_transaction(%Shipment{} = shipment, %Service{carrier: carrier} = service) do
    Carrier.module(carrier).create_transaction(shipment, service)
  end

  @doc """
  Cancels the transaction associated with `label`, if possible. The result is
  returned in a tuple.

  You may pass in either the transaction, or if the full transaction struct
  isn't available, you may pass in the carrier, shipment, and tracking number
  instead.

      case Shippex.cancel_shipment(transaction) do
        {:ok, result} ->
          IO.inspect(result) #=> %{code: "1", message: "Voided successfully."}
        {:error, %{code: code, message: message}} ->
          IO.inspect(code)
          IO.inspect(message)
      end
  """
  @spec cancel_transaction(Transaction.t()) :: {atom, response}
  def cancel_transaction(%Transaction{} = transaction) do
    Carrier.module(transaction.carrier).cancel_transaction(transaction)
  end

  @spec cancel_transaction(Carrier.t(), Shipment.t(), String.t()) :: {atom, response}
  def cancel_transaction(carrier, %Shipment{} = shipment, tracking_number) do
    Carrier.module(carrier).cancel_transaction(shipment, tracking_number)
  end

  @doc """
  Returns `true` if the carrier services the given country. An
  ISO-3166-compliant country code is required.

      iex> Shippex.services_country?(:usps, "US")
      true

      iex> Shippex.services_country?(:usps, "KP")
      false
  """
  @spec services_country?(Carrier.t(), ISO.country_code()) :: boolean()
  def services_country?(carrier, country) do
    Carrier.module(carrier).services_country?(country)
  end

  @doc """
  Returns the status for the given tracking numbers.
  """
  @spec track_packages(Carrier.t(), [String.t()]) :: {atom(), response()}
  def track_packages(carrier, tracking_numbers) do
    Carrier.module(carrier).track_packages(tracking_numbers)
  end

  @doc """
  Performs address validation. If the address is completely invalid,
  `{:error, result}` is returned. For addresses that may have typos,
  `{:ok, candidates}` is returned. You can iterate through the list of
  candidates to present to the end user. Addresses that pass validation
  perfectly will still be in a `list` where `length(candidates) == 1`.

  Note that the `candidates` returned will automatically pass through
  `Shippex.Address.address()` for casting. Also, if `:usps` is used as the
  validation provider, the number of candidates will always be 1.

      address = Shippex.Address.address(%{
        name: "Earl G",
        phone: "123-123-1234",
        address: "9999 Hobby Lane",
        address_line_2: nil,
        city: "Austin",
        state: "TX",
        postal_code: "78703"
      })

      case Shippex.validate_address(address) do
        {:error, %{code: code, message: message}} ->
          # Present the error.
        {:ok, candidates} when length(candidates) == 1 ->
          # Use the address
        {:ok, candidates} when length(candidates) > 1 ->
          # Present candidates to user for selection
      end
  """
  @spec validate_address(Address.t(), Keyword.t()) :: {atom(), response() | [Address.t()]}
  defdelegate validate_address(address, opts \\ []), to: Address, as: :validate

  @doc false
  defdelegate carriers(), to: Config

  @doc false
  defdelegate currency_code(), to: Config

  @doc false
  defdelegate env(), to: Config
end