Vereinfacht accounting software API closes #431 #432

Merged
moritz merged 31 commits from feature/vereinfacht_api into main 2026-02-23 21:18:46 +01:00
Showing only changes of commit bc2d91f9e7 - Show all commits

View file

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