Vereinfacht API: filter-based contact lookup, no extra required fields, country sync, and docs #459

Merged
moritz merged 5 commits from feat/vereinfacht_api into main 2026-03-04 21:15:07 +01:00
3 changed files with 27 additions and 62 deletions
Showing only changes of commit 96ca857e06 - Show all commits

View file

@ -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

View file

@ -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)

View file

@ -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",