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, _} -> not (atom 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 """ 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