mitgliederverwaltung/lib/mv/vereinfacht/client.ex

423 lines
13 KiB
Elixir
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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