From 96ca857e064d5f0476be40956954c31ec82cf3db Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 4 Mar 2026 19:22:27 +0100 Subject: [PATCH] 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) --- lib/mv/constants.ex | 8 ++-- lib/mv/vereinfacht/client.ex | 74 ++++++----------------------- test/mv/vereinfacht/client_test.exs | 7 +++ 3 files changed, 27 insertions(+), 62 deletions(-) diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 3a01fa9..7bb6274 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -28,15 +28,17 @@ defmodule Mv.Constants do @email_validator_checks [:html_input, :pow] - # Member fields that are required when Vereinfacht integration is active (contact sync) - @vereinfacht_required_member_fields [:first_name, :last_name, :street, :postal_code, :city] + # No member fields are required solely for Vereinfacht; API accepts minimal payload + # (contactType + isExternal) when creating external contacts and supports filter by email for lookup. + @vereinfacht_required_member_fields [] def member_fields, do: @member_fields @doc """ 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 diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 6ec8c8c..f41e962 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -166,12 +166,12 @@ defmodule Mv.Vereinfacht.Client do end @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 - 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. + Uses GET /finance-contacts?filter[isExternal]=true&filter[email]=... so the API + returns only matching external contacts. Returns {:ok, contact_id} if a contact + 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 @@ -182,25 +182,17 @@ defmodule Mv.Vereinfacht.Client do 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}" + 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 {: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}} -> {:error, {:http, status, extract_error_message(body)}} @@ -210,48 +202,12 @@ defmodule Mv.Vereinfacht.Client do 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 + defp get_first_contact_id_from_list(%{"data" => [%{"id" => id} | _]}) do + normalize_contact_id(id) 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 get_first_contact_id_from_list(%{"data" => []}), do: nil + defp get_first_contact_id_from_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) diff --git a/test/mv/vereinfacht/client_test.exs b/test/mv/vereinfacht/client_test.exs index d936adc..d326879 100644 --- a/test/mv/vereinfacht/client_test.exs +++ b/test/mv/vereinfacht/client_test.exs @@ -30,6 +30,13 @@ defmodule Mv.Vereinfacht.ClientTest do 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 %{ first_name: "Test",