lib/bexio_api_client/sales_order_management.ex

defmodule BexioApiClient.SalesOrderManagement do
  @moduledoc """
  Bexio API for the sales order management part of the API.
  """

  import BexioApiClient.Helpers

  alias BexioApiClient.GlobalArguments
  alias BexioApiClient.SearchCriteria

  alias BexioApiClient.SalesOrderManagement.{
    Comment,
    Order,
    Quote,
    Invoice,
    DocumentSetting,
    Delivery,
    PositionSubposition,
    PositionPagebreak,
    PositionDiscount,
    PositionItem,
    PositionDefault,
    PositionText,
    PositionSubtotal
  }

  import BexioApiClient.GlobalArguments, only: [opts_to_query: 1]

  @type api_error_type :: BexioApiClient.Helpers.api_error_type()

  # Quotes

  @doc """
  This action fetches a list of all quotes.
  """
  @spec fetch_quotes(
          req :: Req.Request.t(),
          opts :: [GlobalArguments.offset_arg()]
        ) ::
          {:ok, [Quote.t()]} | api_error_type
  def fetch_quotes(req, opts \\ []) do
    bexio_body_handling(
      fn ->
        Req.get(req, url: "/2.0/kb_offer", params: opts_to_query(opts))
      end,
      &map_from_quotes/2
    )
  end

  @doc """
  Search quotes via query.
  The following search fields are supported:

  * id
  * kb_item_status
  * document_nr
  * title
  * contact_id
  * contact_sub_id
  * user_id
  * currency_id
  * total_gross
  * total_net
  * total
  * is_valid_from
  * is_valid_until
  * is_valid_to (?)
  * updated_at
  """
  @spec search_quotes(
          req :: Req.Request.t(),
          criteria :: list(SearchCriteria.t()),
          opts :: [GlobalArguments.offset_arg()]
        ) ::
          {:ok, [Quote.t()]} | api_error_type

  def search_quotes(
        req,
        criteria,
        opts \\ []
      ) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_offer/search", json: criteria, params: opts_to_query(opts))
      end,
      &map_from_quotes/2
    )
  end

  @doc """
  This action fetches a single quote
  """
  @spec fetch_quote(
          req :: Req.Request.t(),
          quote_id :: pos_integer()
        ) ::
          {:ok, Quote.t()} | api_error_type

  def fetch_quote(req, quote_id) do
    bexio_body_handling(
      fn ->
        Req.get(req, url: "/2.0/kb_offer/#{quote_id}")
      end,
      &map_from_quote/2
    )
  end

  @doc """
  Create a quote (id in order will be ignored). Be also aware: whether you need or must not send a document number depends on the settings in Bexio. It cannot be controlled by the API
  and as such will just send if it exists.
  """
  @spec create_quote(
          req :: Req.Request.t(),
          offer :: Quote.t()
        ) ::
          {:ok, Quote.t()} | api_error_type
  def create_quote(req, offer) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_offer", json: remap_quote(offer))
      end,
      &map_from_quote/2
    )
  end

  @doc """
  Edit a quote.
  """
  @spec edit_quote(
          req :: Req.Request.t(),
          offer :: Quote.t()
        ) :: {:ok, Quote.t()} | api_error_type
  def edit_quote(req, offer) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_offer/#{offer.id}", json: remap_edit_quote(offer))
      end,
      &map_from_quote/2
    )
  end

  @doc """
  Delete a quote.
  """
  @spec delete_quote(
          req :: Req.Request.t(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def delete_quote(req, id) do
    bexio_body_handling(
      fn ->
        Req.delete(req, url: "/2.0/kb_offer/#{id}")
      end,
      &success_response/2
    )
  end

  @doc """
  Issue a quote.
  """
  @spec issue_quote(
          req :: Req.Request.t(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def issue_quote(req, id) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_offer/#{id}/issue")
      end,
      &success_response/2
    )
  end

  @doc """
  Revert Issue a quote.
  """
  @spec revert_issue_quote(
          req :: Req.Request.t(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def revert_issue_quote(req, id) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_offer/#{id}/revertIssue")
      end,
      &success_response/2
    )
  end

  @doc """
  Accept a quote.
  """
  @spec accept_quote(
          req :: Req.Request.t(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def accept_quote(req, id) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_offer/#{id}/accept")
      end,
      &success_response/2
    )
  end

  @doc """
  Decline a quote.
  """
  @spec decline_quote(
          req :: Req.Request.t(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def decline_quote(req, id) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_offer/#{id}/reject")
      end,
      &success_response/2
    )
  end

  @doc """
  Reissue a quote.
  """
  @spec reissue_quote(
          req :: Req.Request.t(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def reissue_quote(req, id) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_offer/#{id}/reissue")
      end,
      &success_response/2
    )
  end

  @doc """
  Mark a quote as sent.
  """
  @spec mark_quote_as_sent(
          req :: Req.Request.t(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | {:error, any()}
  def mark_quote_as_sent(req, id) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_offer/#{id}/mark_as_sent")
      end,
      &success_response/2
    )
  end

  @doc """
  This action returns a pdf document of the quote
  """
  @spec quote_pdf(
          req :: Req.Request.t(),
          quote_id :: pos_integer()
        ) :: {:ok, map()} | api_error_type
  def quote_pdf(req, quote_id) do
    bexio_body_handling(
      fn ->
        Req.get(req, url: "/2.0/kb_offer/#{quote_id}/pdf")
      end,
      &map_from_pdf/2
    )
  end

  defp remap_quote(
         %Quote{
           positions: positions
         } = offer
       ) do
    offer
    |> remap_edit_quote()
    |> Map.put(:positions, Enum.map(positions, &map_to_post_position/1))
  end

  defp map_from_pdf(%{"name" => name, "size" => size, "mime" => mime, "content" => content}, _env) do
    %{
      name: name,
      size: size,
      mime: mime,
      content: content
    }
  end

  defp remap_edit_quote(%Quote{
         title: title,
         document_nr: document_nr,
         contact_id: contact_id,
         contact_sub_id: contact_sub_id,
         user_id: user_id,
         project_id: project_id,
         language_id: language_id,
         bank_account_id: bank_account_id,
         currency_id: curency_id,
         payment_type_id: payment_type_id,
         header: header,
         footer: footer,
         mwst_type: mwst_type,
         mwst_is_net?: mwst_is_net?,
         show_position_taxes?: show_position_taxes?,
         is_valid_from: is_valid_from,
         is_valid_until: is_valid_until,
         delivery_address_type: delivery_address_type,
         api_reference: api_reference,
         viewed_by_client_at: viewed_by_client_at,
         kb_terms_of_payment_template_id: kb_terms_of_payment_template_id,
         template_slug: template_slug
       }) do
    %{
      title: title,
      document_nr: document_nr,
      contact_id: contact_id,
      contact_sub_id: contact_sub_id,
      user_id: user_id,
      pr_project_id: project_id,
      language_id: language_id,
      bank_account_id: bank_account_id,
      currency_id: curency_id,
      payment_type_id: payment_type_id,
      header: header,
      footer: footer,
      mwst_type: mwst_type_id(mwst_type),
      mwst_is_net: mwst_is_net?,
      show_position_taxes: show_position_taxes?,
      is_valid_from: to_iso8601(is_valid_from),
      is_valid_until: to_iso8601(is_valid_until),
      delivery_address_type: delivery_address_type,
      api_reference: api_reference,
      viewed_by_client_at: to_naive_string(viewed_by_client_at),
      kb_terms_of_payment_template_id: kb_terms_of_payment_template_id,
      template_slug: template_slug
    }
    |> remove_document_no_if_nil()
  end

  defp map_to_post_position(%PositionDefault{
         amount: amount,
         unit_id: unit_id,
         account_id: account_id,
         tax_id: tax_id,
         text: text,
         unit_price: unit_price,
         discount_in_percent: discount_in_percent
       }),
       do: %{
         type: "KbPositionCustom",
         amount: Decimal.to_string(amount, :normal),
         unit_id: unit_id,
         account_id: account_id,
         tax_id: tax_id,
         unit_price: Decimal.to_string(unit_price, :normal),
         discount_in_percent: Decimal.to_string(discount_in_percent, :normal),
         text: text
       }

  defp map_to_post_position(%PositionItem{
         amount: amount,
         unit_id: unit_id,
         account_id: account_id,
         tax_id: tax_id,
         text: text,
         unit_price: unit_price,
         discount_in_percent: discount_in_percent,
         article_id: article_id
       }),
       do: %{
         type: "KbPositionArticle",
         amount: Decimal.to_string(amount, :normal),
         unit_id: unit_id,
         account_id: account_id,
         tax_id: tax_id,
         unit_price: Decimal.to_string(unit_price, :normal),
         discount_in_percent: Decimal.to_string(discount_in_percent, :normal),
         text: text,
         article_id: article_id
       }

  defp map_to_post_position(%PositionText{
         text: text,
         show_pos_nr?: show_pos_nr?
       }),
       do: %{
         type: "KbPositionText",
         text: text,
         show_pos_nr: show_pos_nr?
       }

  defp map_to_post_position(%PositionSubtotal{text: text}),
    do: %{
      type: "KbPositionSubtotal",
      text: text
    }

  defp map_to_post_position(%PositionPagebreak{}),
    do: %{
      type: "KbPositionPagebreak",
      pagebreak: true
    }

  defp map_to_post_position(%PositionDiscount{
         text: text,
         percentual?: percentual?,
         value: value
       }),
       do: %{
         type: "KbPositionDiscount",
         is_percentual: percentual?,
         text: text,
         value: Decimal.to_string(value, :normal)
       }

  defp map_from_quotes(quotes, _env), do: Enum.map(quotes, &map_from_quote/1)

  defp map_from_quote(
         %{
           "id" => id,
           "document_nr" => document_nr,
           "title" => title,
           "contact_id" => contact_id,
           "contact_sub_id" => contact_sub_id,
           "user_id" => user_id,
           "project_id" => project_id,
           "language_id" => language_id,
           "bank_account_id" => bank_account_id,
           "currency_id" => currency_id,
           "payment_type_id" => payment_type_id,
           "header" => header,
           "footer" => footer,
           "total_gross" => total_gross,
           "total_net" => total_net,
           "total_taxes" => total_taxes,
           "total" => total,
           "total_rounding_difference" => total_rounding_difference,
           "mwst_type" => mwst_type_id,
           "mwst_is_net" => mwst_is_net?,
           "show_position_taxes" => show_position_taxes?,
           "is_valid_from" => is_valid_from,
           "is_valid_until" => is_valid_until,
           "contact_address" => contact_address,
           "delivery_address_type" => delivery_address_type,
           "delivery_address" => delivery_address,
           "kb_item_status_id" => kb_item_status_id,
           "api_reference" => api_reference,
           "viewed_by_client_at" => viewed_by_client_at,
           "kb_terms_of_payment_template_id" => kb_terms_of_payment_template_id,
           "show_total" => show_total?,
           "updated_at" => updated_at,
           "template_slug" => template_slug,
           "taxs" => taxs,
           "network_link" => network_link
         } = map,
         _env \\ nil
       ) do
    %Quote{
      id: id,
      document_nr: document_nr,
      title: title,
      contact_id: contact_id,
      contact_sub_id: contact_sub_id,
      user_id: user_id,
      project_id: project_id,
      language_id: language_id,
      bank_account_id: bank_account_id,
      currency_id: currency_id,
      payment_type_id: payment_type_id,
      header: header,
      footer: footer,
      total_gross: to_decimal(total_gross),
      total_net: to_decimal(total_net),
      total_taxes: to_decimal(total_taxes),
      total: to_decimal(total),
      total_rounding_difference: total_rounding_difference,
      mwst_type: mwst_type(mwst_type_id),
      mwst_is_net?: mwst_is_net?,
      show_position_taxes?: show_position_taxes?,
      is_valid_from: to_date(is_valid_from),
      is_valid_until: to_date(is_valid_until),
      contact_address: contact_address,
      delivery_address_type: delivery_address_type,
      delivery_address: delivery_address,
      kb_item_status: kb_item_status(kb_item_status_id),
      api_reference: api_reference,
      viewed_by_client_at: to_datetime(viewed_by_client_at),
      kb_terms_of_payment_template_id: kb_terms_of_payment_template_id,
      show_total?: show_total?,
      updated_at: to_datetime(updated_at),
      template_slug: template_slug,
      taxs: Enum.map(taxs, &to_tax/1),
      network_link: network_link
    }
    |> map_quote_positions(Map.get(map, "positions"))
  end

  defp map_quote_positions(q, nil), do: q
  defp map_quote_positions(q, positions), do: %{q | positions: remap_positions(positions)}

  defp mwst_type(0), do: :including
  defp mwst_type(1), do: :excluding
  defp mwst_type(2), do: :exempt

  defp mwst_type_id(:including), do: 0
  defp mwst_type_id(:excluding), do: 1
  defp mwst_type_id(:exempt), do: 2

  defp kb_item_status(1), do: :draft
  defp kb_item_status(2), do: :pending
  defp kb_item_status(3), do: :confirmed
  defp kb_item_status(4), do: :declined

  defp to_tax(%{"percentage" => percentage, "value" => value}),
    do: %{percentage: percentage, value: value}

  # Orders

  @doc """
  This action fetches a list of all orders.
  """
  @spec fetch_orders(
          req :: Req.Request.t(),
          opts :: [GlobalArguments.offset_arg()]
        ) :: {:ok, [Order.t()]} | api_error_type
  def fetch_orders(req, opts \\ []) do
    bexio_body_handling(
      fn ->
        Req.get(req, url: "/2.0/kb_order", params: opts_to_query(opts))
      end,
      &map_from_orders/2
    )
  end

  @doc """
  Search orders via query.
  The following search fields are supported:

  * id
  * kb_item_status
  * document_nr
  * title
  * contact_id
  * contact_sub_id
  * user_id
  * currency_id
  * total_gross
  * total_net
  * total
  * is_valid_from
  * updated_at
  """
  @spec search_orders(
          req :: Req.Request.t(),
          criteria :: list(SearchCriteria.t()),
          opts :: [GlobalArguments.offset_arg()]
        ) :: {:ok, [Order.t()]} | api_error_type
  def search_orders(
        req,
        criteria,
        opts \\ []
      ) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_order/search", json: criteria, params: opts_to_query(opts))
      end,
      &map_from_orders/2
    )
  end

  @doc """
  This action fetches a single order
  """
  @spec fetch_order(
          req :: Req.Request.t(),
          order_id :: pos_integer()
        ) :: {:ok, Order.t()} | api_error_type
  def fetch_order(req, order_id) do
    bexio_body_handling(
      fn ->
        Req.get(req, url: "/2.0/kb_order/#{order_id}")
      end,
      &map_from_order/2
    )
  end

  @doc """
  Create an order (id in order will be ignored). Be also aware: whether you need or must not send a document number depends on the settings in Bexio. It cannot be controlled by the API
  and as such will just send if it exists.
  """
  @spec create_order(
          req :: Req.Request.t(),
          order :: Order.t()
        ) :: {:ok, Order.t()} | api_error_type
  def create_order(req, order) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_order", json: remap_order(order))
      end,
      &map_from_order/2
    )
  end

  @doc """
  Edit an order.
  """
  @spec edit_order(
          req :: Req.Request.t(),
          order :: Order.t()
        ) :: {:ok, Order.t()} | api_error_type
  def edit_order(req, order) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_order/#{order.id}", json: remap_edit_order(order))
      end,
      &map_from_order/2
    )
  end

  @doc """
  Delete an order.
  """
  @spec delete_order(
          req :: Req.Request.t(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def delete_order(req, id) do
    bexio_body_handling(
      fn ->
        Req.delete(req, url: "/2.0/kb_order/#{id}")
      end,
      &success_response/2
    )
  end

  @doc """
  This action returns a pdf document of the order
  """
  @spec order_pdf(
          req :: Req.Request.t(),
          order_id :: pos_integer()
        ) :: {:ok, map()} | api_error_type
  def order_pdf(req, order_id) do
    bexio_body_handling(
      fn ->
        Req.get(req, url: "/2.0/kb_order/#{order_id}/pdf")
      end,
      &map_from_pdf/2
    )
  end

  defp remap_order(
         %Order{
           positions: positions
         } = order
       ) do
    order
    |> remap_edit_order()
    |> Map.put(:positions, Enum.map(positions, &map_to_post_position/1))
  end

  defp remap_edit_order(%Order{
         title: title,
         document_nr: document_nr,
         contact_id: contact_id,
         contact_sub_id: contact_sub_id,
         user_id: user_id,
         project_id: project_id,
         language_id: language_id,
         bank_account_id: bank_account_id,
         currency_id: curency_id,
         payment_type_id: payment_type_id,
         header: header,
         footer: footer,
         mwst_type: mwst_type,
         mwst_is_net?: mwst_is_net?,
         show_position_taxes?: show_position_taxes?,
         is_valid_from: is_valid_from,
         delivery_address_type: delivery_address_type,
         api_reference: api_reference,
         template_slug: template_slug
       }) do
    %{
      title: title,
      document_nr: document_nr,
      contact_id: contact_id,
      contact_sub_id: contact_sub_id,
      user_id: user_id,
      pr_project_id: project_id,
      language_id: language_id,
      bank_account_id: bank_account_id,
      currency_id: curency_id,
      payment_type_id: payment_type_id,
      header: header,
      footer: footer,
      mwst_type: mwst_type_id(mwst_type),
      mwst_is_net: mwst_is_net?,
      show_position_taxes: show_position_taxes?,
      is_valid_from: Date.to_iso8601(is_valid_from),
      delivery_address_type: delivery_address_type,
      api_reference: api_reference,
      template_slug: template_slug
    }
    |> remove_document_no_if_nil()
  end

  defp map_from_orders(orders, _env), do: Enum.map(orders, &map_from_order/1)

  defp map_from_order(
         %{
           "id" => id,
           "document_nr" => document_nr,
           "title" => title,
           "contact_id" => contact_id,
           "contact_sub_id" => contact_sub_id,
           "user_id" => user_id,
           "project_id" => project_id,
           "language_id" => language_id,
           "bank_account_id" => bank_account_id,
           "currency_id" => currency_id,
           "payment_type_id" => payment_type_id,
           "header" => header,
           "footer" => footer,
           "total_gross" => total_gross,
           "total_net" => total_net,
           "total_taxes" => total_taxes,
           "total" => total,
           "total_rounding_difference" => total_rounding_difference,
           "mwst_type" => mwst_type_id,
           "mwst_is_net" => mwst_is_net?,
           "show_position_taxes" => show_position_taxes?,
           "is_valid_from" => is_valid_from,
           "contact_address" => contact_address,
           "delivery_address_type" => delivery_address_type,
           "delivery_address" => delivery_address,
           "kb_item_status_id" => kb_item_status_id,
           "is_recurring" => is_recurring?,
           "api_reference" => api_reference,
           "viewed_by_client_at" => viewed_by_client_at,
           "updated_at" => updated_at,
           "template_slug" => template_slug,
           "taxs" => taxs,
           "network_link" => network_link
         } = map,
         _env \\ nil
       ) do
    %Order{
      id: id,
      document_nr: document_nr,
      title: title,
      contact_id: contact_id,
      contact_sub_id: contact_sub_id,
      user_id: user_id,
      project_id: project_id,
      language_id: language_id,
      bank_account_id: bank_account_id,
      currency_id: currency_id,
      payment_type_id: payment_type_id,
      header: header,
      footer: footer,
      total_gross: to_decimal(total_gross),
      total_net: to_decimal(total_net),
      total_taxes: to_decimal(total_taxes),
      total: to_decimal(total),
      total_rounding_difference: total_rounding_difference,
      mwst_type: mwst_type(mwst_type_id),
      mwst_is_net?: mwst_is_net?,
      show_position_taxes?: show_position_taxes?,
      is_valid_from: to_date(is_valid_from),
      contact_address: contact_address,
      delivery_address_type: delivery_address_type,
      delivery_address: delivery_address,
      kb_item_status: order_kb_item_status(kb_item_status_id),
      api_reference: api_reference,
      viewed_by_client_at: to_datetime(viewed_by_client_at),
      is_recurring?: is_recurring?,
      updated_at: to_datetime(updated_at),
      template_slug: template_slug,
      taxs: Enum.map(taxs, &to_tax/1),
      network_link: network_link
    }
    |> map_order_positions(Map.get(map, "positions"))
  end

  defp map_order_positions(q, nil), do: q
  defp map_order_positions(q, positions), do: %{q | positions: remap_positions(positions)}

  defp order_kb_item_status(5), do: :pending
  defp order_kb_item_status(6), do: :done
  defp order_kb_item_status(15), do: :partial
  defp order_kb_item_status(21), do: :cancelled

  # Deliveries

  @doc """
  This action fetches a list of all deliveries.
  """
  @spec fetch_deliveries(
          req :: Req.Request.t(),
          opts :: [GlobalArguments.offset_arg()]
        ) :: {:ok, [Delivery.t()]} | api_error_type
  def fetch_deliveries(req, opts \\ []) do
    bexio_body_handling(
      fn ->
        Req.get(req, url: "/2.0/kb_delivery", params: opts_to_query(opts))
      end,
      &map_from_deliveries/2
    )
  end

  @doc """
  This action fetches a single delivery
  """
  @spec fetch_delivery(
          req :: Req.Request.t(),
          delivery_id :: pos_integer()
        ) :: {:ok, Delivery.t()} | api_error_type
  def fetch_delivery(req, delivery_id) do
    bexio_body_handling(
      fn ->
        Req.get(req, url: "/2.0/kb_delivery/#{delivery_id}")
      end,
      &map_from_delivery/2
    )
  end

  @doc """
  Issues a delivery (only possible if it's in draft status!). The result whether the issue was successful or not
  """
  @spec issue_delivery(
          req :: Req.Request.t(),
          delivery_id :: integer()
        ) :: {:ok, boolean()} | api_error_type
  def issue_delivery(req, delivery_id) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_delivery/#{delivery_id}/issue")
      end,
      &success_response/2
    )
  end

  defp map_from_deliveries(deliveries, _env), do: Enum.map(deliveries, &map_from_delivery/1)

  defp map_from_delivery(
         %{
           "id" => id,
           "document_nr" => document_nr,
           "title" => title,
           "contact_id" => contact_id,
           "contact_sub_id" => contact_sub_id,
           "user_id" => user_id,
           "language_id" => language_id,
           "bank_account_id" => bank_account_id,
           "currency_id" => currency_id,
           "header" => header,
           "footer" => footer,
           "total_gross" => total_gross,
           "total_net" => total_net,
           "total_taxes" => total_taxes,
           "total" => total,
           "total_rounding_difference" => total_rounding_difference,
           "mwst_type" => mwst_type_id,
           "mwst_is_net" => mwst_is_net?,
           "is_valid_from" => is_valid_from,
           "contact_address" => contact_address,
           "delivery_address_type" => delivery_address_type,
           "delivery_address" => delivery_address,
           "kb_item_status_id" => kb_item_status_id,
           "api_reference" => api_reference,
           "updated_at" => updated_at,
           "taxs" => taxs
         } = map,
         _env \\ nil
       ) do
    %Delivery{
      id: id,
      document_nr: document_nr,
      title: title,
      contact_id: contact_id,
      contact_sub_id: contact_sub_id,
      user_id: user_id,
      language_id: language_id,
      bank_account_id: bank_account_id,
      currency_id: currency_id,
      header: header,
      footer: footer,
      total_gross: to_decimal(total_gross),
      total_net: to_decimal(total_net),
      total_taxes: to_decimal(total_taxes),
      total: to_decimal(total),
      total_rounding_difference: total_rounding_difference,
      mwst_type: mwst_type(mwst_type_id),
      mwst_is_net?: mwst_is_net?,
      is_valid_from: to_date(is_valid_from),
      contact_address: contact_address,
      delivery_address_type: delivery_address_type,
      delivery_address: delivery_address,
      kb_item_status: delivery_kb_item_status(kb_item_status_id),
      api_reference: api_reference,
      updated_at: to_datetime(updated_at),
      taxs: Enum.map(taxs, &to_tax/1)
    }
    |> map_delivery_positions(Map.get(map, "positions"))
  end

  defp map_delivery_positions(q, nil), do: q
  defp map_delivery_positions(q, positions), do: %{q | positions: remap_positions(positions)}

  defp delivery_kb_item_status(10), do: :draft
  defp delivery_kb_item_status(18), do: :done
  defp delivery_kb_item_status(20), do: :cancelled

  # Invoices

  @doc """
  This action fetches a list of all invoices.
  """
  @spec fetch_invoices(
          req :: Req.Request.t(),
          opts :: [GlobalArguments.offset_arg()]
        ) :: {:ok, [Invoice.t()]} | api_error_type
  def fetch_invoices(req, opts \\ []) do
    bexio_body_handling(
      fn ->
        Req.get(req, url: "/2.0/kb_invoice", params: opts_to_query(opts))
      end,
      &map_from_invoices/2
    )
  end

  @doc """
  Search invoices via query.
  The following search fields are supported:

  * id
  * kb_item_status
  * document_nr
  * title
  * contact_id
  * contact_sub_id
  * user_id
  * currency_id
  * total_gross
  * total_net
  * total
  * is_valid_from
  * is_valid_to
  * updated_at
  """
  @spec search_invoices(
          req :: Req.Request.t(),
          criteria :: list(SearchCriteria.t()),
          opts :: [GlobalArguments.offset_arg()]
        ) :: {:ok, [Invoice.t()]} | api_error_type
  def search_invoices(
        req,
        criteria,
        opts \\ []
      ) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_invoice/search", json: criteria, params: opts_to_query(opts))
      end,
      &map_from_invoices/2
    )
  end

  @doc """
  This action fetches a single invoice
  """
  @spec fetch_invoice(
          req :: Req.Request.t(),
          invoice_id :: pos_integer()
        ) :: {:ok, Quote.t()} | api_error_type
  def fetch_invoice(req, invoice_id) do
    bexio_body_handling(
      fn ->
        Req.get(req, url: "/2.0/kb_invoice/#{invoice_id}")
      end,
      &map_from_invoice/2
    )
  end

  @doc """
  Create an invoice (id in order will be ignored). Be also aware: whether you need or must not send a document number depends on the settings in Bexio. It cannot be controlled by the API
  and as such will just send if it exists.
  """
  @spec create_invoice(
          req :: Req.Request.t(),
          invoice :: Invoice.t()
        ) :: {:ok, Invoice.t()} | api_error_type
  def create_invoice(req, invoice) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_invoice", json: remap_invoice(invoice))
      end,
      &map_from_invoice/2
    )
  end

  @doc """
  Edit an invoice.
  """
  @spec edit_invoice(
          req :: Req.Request.t(),
          invoice :: Invoice.t()
        ) :: {:ok, Invoice.t()} | api_error_type
  def edit_invoice(req, invoice) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_invoice/#{invoice.id}", json: remap_edit_invoice(invoice))
      end,
      &map_from_invoice/2
    )
  end

  @doc """
  Delete an invoice.
  """
  @spec delete_invoice(
          req :: Req.Request.t(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def delete_invoice(req, id) do
    bexio_body_handling(
      fn ->
        Req.delete(req, url: "/2.0/kb_invoice/#{id}")
      end,
      &success_response/2
    )
  end

  @doc """
  Issue an invoice.
  """
  @spec issue_invoice(
          req :: Req.Request.t(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def issue_invoice(req, id) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_invoice/#{id}/issue")
      end,
      &success_response/2
    )
  end

  @doc """
  Revert Issue an invoice.
  """
  @spec revert_issue_invoice(
          req :: Req.Request.t(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def revert_issue_invoice(req, id) do
    bexio_body_handling(
      fn ->
        Req.post(req, url: "/2.0/kb_invoice/#{id}/revert_issue")
      end,
      &success_response/2
    )
  end

  @doc """
  This action returns a pdf document of the quote
  """
  @spec invoice_pdf(
          req :: Req.Request.t(),
          invoice_id :: pos_integer()
        ) :: {:ok, map()} | api_error_type
  def invoice_pdf(req, invoice_id) do
    bexio_body_handling(
      fn ->
        Req.get(req, url: "/2.0/kb_invoice/#{invoice_id}/pdf")
      end,
      &map_from_pdf/2
    )
  end

  defp remap_invoice(
         %Invoice{
           positions: positions
         } = invoice
       ) do
    invoice
    |> remap_edit_invoice()
    |> Map.put(:positions, Enum.map(positions, &map_to_post_position/1))
  end

  defp remap_edit_invoice(%Invoice{
         title: title,
         document_nr: document_nr,
         contact_id: contact_id,
         contact_sub_id: contact_sub_id,
         user_id: user_id,
         project_id: project_id,
         language_id: language_id,
         bank_account_id: bank_account_id,
         currency_id: curency_id,
         payment_type_id: payment_type_id,
         header: header,
         footer: footer,
         mwst_type: mwst_type,
         mwst_is_net?: mwst_is_net?,
         show_position_taxes?: show_position_taxes?,
         is_valid_from: is_valid_from,
         is_valid_to: is_valid_to,
         api_reference: api_reference,
         template_slug: template_slug,
         reference: reference
       }) do
    %{
      title: title,
      document_nr: document_nr,
      contact_id: contact_id,
      contact_sub_id: contact_sub_id,
      user_id: user_id,
      pr_project_id: project_id,
      language_id: language_id,
      bank_account_id: bank_account_id,
      currency_id: curency_id,
      payment_type_id: payment_type_id,
      header: header,
      footer: footer,
      mwst_type: mwst_type_id(mwst_type),
      mwst_is_net: mwst_is_net?,
      show_position_taxes: show_position_taxes?,
      is_valid_from: to_iso8601(is_valid_from),
      is_valid_to: to_iso8601(is_valid_to),
      reference: reference,
      api_reference: api_reference,
      template_slug: template_slug
    }
    |> remove_document_no_if_nil()
  end

  defp map_from_invoices(invoices, _env), do: Enum.map(invoices, &map_from_invoice/1)

  defp map_from_invoice(
         %{
           "id" => id,
           "document_nr" => document_nr,
           "title" => title,
           "contact_id" => contact_id,
           "contact_sub_id" => contact_sub_id,
           "user_id" => user_id,
           "project_id" => project_id,
           "language_id" => language_id,
           "bank_account_id" => bank_account_id,
           "currency_id" => currency_id,
           "payment_type_id" => payment_type_id,
           "header" => header,
           "footer" => footer,
           "total_gross" => total_gross,
           "total_net" => total_net,
           "total_taxes" => total_taxes,
           "total" => total,
           "total_rounding_difference" => total_rounding_difference,
           "mwst_type" => mwst_type_id,
           "mwst_is_net" => mwst_is_net?,
           "show_position_taxes" => show_position_taxes?,
           "is_valid_from" => is_valid_from,
           "is_valid_to" => is_valid_to,
           "contact_address" => contact_address,
           "kb_item_status_id" => kb_item_status_id,
           "api_reference" => api_reference,
           "viewed_by_client_at" => viewed_by_client_at,
           "updated_at" => updated_at,
           "template_slug" => template_slug,
           "taxs" => taxs,
           "network_link" => network_link,
           "esr_id" => esr_id,
           "qr_invoice_id" => qr_invoice_id,
           "reference" => reference,
           "total_credit_vouchers" => total_credit_vouchers,
           "total_received_payments" => total_received_payments,
           "total_remaining_payments" => total_remaining_payments
         } = map,
         _env \\ nil
       ) do
    %Invoice{
      id: id,
      document_nr: document_nr,
      title: title,
      contact_id: contact_id,
      contact_sub_id: contact_sub_id,
      user_id: user_id,
      project_id: project_id,
      language_id: language_id,
      bank_account_id: bank_account_id,
      currency_id: currency_id,
      payment_type_id: payment_type_id,
      header: header,
      footer: footer,
      total_gross: to_decimal(total_gross),
      total_net: to_decimal(total_net),
      total_taxes: to_decimal(total_taxes),
      total: to_decimal(total),
      total_rounding_difference: total_rounding_difference,
      mwst_type: mwst_type(mwst_type_id),
      mwst_is_net?: mwst_is_net?,
      show_position_taxes?: show_position_taxes?,
      is_valid_from: to_date(is_valid_from),
      is_valid_to: to_date(is_valid_to),
      contact_address: contact_address,
      kb_item_status: invoice_kb_item_status(kb_item_status_id),
      api_reference: api_reference,
      viewed_by_client_at: to_datetime(viewed_by_client_at),
      updated_at: to_datetime(updated_at),
      template_slug: template_slug,
      taxs: Enum.map(taxs, &to_tax/1),
      network_link: network_link,
      esr_id: esr_id,
      qr_invoice_id: qr_invoice_id,
      reference: reference,
      total_credit_vouchers: to_decimal(total_credit_vouchers),
      total_received_payments: to_decimal(total_received_payments),
      total_remaining_payments: to_decimal(total_remaining_payments)
    }
    |> map_invoice_positions(Map.get(map, "positions"))
  end

  defp map_invoice_positions(q, nil), do: q
  defp map_invoice_positions(q, positions), do: %{q | positions: remap_positions(positions)}

  defp invoice_kb_item_status(7), do: :draft
  defp invoice_kb_item_status(8), do: :pending
  defp invoice_kb_item_status(9), do: :paid
  defp invoice_kb_item_status(16), do: :partial
  defp invoice_kb_item_status(19), do: :cancelled
  defp invoice_kb_item_status(31), do: :unpaid

  # Document Settings
  @doc """
  Fetch a list of document settings
  """
  @spec fetch_document_settings(
          req :: Req.Request.t(),
          opts :: [GlobalArguments.offset_arg()]
        ) ::
          {:ok, [DocumentSetting.t()]} | api_error_type
  def fetch_document_settings(req, opts \\ []) do
    bexio_body_handling(
      fn ->
        Req.get(req, url: "/2.0/kb_item_setting", params: opts_to_query(opts))
      end,
      &map_from_document_settings/2
    )
  end

  defp map_from_document_settings(document_settings, _env),
    do: Enum.map(document_settings, &map_from_document_setting/1)

  defp map_from_document_setting(
         %{
           "id" => id,
           "text" => text,
           "kb_item_class" => kb_item_class,
           "enumeration_format" => enumeration_format,
           "use_automatic_enumeration" => use_automatic_enumeration,
           "use_yearly_enumeration" => use_yearly_enumeration,
           "next_nr" => next_nr,
           "nr_min_length" => nr_min_length,
           "default_time_period_in_days" => default_time_period_in_days,
           "default_logopaper_id" => default_logopaper_id,
           "default_language_id" => default_language_id,
           "default_client_bank_account_new_id" => default_client_bank_account_new_id,
           "default_currency_id" => default_currency_id,
           "default_mwst_type" => default_mwst_type,
           "default_mwst_is_net" => default_mwst_is_net,
           "default_nb_decimals_amount" => default_nb_decimals_amount,
           "default_nb_decimals_price" => default_nb_decimals_price,
           "default_show_position_taxes" => default_show_position_taxes,
           "default_title" => default_title,
           "default_show_esr_on_same_page" => default_show_esr_on_same_page?,
           "default_payment_type_id" => default_payment_type_id?,
           "kb_terms_of_payment_template_id" => kb_terms_of_payment_template_id,
           "default_show_total" => default_show_total
         },
         _env \\ nil
       ) do
    %DocumentSetting{
      id: id,
      text: text,
      kb_item_class: String.to_atom(kb_item_class),
      enumeration_format: enumeration_format,
      automatic_enumeration?: use_automatic_enumeration,
      yearly_enumeration?: use_yearly_enumeration,
      next_nr: next_nr,
      nr_min_length: nr_min_length,
      default_time_period_in_days: default_time_period_in_days,
      default_logopaper_id: default_logopaper_id,
      default_language_id: default_language_id,
      default_client_bank_account_new_id: default_client_bank_account_new_id,
      default_currency_id: default_currency_id,
      default_mwst_type: default_mwst_type,
      default_mwst_net?: default_mwst_is_net,
      default_nb_decimals_amount: default_nb_decimals_amount,
      default_nb_decimals_price: default_nb_decimals_price,
      default_show_position_taxes?: default_show_position_taxes,
      default_title: default_title,
      default_show_esr_on_same_page?: default_show_esr_on_same_page?,
      default_payment_type_id: default_payment_type_id?,
      kb_terms_of_payment_template_id: kb_terms_of_payment_template_id,
      default_show_total?: default_show_total
    }
  end

  # Comments

  @doc """
  This action fetches a list of comments.
  """
  @spec fetch_comments(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          opts :: [GlobalArguments.offset_without_order_by_arg()]
        ) :: {:ok, [Comment.t()]} | api_error_type
  def fetch_comments(
        req,
        document_type,
        document_id,
        opts \\ []
      ) do
    bexio_body_handling(
      fn ->
        Req.get(req,
          url: "/2.0/kb_#{document_type}/#{document_id}/comment",
          params: opts_to_query(opts)
        )
      end,
      &map_from_comments/2
    )
  end

  @doc """
  This action fetches a single comment.
  """
  @spec fetch_comment(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          comment_id :: pos_integer()
        ) :: {:ok, Comment.t()} | api_error_type
  def fetch_comment(
        req,
        document_type,
        document_id,
        comment_id
      ) do
    bexio_body_handling(
      fn ->
        Req.get(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/comment/#{comment_id}"
        )
      end,
      &map_from_comment/2
    )
  end

  @doc """
  Create a comment
  """
  @spec create_comment(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          comment :: Comment.t()
        ) :: {:ok, Comment.t()} | api_error_type
  def create_comment(
        req,
        document_type,
        document_id,
        comment
      ) do
    bexio_body_handling(
      fn ->
        Req.post(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/comment",
          json: mapped_comment(comment)
        )
      end,
      &map_from_comment/2
    )
  end

  defp mapped_comment(%{
         text: text,
         user_id: user_id,
         user_email: user_email,
         user_name: user_name,
         public?: public?
       }) do
    %{
      text: text,
      user_id: user_id,
      user_email: user_email,
      user_name: user_name,
      is_public: public?
    }
  end

  defp map_from_comments(comments, _env), do: Enum.map(comments, &map_from_comment/1)

  defp map_from_comment(
         %{
           "id" => id,
           "text" => text,
           "user_id" => user_id,
           "user_email" => user_email,
           "user_name" => user_name,
           "date" => date,
           "is_public" => public?,
           "image" => image,
           "image_path" => image_path
         },
         _env \\ nil
       ) do
    %Comment{
      id: id,
      text: text,
      user_id: user_id,
      user_email: user_email,
      user_name: user_name,
      date: to_datetime(date),
      public?: public?,
      image: image,
      image_path: image_path
    }
  end

  # Remap positions for single orders / quotes
  defp remap_positions([]), do: []
  defp remap_positions([position | tl]), do: [remap_position(position) | remap_positions(tl)]

  defp remap_position(%{"type" => "KbPositionCustom"} = position),
    do: map_from_default_position(position)

  defp remap_position(%{"type" => "KbPositionArticle"} = position),
    do: map_from_item_position(position)

  defp remap_position(%{"type" => "KbPositionText"} = position),
    do: map_from_text_position(position)

  defp remap_position(%{"type" => "KbPositionSubtotal"} = position),
    do: map_from_subtotal_position(position)

  defp remap_position(%{"type" => "KbPositionPagebreak"} = position),
    do: map_from_pagebreak_position(position)

  defp remap_position(%{"type" => "KbPositionDiscount"} = position),
    do: map_from_discount_position(position)

  # Subtotal Positions

  @doc """
  This action fetches a list of all subtotal positions for a document.
  """
  @spec fetch_subtotal_positions(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          opts :: [GlobalArguments.offset_without_order_by_arg()]
        ) :: {:ok, [PositionSubtotal.t()]} | api_error_type
  def fetch_subtotal_positions(
        req,
        document_type,
        document_id,
        opts \\ []
      ) do
    bexio_body_handling(
      fn ->
        Req.get(req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_subtotal",
          params: opts_to_query(opts)
        )
      end,
      &map_from_subtotal_positions/2
    )
  end

  @doc """
  This action fetches a single subtotal position for a document.
  """
  @spec fetch_subtotal_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position_id :: pos_integer()
        ) :: {:ok, PositionSubtotal.t()} | api_error_type
  def fetch_subtotal_position(
        req,
        document_type,
        document_id,
        position_id
      ) do
    bexio_body_handling(
      fn ->
        Req.get(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_subtotal/#{position_id}"
        )
      end,
      &map_from_subtotal_position/2
    )
  end

  @doc """
  Create a subtotal position.
  """
  @spec create_subtotal_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          text :: String.t()
        ) :: {:ok, PositionSubtotal.t()} | api_error_type
  def create_subtotal_position(req, document_type, document_id, text) do
    bexio_body_handling(
      fn ->
        Req.post(req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_subtotal",
          json: %{
            text: text
          }
        )
      end,
      &map_from_subtotal_position/2
    )
  end

  @doc """
  Edit a subtotal position.
  """
  @spec edit_subtotal_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position_id :: pos_integer(),
          text :: String.t()
        ) :: {:ok, PositionSubtotal.t()} | api_error_type
  def edit_subtotal_position(req, document_type, document_id, position_id, text) do
    bexio_body_handling(
      fn ->
        Req.post(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_subtotal/#{position_id}",
          json: %{text: text}
        )
      end,
      &map_from_subtotal_position/2
    )
  end

  @doc """
  Delete a subtotal position.
  """
  @spec delete_subtotal_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def delete_subtotal_position(req, document_type, document_id, id) do
    bexio_body_handling(
      fn ->
        Req.delete(req, url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_subtotal/#{id}")
      end,
      &success_response/2
    )
  end

  defp map_from_subtotal_positions(subtotal_positions, _env),
    do: Enum.map(subtotal_positions, &map_from_subtotal_position/1)

  defp map_from_subtotal_position(
         %{
           "id" => id,
           "text" => text,
           "value" => value,
           "internal_pos" => internal_pos,
           "is_optional" => optional?,
           "parent_id" => parent_id
         },
         _env \\ nil
       ) do
    %PositionSubtotal{
      id: id,
      text: text,
      value: decimal_nil_as_zero(value),
      internal_pos: internal_pos,
      optional?: optional?,
      parent_id: parent_id
    }
  end

  ### Text Position

  @doc """
  This action fetches a list of all text positions for a document.
  """
  @spec fetch_text_positions(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          opts :: [GlobalArguments.offset_without_order_by_arg()]
        ) :: {:ok, [PositionText.t()]} | api_error_type
  def fetch_text_positions(
        req,
        document_type,
        document_id,
        opts \\ []
      ) do
    bexio_body_handling(
      fn ->
        Req.get(req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_text",
          params: opts_to_query(opts)
        )
      end,
      &map_from_text_positions/2
    )
  end

  @doc """
  This action fetches a single text position for a document.
  """
  @spec fetch_text_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position_id :: pos_integer()
        ) :: {:ok, PositionText.t()} | api_error_type
  def fetch_text_position(
        req,
        document_type,
        document_id,
        position_id
      ) do
    bexio_body_handling(
      fn ->
        Req.get(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_text/#{position_id}"
        )
      end,
      &map_from_text_position/2
    )
  end

  @doc """
  Create a text position
  """
  @spec create_text_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          text :: String.t(),
          show_pos_nr? :: boolean()
        ) :: {:ok, PositionText.t()} | api_error_type
  def create_text_position(req, document_type, document_id, text, show_pos_nr?) do
    bexio_body_handling(
      fn ->
        Req.post(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_text",
          json: %{text: text, show_pos_nr: show_pos_nr?}
        )
      end,
      &map_from_text_position/2
    )
  end

  @doc """
  Edit a text position.
  """
  @spec edit_text_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position_id :: pos_integer(),
          text :: String.t(),
          show_pos_nr? :: boolean()
        ) :: {:ok, PositionText.t()} | api_error_type
  def edit_text_position(req, document_type, document_id, position_id, text, show_pos_nr?) do
    bexio_body_handling(
      fn ->
        Req.post(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_text/#{position_id}",
          json: %{text: text, show_pos_nr: show_pos_nr?}
        )
      end,
      &map_from_text_position/2
    )
  end

  @doc """
  Delete a text position.
  """
  @spec delete_text_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def delete_text_position(req, document_type, document_id, id) do
    bexio_body_handling(
      fn ->
        Req.delete(req, url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_text/#{id}")
      end,
      &success_response/2
    )
  end

  defp map_from_text_positions(text_positions, _env),
    do: Enum.map(text_positions, &map_from_text_position/1)

  defp map_from_text_position(
         %{
           "id" => id,
           "text" => text,
           "show_pos_nr" => show_pos_nr?,
           "pos" => pos,
           "internal_pos" => internal_pos,
           "is_optional" => optional?,
           "parent_id" => parent_id
         },
         _env \\ nil
       ) do
    %PositionText{
      id: id,
      text: text,
      show_pos_nr?: show_pos_nr?,
      pos: pos,
      internal_pos: internal_pos,
      optional?: optional?,
      parent_id: parent_id
    }
  end

  ### Default Position

  @doc """
  This action fetches a list of all default positions for a document.
  """
  @spec fetch_default_positions(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          opts :: [GlobalArguments.offset_without_order_by_arg()]
        ) :: {:ok, [PositionDefault.t()]} | api_error_type
  def fetch_default_positions(
        req,
        document_type,
        document_id,
        opts \\ []
      ) do
    bexio_body_handling(
      fn ->
        Req.get(req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_custom",
          params: opts_to_query(opts)
        )
      end,
      &map_from_default_positions/2
    )
  end

  @doc """
  This action fetches a single default position for a document.
  """
  @spec fetch_default_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position_id :: pos_integer()
        ) :: {:ok, PositionDefault.t()} | api_error_type
  def fetch_default_position(
        req,
        document_type,
        document_id,
        position_id
      ) do
    bexio_body_handling(
      fn ->
        Req.get(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_custom/#{position_id}"
        )
      end,
      &map_from_default_position/2
    )
  end

  @doc """
  Create a default position
  """
  @spec create_default_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position :: PositionDefault.t()
        ) :: {:ok, PositionDefault.t()} | api_error_type
  def create_default_position(req, document_type, document_id, position) do
    bexio_body_handling(
      fn ->
        Req.post(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_custom",
          json: remap_default_position(position)
        )
      end,
      &map_from_default_position/2
    )
  end

  @doc """
  Edit a default position.
  """
  @spec edit_default_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position :: PositionDefault.t()
        ) :: {:ok, PositionDefault.t()} | api_error_type
  def edit_default_position(req, document_type, document_id, position) do
    bexio_body_handling(
      fn ->
        Req.post(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_custom/#{position.id}",
          json: remap_default_position(position)
        )
      end,
      &map_from_default_position/2
    )
  end

  @doc """
  Delete a default position.
  """
  @spec delete_default_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def delete_default_position(req, document_type, document_id, id) do
    bexio_body_handling(
      fn ->
        Req.delete(req, url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_custom/#{id}")
      end,
      &success_response/2
    )
  end

  defp remap_default_position(%PositionDefault{
         amount: amount,
         unit_id: unit_id,
         account_id: account_id,
         tax_id: tax_id,
         text: text,
         unit_price: unit_price,
         discount_in_percent: discount_in_percent
       }) do
    %{
      amount: Decimal.to_string(amount, :normal),
      unit_id: unit_id,
      account_id: account_id,
      tax_id: tax_id,
      text: text,
      unit_price: Decimal.to_string(unit_price, :normal),
      discount_in_percent: Decimal.to_string(discount_in_percent, :normal)
    }
  end

  defp map_from_default_positions(default_positions, _env),
    do: Enum.map(default_positions, &map_from_default_position/1)

  defp map_from_default_position(
         %{
           "id" => id,
           "amount" => amount,
           "unit_id" => unit_id,
           "account_id" => account_id,
           "unit_name" => unit_name,
           "tax_id" => tax_id,
           "tax_value" => tax_value,
           "text" => text,
           "unit_price" => unit_price,
           "discount_in_percent" => discount_in_percent,
           "position_total" => position_total,
           "pos" => pos,
           "internal_pos" => internal_pos,
           "is_optional" => optional?,
           "parent_id" => parent_id
         },
         _env \\ nil
       ) do
    %PositionDefault{
      id: id,
      amount: decimal_nil_as_zero(amount),
      unit_id: unit_id,
      account_id: account_id,
      unit_name: unit_name,
      tax_id: tax_id,
      tax_value: decimal_nil_as_zero(tax_value),
      text: text,
      unit_price: decimal_nil_as_zero(unit_price),
      discount_in_percent: decimal_nil_as_zero(discount_in_percent),
      position_total: decimal_nil_as_zero(position_total),
      pos: pos,
      internal_pos: internal_pos,
      optional?: optional?,
      parent_id: parent_id
    }
  end

  ### Item Position

  @doc """
  This action fetches a list of all item positions for a document.
  """
  @spec fetch_item_positions(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          opts :: [GlobalArguments.offset_without_order_by_arg()]
        ) :: {:ok, [PositionItem.t()]} | api_error_type
  def fetch_item_positions(
        req,
        document_type,
        document_id,
        opts \\ []
      ) do
    bexio_body_handling(
      fn ->
        Req.get(req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_article",
          params: opts_to_query(opts)
        )
      end,
      &map_from_item_positions/2
    )
  end

  @doc """
  This action fetches a single item position for a document.
  """
  @spec fetch_item_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position_id :: pos_integer()
        ) :: {:ok, PositionItem.t()} | api_error_type
  def fetch_item_position(
        req,
        document_type,
        document_id,
        position_id
      ) do
    bexio_body_handling(
      fn ->
        Req.get(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_article/#{position_id}"
        )
      end,
      &map_from_item_position/2
    )
  end

  @doc """
  Create an item position
  """
  @spec create_item_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position :: PositionItem.t()
        ) :: {:ok, PositionItem.t()} | api_error_type
  def create_item_position(req, document_type, document_id, position) do
    bexio_body_handling(
      fn ->
        Req.post(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_article",
          json: remap_item_position(position)
        )
      end,
      &map_from_item_position/2
    )
  end

  @doc """
  Edit an item.
  """
  @spec edit_item_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position :: PositionItem.t()
        ) :: {:ok, PositionItem.t()} | api_error_type
  def edit_item_position(req, document_type, document_id, position) do
    bexio_body_handling(
      fn ->
        Req.post(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_article/#{position.id}",
          json: remap_item_position(position)
        )
      end,
      &map_from_default_position/2
    )
  end

  @doc """
  Delete a default position.
  """
  @spec delete_item_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def delete_item_position(req, document_type, document_id, id) do
    bexio_body_handling(
      fn ->
        Req.delete(req, url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_article/#{id}")
      end,
      &success_response/2
    )
  end

  defp remap_item_position(%PositionItem{
         amount: amount,
         unit_id: unit_id,
         account_id: account_id,
         tax_id: tax_id,
         text: text,
         unit_price: unit_price,
         discount_in_percent: discount_in_percent,
         article_id: article_id
       }) do
    %{
      amount: Decimal.to_string(amount, :normal),
      unit_id: unit_id,
      account_id: account_id,
      tax_id: tax_id,
      text: text,
      unit_price: Decimal.to_string(unit_price, :normal),
      discount_in_percent: Decimal.to_string(discount_in_percent, :normal),
      article_id: article_id
    }
  end

  defp map_from_item_positions(item_positions, _env),
    do: Enum.map(item_positions, &map_from_item_position/1)

  defp map_from_item_position(
         %{
           "id" => id,
           "amount" => amount,
           "unit_id" => unit_id,
           "account_id" => account_id,
           "unit_name" => unit_name,
           "tax_id" => tax_id,
           "tax_value" => tax_value,
           "text" => text,
           "unit_price" => unit_price,
           "discount_in_percent" => discount_in_percent,
           "position_total" => position_total,
           "pos" => pos,
           "internal_pos" => internal_pos,
           "is_optional" => optional?,
           "article_id" => article_id,
           "parent_id" => parent_id
         },
         _env \\ nil
       ) do
    %PositionItem{
      id: id,
      amount: decimal_nil_as_zero(amount),
      unit_id: unit_id,
      account_id: account_id,
      unit_name: unit_name,
      tax_id: tax_id,
      tax_value: decimal_nil_as_zero(tax_value),
      text: text,
      unit_price: decimal_nil_as_zero(unit_price),
      discount_in_percent: decimal_nil_as_zero(discount_in_percent),
      position_total: decimal_nil_as_zero(position_total),
      pos: pos,
      internal_pos: internal_pos,
      optional?: optional?,
      article_id: article_id,
      parent_id: parent_id
    }
  end

  ### Discount Position

  @doc """
  This action fetches a list of all discount positions for a document.
  """
  @spec fetch_discount_positions(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          opts :: [GlobalArguments.offset_without_order_by_arg()]
        ) :: {:ok, [PositionDiscount.t()]} | api_error_type
  def fetch_discount_positions(
        req,
        document_type,
        document_id,
        opts \\ []
      ) do
    bexio_body_handling(
      fn ->
        Req.get(req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_discount",
          params: opts_to_query(opts)
        )
      end,
      &map_from_discount_positions/2
    )
  end

  @doc """
  This action fetches a single discount position for a document.
  """
  @spec fetch_discount_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position_id :: pos_integer()
        ) :: {:ok, PositionDiscount.t()} | api_error_type
  def fetch_discount_position(
        req,
        document_type,
        document_id,
        position_id
      ) do
    bexio_body_handling(
      fn ->
        Req.get(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_discount/#{position_id}"
        )
      end,
      &map_from_discount_position/2
    )
  end

  @doc """
  Create a discount position
  """
  @spec create_discount_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position :: PositionDiscount.t()
        ) :: {:ok, PositionDiscount.t()} | api_error_type
  def create_discount_position(req, document_type, document_id, position) do
    bexio_body_handling(
      fn ->
        Req.post(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_discount",
          json: remap_discount_position(position)
        )
      end,
      &map_from_discount_position/2
    )
  end

  @doc """
  Edit a discount position.
  """
  @spec edit_discount_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position :: PositionDiscount.t()
        ) :: {:ok, PositionDiscount.t()} | api_error_type
  def edit_discount_position(req, document_type, document_id, position) do
    bexio_body_handling(
      fn ->
        Req.post(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_discount/#{position.id}",
          json: remap_discount_position(position)
        )
      end,
      &map_from_discount_position/2
    )
  end

  @doc """
  Delete a discount position.
  """
  @spec delete_discount_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def delete_discount_position(req, document_type, document_id, id) do
    bexio_body_handling(
      fn ->
        Req.delete(req, url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_discount/#{id}")
      end,
      &success_response/2
    )
  end

  defp remap_discount_position(%PositionDiscount{
         text: text,
         percentual?: percentual?,
         value: value
       }) do
    %{
      text: text,
      is_percentual: percentual?,
      value: Decimal.to_string(value, :normal)
    }
  end

  defp map_from_discount_positions(discount_positions, _env),
    do: Enum.map(discount_positions, &map_from_discount_position/1)

  defp map_from_discount_position(
         %{
           "id" => id,
           "text" => text,
           "value" => value,
           "discount_total" => discount_total,
           "is_percentual" => percentual?
         },
         _env \\ nil
       ) do
    %PositionDiscount{
      id: id,
      text: text,
      value: decimal_nil_as_zero(value),
      discount_total: decimal_nil_as_zero(discount_total),
      percentual?: percentual?
    }
  end

  ### Pagebreak Position

  @doc """
  This action fetches a list of all pagebreak positions for a document.
  """
  @spec fetch_pagebreak_positions(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          opts :: [GlobalArguments.offset_without_order_by_arg()]
        ) :: {:ok, [PositionPagebreak.t()]} | api_error_type
  def fetch_pagebreak_positions(
        req,
        document_type,
        document_id,
        opts \\ []
      ) do
    bexio_body_handling(
      fn ->
        Req.get(req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_pagebreak",
          params: opts_to_query(opts)
        )
      end,
      &map_from_pagebreak_positions/2
    )
  end

  @doc """
  This action fetches a single pagebreak position for a document.
  """
  @spec fetch_pagebreak_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position_id :: pos_integer()
        ) :: {:ok, PositionPagebreak.t()} | api_error_type
  def fetch_pagebreak_position(
        req,
        document_type,
        document_id,
        position_id
      ) do
    bexio_body_handling(
      fn ->
        Req.get(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_pagebreak/#{position_id}"
        )
      end,
      &map_from_pagebreak_position/2
    )
  end

  @doc """
  Create a pagebreak position
  """
  @spec create_pagebreak_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer()
        ) :: {:ok, PositionPagebreak.t()} | api_error_type
  def create_pagebreak_position(req, document_type, document_id) do
    bexio_body_handling(
      fn ->
        Req.post(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_pagebreak"
        )
      end,
      &map_from_pagebreak_position/2
    )
  end

  @doc """
  Edit a pagebreak position.
  """
  @spec edit_pagebreak_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position_id :: pos_integer(),
          pagebreak :: boolean()
        ) :: {:ok, PositionPagebreak.t()} | api_error_type
  def edit_pagebreak_position(req, document_type, document_id, position_id, pagebreak) do
    bexio_body_handling(
      fn ->
        Req.post(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_pagebreak/#{position_id}",
          json: %{papebreak: pagebreak}
        )
      end,
      &map_from_pagebreak_position/2
    )
  end

  @doc """
  Delete a pagebreak position.
  """
  @spec delete_pagebreak_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def delete_pagebreak_position(req, document_type, document_id, id) do
    bexio_body_handling(
      fn ->
        Req.delete(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_pagebreak/#{id}"
        )
      end,
      &success_response/2
    )
  end

  defp map_from_pagebreak_positions(pagebreak_positions, _env),
    do: Enum.map(pagebreak_positions, &map_from_pagebreak_position/1)

  defp map_from_pagebreak_position(
         %{
           "id" => id,
           "internal_pos" => internal_pos,
           "is_optional" => optional?,
           "parent_id" => parent_id
         },
         _env \\ nil
       ) do
    %PositionPagebreak{
      id: id,
      internal_pos: internal_pos,
      optional?: optional?,
      parent_id: parent_id
    }
  end

  ### Subposition Position

  @doc """
  This action fetches a list of all subposition positions for a document.
  """
  @spec fetch_subposition_positions(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          opts :: [GlobalArguments.offset_without_order_by_arg()]
        ) ::
          {:ok, [PositionSubposition.t()]} | api_error_type
  def fetch_subposition_positions(
        req,
        document_type,
        document_id,
        opts \\ []
      ) do
    bexio_body_handling(
      fn ->
        Req.get(req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_subposition",
          params: opts_to_query(opts)
        )
      end,
      &map_from_subposition_positions/2
    )
  end

  @doc """
  This action fetches a single subposition position for a document.
  """
  @spec fetch_subposition_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position_id :: pos_integer()
        ) ::
          {:ok, PositionSubposition.t()} | api_error_type
  def fetch_subposition_position(
        req,
        document_type,
        document_id,
        position_id
      ) do
    bexio_body_handling(
      fn ->
        Req.get(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_subposition/#{position_id}"
        )
      end,
      &map_from_subposition_position/2
    )
  end

  @doc """
  Create a subposition position
  """
  @spec create_subposition_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          text :: String.t(),
          show_pos_nr? :: boolean()
        ) :: {:ok, PositionSubposition.t()} | api_error_type
  def create_subposition_position(req, document_type, document_id, text, show_pos_nr?) do
    bexio_body_handling(
      fn ->
        Req.post(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_subposition",
          json: %{text: text, show_pos_nr: show_pos_nr?}
        )
      end,
      &map_from_subposition_position/2
    )
  end

  @doc """
  Edit a subposition position.
  """
  @spec edit_subposition_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          position_id :: pos_integer(),
          text :: String.t(),
          show_pos_nr? :: boolean()
        ) :: {:ok, PositionSubposition.t()} | api_error_type
  def edit_subposition_position(
        req,
        document_type,
        document_id,
        position_id,
        text,
        show_pos_nr?
      ) do
    bexio_body_handling(
      fn ->
        Req.post(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_subposition/#{position_id}",
          json: %{text: text, show_pos_nr: show_pos_nr?}
        )
      end,
      &map_from_subposition_position/2
    )
  end

  @doc """
  Delete a subposition position.
  """
  @spec delete_subposition_position(
          req :: Req.Request.t(),
          document_type :: :offer | :order | :invoice,
          document_id :: pos_integer(),
          id :: non_neg_integer()
        ) :: {:ok, boolean()} | api_error_type
  def delete_subposition_position(req, document_type, document_id, id) do
    bexio_body_handling(
      fn ->
        Req.delete(
          req,
          url: "/2.0/kb_#{document_type}/#{document_id}/kb_position_subposition/#{id}"
        )
      end,
      &success_response/2
    )
  end

  defp map_from_subposition_positions(subposition_positions, _env),
    do: Enum.map(subposition_positions, &map_from_subposition_position/1)

  defp map_from_subposition_position(
         %{
           "id" => id,
           "text" => text,
           "pos" => pos,
           "internal_pos" => internal_pos,
           "show_pos_nr" => show_pos_nr?,
           "is_optional" => optional?,
           "total_sum" => total_sum,
           "show_pos_prices" => show_pos_prices?,
           "parent_id" => parent_id
         },
         _env \\ nil
       ) do
    %PositionSubposition{
      id: id,
      text: text,
      pos: pos,
      internal_pos: internal_pos,
      show_pos_prices?: show_pos_prices?,
      show_pos_nr?: show_pos_nr?,
      total_sum: decimal_nil_as_zero(total_sum),
      optional?: optional?,
      parent_id: parent_id
    }
  end
end