defmodule Mv.Vereinfacht.Client do @moduledoc """ HTTP client for the Vereinfacht accounting software JSON:API. Creates and updates finance contacts. Uses Bearer token authentication and requires club ID for multi-tenancy. Configuration via ENV or Settings (see Mv.Config). """ require Logger @content_type "application/vnd.api+json" @doc """ Tests the connection to the Vereinfacht API with the given credentials. Makes a lightweight `GET /finance-contacts?page[size]=1` request to verify that the API URL, API key, and club ID are valid and reachable. ## Returns - `{:ok, :connected}` – credentials are valid (HTTP 200) - `{:error, :not_configured}` – any parameter is nil or blank - `{:error, {:http, status, message}}` – API returned an error (e.g. 401, 403) - `{:error, {:request_failed, reason}}` – network/transport error ## Examples iex> test_connection("https://api.example.com/api/v1", "token", "2") {:ok, :connected} iex> test_connection(nil, "token", "2") {:error, :not_configured} """ @spec test_connection(String.t() | nil, String.t() | nil, String.t() | nil) :: {:ok, :connected} | {:error, term()} def test_connection(api_url, api_key, club_id) do if blank?(api_url) or blank?(api_key) or blank?(club_id) do {:error, :not_configured} else url = api_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts?page[size]=1") case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do {:ok, %{status: 200}} -> {:ok, :connected} {:ok, %{status: status, body: body}} -> {:error, {:http, status, extract_error_message(body)}} {:error, reason} -> {:error, {:request_failed, reason}} end end end defp blank?(nil), do: true defp blank?(s) when is_binary(s), do: String.trim(s) == "" defp blank?(_), do: true @doc """ Creates a finance contact in Vereinfacht for the given member. Returns the contact ID on success. Does not update the member record; the caller (e.g. SyncContact change) must persist `vereinfacht_contact_id`. ## Options - None; URL, API key, and club ID are read from Mv.Config. ## Examples iex> create_contact(member) {:ok, "242"} iex> create_contact(member) {:error, {:http, 401, "Unauthenticated."}} """ @spec create_contact(struct()) :: {:ok, String.t()} | {:error, term()} def create_contact(member) do base_url = base_url() api_key = api_key() club_id = club_id() if is_nil(base_url) or is_nil(api_key) or is_nil(club_id) do {:error, :not_configured} else body = build_create_body(member, club_id) url = base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts") post_and_parse_contact(url, body, api_key) end end @sync_timeout_ms 5_000 # In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits). defp req_http_options do opts = [receive_timeout: @sync_timeout_ms] if Mix.env() == :test, do: [retry: false] ++ opts, else: opts end defp post_and_parse_contact(url, body, api_key) do encoded_body = Jason.encode!(body) case Req.post(url, [body: encoded_body, headers: headers(api_key)] ++ req_http_options()) do {:ok, %{status: 201, body: resp_body}} -> case get_contact_id_from_response(resp_body) do nil -> {:error, {:invalid_response, resp_body}} id -> {:ok, id} end {:ok, %{status: status, body: resp_body}} -> {:error, {:http, status, extract_error_message(resp_body)}} {:error, reason} -> {:error, {:request_failed, reason}} end end @doc """ Updates an existing finance contact in Vereinfacht. Only sends attributes that are typically synced from the member (name, email, address fields). Returns the same contact_id on success. ## Examples iex> update_contact("242", member) {:ok, "242"} iex> update_contact("242", member) {:error, {:http, 404, "Not Found"}} """ @spec update_contact(String.t(), struct()) :: {:ok, String.t()} | {:error, term()} def update_contact(contact_id, member) when is_binary(contact_id) do base_url = base_url() api_key = api_key() if is_nil(base_url) or is_nil(api_key) do {:error, :not_configured} else body = build_update_body(contact_id, member) encoded_body = Jason.encode!(body) url = base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}") case Req.patch( url, [ body: encoded_body, headers: headers(api_key) ] ++ req_http_options() ) do {:ok, %{status: 200, body: _resp_body}} -> {:ok, contact_id} {:ok, %{status: status, body: body}} -> {:error, {:http, status, extract_error_message(body)}} {:error, reason} -> {:error, {:request_failed, reason}} end end end @doc """ Finds a finance contact by email (GET /finance-contacts, then match in response). The Vereinfacht API does not allow filter by email on this endpoint, so we fetch the first page and find the contact client-side. Returns {:ok, contact_id} if a contact with that email exists, {:error, :not_found} if none, or {:error, reason} on API/network failure. Used before create for idempotency. """ @spec find_contact_by_email(String.t()) :: {:ok, String.t()} | {:error, :not_found | term()} def find_contact_by_email(email) when is_binary(email) do if is_nil(base_url()) or is_nil(api_key()) or is_nil(club_id()) do {:error, :not_configured} else do_find_contact_by_email(email) end end @find_contact_page_size 100 @find_contact_max_pages 100 defp do_find_contact_by_email(email) do normalized = String.trim(email) |> String.downcase() do_find_contact_by_email_page(1, normalized) end defp do_find_contact_by_email_page(page, _normalized) when page > @find_contact_max_pages do {:error, :not_found} end defp do_find_contact_by_email_page(page, normalized) do base = base_url() |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts") url = base <> "?page[size]=#{@find_contact_page_size}&page[number]=#{page}" case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do {:ok, %{status: 200, body: body}} when is_map(body) -> handle_find_contact_page_response(body, page, normalized) {:ok, %{status: status, body: body}} -> {:error, {:http, status, extract_error_message(body)}} {:error, reason} -> {:error, {:request_failed, reason}} end end defp handle_find_contact_page_response(body, page, normalized) do case find_contact_id_by_email_in_list(body, normalized) do id when is_binary(id) -> {:ok, id} nil -> maybe_find_contact_next_page(body, page, normalized) end end defp maybe_find_contact_next_page(body, page, normalized) do data = Map.get(body, "data") || [] if length(data) < @find_contact_page_size, do: {:error, :not_found}, else: do_find_contact_by_email_page(page + 1, normalized) end defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do Enum.find_value(list, fn %{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => true}} when is_binary(att_email) -> if att_email |> String.trim() |> String.downcase() == normalized do normalize_contact_id(id) else nil end %{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => "true"}} when is_binary(att_email) -> if att_email |> String.trim() |> String.downcase() == normalized do normalize_contact_id(id) else nil end %{"id" => _id, "attributes" => _} -> nil _ -> nil end) end defp find_contact_id_by_email_in_list(_, _), do: nil defp normalize_contact_id(id) when is_binary(id), do: id defp normalize_contact_id(id) when is_integer(id), do: to_string(id) defp normalize_contact_id(_), do: nil @doc """ Fetches a single finance contact from Vereinfacht (GET /finance-contacts/:id). Returns the full response body (decoded JSON) for debugging/display. """ @spec get_contact(String.t()) :: {:ok, map()} | {:error, term()} def get_contact(contact_id) when is_binary(contact_id) do fetch_contact(contact_id, []) end @doc """ Fetches a finance contact with receipts (GET /finance-contacts/:id?include=receipts). Returns {:ok, receipts} where receipts is a list of maps with :id and :attributes (and optional :type) for each receipt, or {:error, reason}. """ @spec get_contact_with_receipts(String.t()) :: {:ok, [map()]} | {:error, term()} def get_contact_with_receipts(contact_id) when is_binary(contact_id) do case fetch_contact(contact_id, include: "receipts") do {:ok, body} -> {:ok, extract_receipts_from_response(body)} {:error, _} = err -> err end end defp fetch_contact(contact_id, query_params) do base_url = base_url() api_key = api_key() if is_nil(base_url) or is_nil(api_key) do {:error, :not_configured} else path = base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}") url = build_url_with_params(path, query_params) case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do {:ok, %{status: 200, body: body}} when is_map(body) -> {:ok, body} {:ok, %{status: status, body: body}} -> {:error, {:http, status, extract_error_message(body)}} {:error, reason} -> {:error, {:request_failed, reason}} end end end defp build_url_with_params(base, []), do: base defp build_url_with_params(base, include: value) do sep = if String.contains?(base, "?"), do: "&", else: "?" base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1) end # Allowlist of receipt attribute keys we expose (avoids String.to_atom on arbitrary API input / DoS). @receipt_attr_allowlist ~w[amount bookingDate createdAt receiptType referenceNumber status updatedAt]a defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do included |> Enum.filter(&match?(%{"type" => "receipts"}, &1)) |> Enum.map(fn %{"id" => id, "attributes" => attrs} = r -> Map.merge(%{id: id, type: r["type"]}, receipt_attrs_allowlist(attrs || %{})) end) end defp extract_receipts_from_response(_), do: [] defp receipt_attrs_allowlist(attrs) when is_map(attrs) do Map.new(@receipt_attr_allowlist, fn key -> str_key = to_string(key) {key, Map.get(attrs, str_key)} end) |> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Map.new() end defp base_url, do: Mv.Config.vereinfacht_api_url() defp api_key, do: Mv.Config.vereinfacht_api_key() defp club_id, do: Mv.Config.vereinfacht_club_id() defp headers(api_key) do [ {"Accept", @content_type}, {"Content-Type", @content_type}, {"Authorization", "Bearer #{api_key}"} ] end defp build_create_body(member, club_id) do attributes = member_to_attributes(member) %{ "data" => %{ "type" => "finance-contacts", "attributes" => attributes, "relationships" => %{ "club" => %{ "data" => %{"type" => "clubs", "id" => club_id} } } } } end defp build_update_body(contact_id, member) do attributes = member_to_attributes(member) %{ "data" => %{ "type" => "finance-contacts", "id" => contact_id, "attributes" => attributes } } end defp member_to_attributes(member) do address = [member |> Map.get(:street), member |> Map.get(:house_number)] |> Enum.reject(&is_nil/1) |> Enum.map_join(" ", &to_string/1) |> then(fn s -> if s == "", do: nil, else: s end) %{} |> put_attr("lastName", member |> Map.get(:last_name)) |> put_attr("firstName", member |> Map.get(:first_name)) |> put_attr("email", member |> Map.get(:email)) |> put_attr("address", address) |> put_attr("zipCode", member |> Map.get(:postal_code)) |> put_attr("city", member |> Map.get(:city)) |> Map.put("contactType", "person") |> Map.put("isExternal", true) |> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Map.new() end defp put_attr(acc, _key, nil), do: acc defp put_attr(acc, key, value), do: Map.put(acc, key, to_string(value)) defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_binary(id), do: id defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_integer(id), do: to_string(id) defp get_contact_id_from_response(_), do: nil defp extract_error_message(%{"errors" => [%{"detail" => d} | _]}) when is_binary(d), do: d defp extract_error_message(%{"errors" => [%{"title" => t} | _]}) when is_binary(t), do: t defp extract_error_message(body) when is_map(body), do: inspect(body) defp extract_error_message(body) when is_binary(body) do trimmed = String.trim(body) if String.starts_with?(trimmed, "<") do :html_response else trimmed end end defp extract_error_message(other), do: inspect(other) end