Vereinfacht client: receipt allowlist, find_contact pagination, flatten nesting

- Receipt attrs: allowlist only (no String.to_atom on API input / DoS)
- find_contact_by_email: paginate through all pages (page[size]=100)
- Extract helpers to satisfy Credo max nesting depth
This commit is contained in:
Moritz 2026-02-23 20:48:16 +01:00
parent 1f21afeb72
commit 8ffd842c38
Signed by: moritz
GPG key ID: 1020A035E5DD0824

View file

@ -134,15 +134,25 @@ 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
url =
base_url()
|> String.trim_trailing("/")
|> then(&"#{&1}/finance-contacts")
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}"
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)
handle_find_contact_page_response(body, page, normalized)
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}}
@ -152,15 +162,21 @@ defmodule Mv.Vereinfacht.Client do
end
end
defp parse_find_by_email_response(body, email) do
normalized = String.trim(email) |> String.downcase()
defp handle_find_contact_page_response(body, page, normalized) do
case find_contact_id_by_email_in_list(body, normalized) do
nil -> {:error, :not_found}
id -> {:ok, id}
id when is_binary(id) -> {:ok, id}
nil -> maybe_find_contact_next_page(body, page, normalized)
end
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}}
@ -249,31 +265,28 @@ defmodule Mv.Vereinfacht.Client do
base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1)
end
# Allowlist of receipt attribute keys we expose (avoids String.to_atom on arbitrary API input / DoS).
@receipt_attr_allowlist ~w[amount bookingDate createdAt receiptType referenceNumber status updatedAt]a
defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do
included
|> Enum.filter(&match?(%{"type" => "receipts"}, &1))
|> Enum.map(fn %{"id" => id, "attributes" => attrs} = r ->
Map.merge(%{id: id, type: r["type"]}, string_keys_to_atoms(attrs || %{}))
Map.merge(%{id: id, type: r["type"]}, receipt_attrs_allowlist(attrs || %{}))
end)
end
defp extract_receipts_from_response(_), do: []
defp string_keys_to_atoms(map) when is_map(map) do
Map.new(map, fn {k, v} -> {to_atom_key(k), v} end)
defp receipt_attrs_allowlist(attrs) when is_map(attrs) do
Map.new(@receipt_attr_allowlist, fn key ->
str_key = to_string(key)
{key, Map.get(attrs, str_key)}
end)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
end
defp to_atom_key(k) when is_binary(k) do
try do
String.to_existing_atom(k)
rescue
ArgumentError -> String.to_atom(k)
end
end
defp to_atom_key(k) when is_atom(k), do: k
defp to_atom_key(k), do: to_atom_key(to_string(k))
defp base_url, do: Mv.Config.vereinfacht_api_url()
defp api_key, do: Mv.Config.vereinfacht_api_key()
defp club_id, do: Mv.Config.vereinfacht_club_id()