defmodule Shippex do @moduledoc """ ## Configuration config :shippex, env: :dev, distance_unit: :in, # either :in or :cm weight_unit: :lbs, # either :lbs or :kg currency: :usd, # :usd, :can, :mxn, :eur carriers: [ ups: [ username: "MyUsername", password: "MyPassword", secret_key: "123123", shipper: %{ account_number: "AB1234", name: "My Company", phone: "123-456-7890", address: "1234 Foo St", city: "Foo", state: "TX", zip: "78999" } ], usps: [ username: "MyUsername", password: "MyPassword" ] ] ## Create origin/destination addresses origin = Shippex.Address.address(%{ name: "Earl G", phone: "123-123-1234", address: "9999 Hobby Lane", address_line_2: nil, city: "Austin", state: "TX", zip: "78703" }) destination = Shippex.Address.address(%{ name: "Bar Baz", phone: "123-123-1234", address: "1234 Foo Blvd", address_line_2: nil, city: "Plano", state: "TX", zip: "75074" }) ## Create a package # Currently only inches and pounds (lbs) supported. package = %Shippex.Package{ length: 8, width: 8, height: 4, weight: 5, description: "Headphones" } ## Link the origin, destination, and package with a Shipment shipment = %Shippex.Shipment{ from: origin, to: destination, package: package } ## Fetch rates to present to the user. rates = Shippex.fetch_rates(shipment) ## Accept one of the services and print the label {:ok, rate} = Enum.shuffle(rates) |> hd {:ok, label} = Shippex.fetch_label(rate, shipment) ## Write the label gif to disk File.write!("\#{label.tracking_number}.gif", Base.decode64!(label.image)) """ alias Shippex.{Carrier, Service, Shipment, Transaction} @type response :: %{code: String.t(), message: String.t()} defmodule InvalidConfigError do defexception [:message] def exception(message) do %InvalidConfigError{message: message} end end @doc false @spec config() :: Keyword.t | none() def config() do case Application.get_env(:shippex, :carriers, :not_found) do :not_found -> raise InvalidConfigError, "Shippex config not found" config -> if not Keyword.keyword?(config) do raise InvalidConfigError, "Shippex config was found, but doesn't contain a keyword list." end config end end @doc """ Provides a method of returning all available carriers. This is based on the config and does not include validation. Shippex.carriers #=> [:ups] """ @spec carriers() :: [Carrier.t()] def carriers() do cfg = Shippex.config() ups = if Keyword.get(cfg, :ups), do: :ups fedex = if Keyword.get(cfg, :fedex), do: :fedex usps = if Keyword.get(cfg, :usps), do: :usps Enum.reject([ups, fedex, usps], &is_nil/1) end @doc false @spec currency_code() :: String.t() | none() def currency_code() do case Application.get_env(:shippex, :currency, :usd) do code when code in [:usd, :can, :mxn] -> code |> Atom.to_string() |> String.upcase() _ -> raise InvalidConfigError, "Shippex currency must be either :usd, :can, or :mxn" end end @doc false @spec env() :: :dev | :prod | none() def env() do case Application.get_env(:shippex, :env, :dev) do e when e in [:dev, :prod] -> e _ -> raise InvalidConfigError, "Shippex env must be either :dev or :prod" end end @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()) :: {atom, Transaction.t()} 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 """ 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", zip: "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()]} def validate_address(%Shippex.Address{} = address, opts \\ []) do carrier = Keyword.get(opts, :carrier, :usps) case address.country do "US" -> Carrier.module(carrier).validate_address(address) country -> case Shippex.Util.states(country)[address.state] do nil -> {:error, %{code: "0", description: "State does not belong to country."}} _ -> {:ok, [address]} end end end end