From 6c22d889a1aa2ec9e3c7097ffbc3422fe499470d Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 19:21:19 +0100 Subject: [PATCH] Vereinfacht client: receipts API, fetch_contact refactor, isExternal - get_contact_with_receipts(contact_id) with ?include=receipts - fetch_contact/2, build_url_with_params, extract_receipts_from_response - Filter external contacts by isExternal in find_contact_id_by_email - Send isExternal: true in create/update payloads --- lib/mv/vereinfacht/client.ex | 70 +++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 72859ac..2aafc7f 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -163,7 +163,16 @@ defmodule Mv.Vereinfacht.Client do 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) -> + %{"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 @@ -191,16 +200,34 @@ defmodule Mv.Vereinfacht.Client do """ @spec get_contact(String.t()) :: {:ok, map()} | {:error, term()} def get_contact(contact_id) when is_binary(contact_id) do + fetch_contact(contact_id, []) + end + + @doc """ + Fetches a finance contact with receipts (GET /finance-contacts/:id?include=receipts). + + Returns {:ok, receipts} where receipts is a list of maps with :id and :attributes + (and optional :type) for each receipt, or {:error, reason}. + """ + @spec get_contact_with_receipts(String.t()) :: {:ok, [map()]} | {:error, term()} + def get_contact_with_receipts(contact_id) when is_binary(contact_id) do + case fetch_contact(contact_id, include: "receipts") do + {:ok, body} -> {:ok, extract_receipts_from_response(body)} + {:error, _} = err -> err + end + end + + defp fetch_contact(contact_id, query_params) do base_url = base_url() api_key = api_key() if is_nil(base_url) or is_nil(api_key) do {:error, :not_configured} else - url = - base_url - |> String.trim_trailing("/") - |> then(&"#{&1}/finance-contacts/#{contact_id}") + path = + base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}") + + url = build_url_with_params(path, query_params) case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do {:ok, %{status: 200, body: body}} when is_map(body) -> @@ -215,6 +242,38 @@ defmodule Mv.Vereinfacht.Client do end end + defp build_url_with_params(base, []), do: base + + defp build_url_with_params(base, include: value) do + sep = if String.contains?(base, "?"), do: "&", else: "?" + base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1) + end + + 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 || %{})) + 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) + 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() @@ -270,6 +329,7 @@ defmodule Mv.Vereinfacht.Client do |> put_attr("zipCode", member |> Map.get(:postal_code)) |> put_attr("city", member |> Map.get(:city)) |> Map.put("contactType", "person") + |> Map.put("isExternal", true) |> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Map.new() end