From bc2d91f9e78832b887134703b39d471cd5683f31 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:14:13 +0100 Subject: [PATCH] Vereinfacht client: find by email in response, no retries in test API does not allow filter[email]; fetch list and match client-side. Disable Req retries in test for fast failure and less log noise. --- lib/mv/vereinfacht/client.ex | 97 ++++++++++++++++++++++++++++++------ 1 file changed, 83 insertions(+), 14 deletions(-) diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 05eff58..72859ac 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -42,15 +42,18 @@ defmodule Mv.Vereinfacht.Client do 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 - # Req expects body to be iodata (e.g. string); a raw map causes ArgumentError. encoded_body = Jason.encode!(body) - case Req.post(url, - body: encoded_body, - headers: headers(api_key), - receive_timeout: 15_000 - ) do + 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}} @@ -95,10 +98,12 @@ defmodule Mv.Vereinfacht.Client do |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}") - case Req.patch(url, - body: encoded_body, - headers: headers(api_key), - receive_timeout: 15_000 + case Req.patch( + url, + [ + body: encoded_body, + headers: headers(api_key) + ] ++ req_http_options() ) do {:ok, %{status: 200, body: _resp_body}} -> {:ok, contact_id} @@ -112,6 +117,73 @@ defmodule Mv.Vereinfacht.Client do 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 + + defp do_find_contact_by_email(email) do + url = + base_url() + |> String.trim_trailing("/") + |> then(&"#{&1}/finance-contacts") + + case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do + {:ok, %{status: 200, body: body}} when is_map(body) -> + parse_find_by_email_response(body, email) + + {:ok, %{status: status, body: body}} -> + {:error, {:http, status, extract_error_message(body)}} + + {:error, reason} -> + {:error, {:request_failed, reason}} + end + end + + defp parse_find_by_email_response(body, email) do + normalized = String.trim(email) |> String.downcase() + + case find_contact_id_by_email_in_list(body, normalized) do + nil -> {:error, :not_found} + id -> {:ok, id} + end + 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}} 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). @@ -130,10 +202,7 @@ defmodule Mv.Vereinfacht.Client do |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}") - case Req.get(url, - headers: headers(api_key), - receive_timeout: 15_000 - ) do + case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do {:ok, %{status: 200, body: body}} when is_map(body) -> {:ok, body}