423 lines
13 KiB
Elixir
423 lines
13 KiB
Elixir
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
|