## ## Copyright (c) 2020 Kwame Yamgane. All rights reserved. ## ## Redistribution and use in source and binary forms, with or ## without modification, are permitted provided that the following ## conditions are met: ## ## 1. Redistributions of source code must retain the above copyright ## notice, this list of conditions and the following disclaimer. ## ## 2. Redistributions in binary form must reproduce the above ## copyright notice, this list of conditions and the following ## disclaimer in the documentation and/or other materials ## provided with the distribution. ## ## 3. All advertising materials mentioning features or use of this ## software must display the following acknowledgement:
This ## product includes software developed by the organization. ## ## 4. Neither the name of the copyright holder nor the names of its ## contributors may be used to endorse or promote products derived ## from this software without specific prior written permission. ## ## THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDER "AS IS" AND ANY ## EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, ## THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A ## PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL COPYRIGHT ## HOLDER BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, ## EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED ## TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, ## OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY ## THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR ## TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT ## OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY ## OF SUCH DAMAGE. defmodule Billcom do @moduledoc """ Simple api library to connect to bill.com api Config your bill.com connection: ```elixir config :billcom, :api, %{ devKey: "T3WMJHVXXYZUSUKQE9CDPEKN", orgId: "Y8RGTBK2PXFGBUQMSCNM", password: "AJXR5CZJBPJD", userName: "sarah.connor@example.com" prod: true // optionnal } ``` Where `devKey`, `orgId`, `password`, `userName` are provided by Bill.com and are mandatory. `prod` let you choose between bill.com in sandbox mode or production. By default the api try to connect to sandbox. Note that most of the api return are with a form of a 2 values tuple: ``` {status, data} ``` Where status can be either `:ok` or `:error` dans val the corresponding data, and take a parameter a connection struture as a parameters, return of `Billcom.login!/0` ```Elixir conn = Billcom.login Billcom.list_orgs(conn) ``` """ @response_data "response_data" @response_status "response_status" @prod_url "https://api.bill.com/api/v2" @sandbox_url "https://api-sandbox.bill.com/api/v2" @doc """ Login to bill.com api ## Parameters: none - the module load a configuration from config file ## Configuration file: ```elixir config :billcom, :api, %{ devKey: "T3WMJHVXXYZUSUKQE9CDPEKN", orgId: "Y8RGTBK2PXFGBUQMSCNM", password: "AJXR5CZJBPJD", userName: "sarah.connor@example.com" } ``` ## Return: ### success: ```elixir {:ok, conn} ``` A bill.com connexiton structure ### failure: raise error """ @spec login! :: conn def login! do conn = get_conf() |> update_conn_url(:login) HTTPoison.start() case create_body(conn, :no_session) |> execute(conn) do {:ok, val} -> update_conn(val, conn, ["sessionId", "usersId"]) {:error, val} -> raise "Cannot login #{Map.fetch!(val, @response_data) |> Map.fetch!("error_message")}" end end @doc """ logout from Bill.com api ## Parameters: conn - see `Billcom.login!/0` ## Return: ### Success: ```elixir {:ok, val} ``` where data are logout data ### Failure: ```elixir {:error, val} ``` where date are failure reasons """ @spec logout(conn) :: {atom(), map()} def logout(connection) do conn = update_conn_url(connection, :logout) create_body(conn) |> execute(conn) end @doc """ Return the list of organisation associated with your account ## Parameters: conn - see `Billcom.login!/0` ## Return ### Success ```elixir {:ok, val} ``` from where you can fetch organisation list (example: val |> Map.fetch!("response_data")) ### Failure ```elixir {:error, val} ``` where date are failure reasons """ @spec list_orgs(conn()) :: {atom(), map()} def list_orgs(connection) do conn = update_conn_url(connection, :list_orgs) create_body(conn) |> execute(conn) end @doc """ Check for the presence of a key in the result collections ## Parameters: - result: collection of data from another api call - key: the key you are looking for ## return: - success: true - failure: false """ @spec has_key?(map(), String.t) :: atom() def has_key?(result, key) do result |> elem(1) |> Map.fetch!(@response_data) |> Map.has_key?(key) end @doc """ Return the value of a key in the result collections ## Parameters: - result: collection of data from another api call. - key: the key you are looking for ## return: - success: value - failure: unkown behavior see `has_key?/2` """ @spec get_val(map(), String.t) :: atom() def get_val(result, key) do result |> elem(1) |> Map.fetch!(@response_data) |> Map.fetch!(key) end @doc """ Return the colection data from the result collection ## Parameters: Result collection of a api call. It can be the tuple of the api call or a map which is usually the second element of the return of any api cal ## return: - success: value - failure: unkown behavior """ @spec get_val(map(), tuple()) :: atom() def get_data(result) when is_tuple(result) do result |> elem(1) |> Map.fetch!(@response_data) end def get_data(result) when is_map(result) do result |> Map.fetch!(@response_data) end @api_function_list_data [ "RecordAPPayment", "VoidAPPayment", "CancelAPPayment", "GetAPSummary", "GetDisbursementData", "ListPayments", "GetCheckImageData", "SetApprovers", "ListApprovers", "ListUserApprovals", "Approve", "Deny", "ClearApprovers", "SendInvoice", "MailInvoice", "ChargeCustomer", "RecordARPayment", "GetARSummary", "SetCustomerAuthorization", "GetProfilePermissions", "GetBankBalance", "SetBankBalance", "UploadAttachment", "SendMessage", "GetDocumentPages", "ListMessage", "NetworkSearch", "SendVendorInvite", "SendInvite", "LargeBillerSearch", "GetLargeBillerPaymentAddress", "ConnectLargeBillerAsVendor", "GetNetworkStatus", "CancelInvite", "DisconnectVendorFromNetwork", "DisconnectCustomerFromNetwork", "MFAStatus", "GetObjectUrl", "SearchEntity", "GetEntityMetadata", "MFAChallenge", "MFAAuthenticate" ] account_replacement = [ {"AP", "Ap"}, {"AR", "Ar"} ] replace_string = [ {"actg", "accounting"}, {"mfa", "multi factor authentication"}, {"ap ", " accounts payable "}, {"ar ", " accounts receivable "} ] slug_case = fn str, repl -> slug = Enum.reduce(account_replacement, str, fn str, res -> String.replace(res, elem(str, 0), elem(str, 1)) end) |> String.replace(~r/([A-Z][a-z]+)/, repl) |> String.downcase() cond do String.first(slug) == "_" -> String.slice(slug, 1..-1) true -> slug end end writen_case = fn str -> slug = slug_case.(str, " \\1") Enum.reduce(replace_string, slug, fn str, res -> String.replace(res, elem(str, 0), elem(str, 1)) end) |> String.trim_leading() |> String.capitalize() end for function <- @api_function_list_data do function_name = slug_case.(function, "_\\1") @doc """ #{writen_case.(function)} for bill.com api ## Parameters: - conn: a connection structure (see `Billcom.login!/0`) - data: data object to send for the object ## return: ### success: ```elixir {:ok, val} ``` ### fail: ```elixir {:error, val} ``` """ @spec unquote(:"#{function_name}")(map(), map()) :: any def unquote(:"#{function_name}")(connection, data) do conn = Billcom.update_map(connection, :conn_url, connection.api_url <> "#{unquote(function)}.json") Billcom.create_body(conn, data) |> Billcom.execute(conn) end end @type conn :: %{ dev_key: String.t, org_id: String.t, password: String.t, user_name: String.t, session_id: String.t, api_url: String.t, conn_url: String.t } defmodule Conn do defstruct dev_key: "", org_id: "", password: "", user_name: "", session_id: "", api_url: "" end is_struct? = fn atom -> atom == :__struct__ end for field <- Conn.__struct__() |> Map.keys(), not is_struct?.(field) do field_str = Atom.to_string(field) @doc """ get_#{field_str} get object value #{field_str} ## Parameters: Actual connexion structure (see `billcom.login!/0`) ## return: #{field_str} value """ @spec unquote(:"get_#{field_str}")(conn) :: String.t def unquote(:"get_#{field_str}")(conn) do Map.fetch!(conn, unquote(field)) end @doc """ set_#{field_str} set object "#{field_str}" value ## Parameters: - conn: actual connexion structure - val: value of the object ## return: new conn strucutre with #{field_str} set to val """ @spec unquote(:"set_#{field_str}")(conn, String.t) :: String.t def unquote(:"set_#{field_str}")(conn, val) do Map.put(conn, unquote(field), val) end end def execute(body, conn) do case HTTPoison.post(conn.conn_url, URI.encode_query(body), %{"Content-Type" => "application/x-www-form-urlencoded"}) do {:ok, answer} -> answer _ -> raise "Cannot execute request" end |> Map.fetch!(:body) |> Poison.decode!() |> check_answer() end def update_map(map, key, val) do Map.update(map, key, val, fn _ -> val end) end def create_body(conn, :no_session) do %{ devKey: conn.dev_key, orgId: conn.org_id, password: conn.password, userName: conn.user_name } end def create_body(conn, data) do create_body(conn) |> Map.put_new(:data, Poison.encode!(data)) end def create_body(conn) do create_body(conn, :no_session) |> Map.put_new(:sessionId, conn.session_id) end defp get_conf do configuration = Application.fetch_env!(:billcom, :api) api_url = cond do Map.has_key?(configuration, :prod) == true -> @prod_url true -> @sandbox_url end %Conn{ dev_key: configuration.devKey, org_id: configuration.orgId, password: configuration.password, user_name: configuration.userName, api_url: api_url } end @json_url [ {:login, "Login.json"}, {:logout, "Logout.json"}, {:list_orgs, "ListOrgs.json"}, {:approve, "Approve.json"}, {:pay_bill, "PayBill.json"}, {:record_ap_payment, "RecordAPPayment.json"}, {:upload_attachment, "UploadAttachment.json"} ] for {token, json} <- @json_url do defp update_conn_url(conn, unquote(token)), do: update_map(conn, :conn_url, conn.api_url <> "/" <> unquote(json)) end defp check_answer(answer) do cond do Map.fetch!(answer, @response_status) == 0 -> {:ok, answer} Map.fetch!(answer, @response_status) != 0 -> {:error, answer} end end defp key_to_atom(key) do key |> String.replace(~r/([A-Z]\w+)/, "_\\1") |> String.downcase() |> String.to_atom() end defp update_conn(_, conn, []) do conn end defp update_conn(data, conn, [val | last]) do value = Map.fetch!(data, @response_data) |> Map.fetch!(val) new_conn = update_map(conn, key_to_atom(val), value) update_conn(data, new_conn, last) end defp update_conn(data, conn, val) do Map.update(conn, key_to_atom(val), Map.fetch(data, val), fn _ -> Map.fetch(data, val) end) end end