Vereinfacht API: use filter for contact lookup, drop extra required fields

- find_contact_by_email uses GET with filter[isExternal]=true and filter[email]
- vereinfacht_required_member_fields is now empty (API accepts minimal payload)
This commit is contained in:
Moritz 2026-03-04 19:22:27 +01:00
parent e4ddaf0dc3
commit dc2cff8ec4
Signed by: moritz
GPG key ID: 1020A035E5DD0824
3 changed files with 27 additions and 62 deletions

View file

@ -28,15 +28,17 @@ defmodule Mv.Constants do
@email_validator_checks [:html_input, :pow] @email_validator_checks [:html_input, :pow]
# Member fields that are required when Vereinfacht integration is active (contact sync) # No member fields are required solely for Vereinfacht; API accepts minimal payload
@vereinfacht_required_member_fields [:first_name, :last_name, :street, :postal_code, :city] # (contactType + isExternal) when creating external contacts and supports filter by email for lookup.
@vereinfacht_required_member_fields []
def member_fields, do: @member_fields def member_fields, do: @member_fields
@doc """ @doc """
Returns member fields that are always required when Vereinfacht integration is configured. Returns member fields that are always required when Vereinfacht integration is configured.
Used for validation, member form required indicators, and settings UI (checkbox disabled). Currently empty: the Vereinfacht API only requires contactType (e.g. "person") when creating
external contacts; lookup uses filter[email] so no extra required fields in the app.
""" """
def vereinfacht_required_member_fields, do: @vereinfacht_required_member_fields def vereinfacht_required_member_fields, do: @vereinfacht_required_member_fields

View file

@ -166,12 +166,12 @@ defmodule Mv.Vereinfacht.Client do
end end
@doc """ @doc """
Finds a finance contact by email (GET /finance-contacts, then match in response). Finds a finance contact by email using the API filter.
The Vereinfacht API does not allow filter by email on this endpoint, so we Uses GET /finance-contacts?filter[isExternal]=true&filter[email]=... so the API
fetch the first page and find the contact client-side. Returns {:ok, contact_id} returns only matching external contacts. Returns {:ok, contact_id} if a contact
if a contact with that email exists, {:error, :not_found} if none, or exists, {:error, :not_found} if none, or {:error, reason} on API/network failure.
{:error, reason} on API/network failure. Used before create for idempotency. Used before create for idempotency.
""" """
@spec find_contact_by_email(String.t()) :: {:ok, String.t()} | {:error, :not_found | term()} @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 def find_contact_by_email(email) when is_binary(email) do
@ -182,25 +182,17 @@ defmodule Mv.Vereinfacht.Client do
end end
end end
@find_contact_page_size 100
@find_contact_max_pages 100
defp do_find_contact_by_email(email) do 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") base = base_url() |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts")
url = base <> "?page[size]=#{@find_contact_page_size}&page[number]=#{page}" encoded_email = URI.encode_www_form(email |> String.trim())
url = "#{base}?filter[isExternal]=true&filter[email]=#{encoded_email}"
case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do
{:ok, %{status: 200, body: body}} when is_map(body) -> {:ok, %{status: 200, body: body}} when is_map(body) ->
handle_find_contact_page_response(body, page, normalized) case get_first_contact_id_from_list(body) do
nil -> {:error, :not_found}
id -> {:ok, id}
end
{:ok, %{status: status, body: body}} -> {:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}} {:error, {:http, status, extract_error_message(body)}}
@ -210,48 +202,12 @@ defmodule Mv.Vereinfacht.Client do
end end
end end
defp handle_find_contact_page_response(body, page, normalized) do defp get_first_contact_id_from_list(%{"data" => [%{"id" => id} | _]}) do
case find_contact_id_by_email_in_list(body, normalized) do normalize_contact_id(id)
id when is_binary(id) -> {:ok, id}
nil -> maybe_find_contact_next_page(body, page, normalized)
end
end end
defp maybe_find_contact_next_page(body, page, normalized) do defp get_first_contact_id_from_list(%{"data" => []}), do: nil
data = Map.get(body, "data") || [] defp get_first_contact_id_from_list(_), do: nil
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_binary(id), do: id
defp normalize_contact_id(id) when is_integer(id), do: to_string(id) defp normalize_contact_id(id) when is_integer(id), do: to_string(id)

View file

@ -30,6 +30,13 @@ defmodule Mv.Vereinfacht.ClientTest do
end end
end end
describe "find_contact_by_email/1" do
test "returns {:error, :not_configured} when Vereinfacht is not configured" do
assert Client.find_contact_by_email("kayley.becker@example.com") ==
{:error, :not_configured}
end
end
defp build_member_struct do defp build_member_struct do
%{ %{
first_name: "Test", first_name: "Test",