Compare commits

..

1 commit

Author SHA1 Message Date
fd6301efab
Fix config test: clear vereinfacht_app_url from settings so derived URL is used
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 19:54:45 +01:00
6 changed files with 41 additions and 66 deletions

View file

@ -1300,15 +1300,12 @@ defmodule Mv.Membership.Member do
end
end
# Extracts custom field values from existing member data (update scenario).
# Actor must come from context; no system-actor fallback (per guidelines).
# When no actor is present we skip the load and return empty map.
# Extracts custom field values from existing member data (update scenario)
defp extract_existing_values(member_data, changeset) do
case Map.get(changeset.context, :actor) do
nil ->
%{}
actor =
Map.get(changeset.context, :actor) ||
Mv.Helpers.SystemActor.get_system_actor()
actor ->
opts = Helpers.ash_actor_opts(actor)
case Ash.load(member_data, :custom_field_values, opts) do
@ -1319,7 +1316,6 @@ defmodule Mv.Membership.Member do
%{}
end
end
end
# Extracts value from a CustomFieldValue struct
defp extract_value_from_cfv(cfv, acc) do

View file

@ -242,7 +242,7 @@ defmodule Mv.Membership.Setting do
attribute :vereinfacht_api_key, :string do
allow_nil? true
public? false
public? true
description "Vereinfacht API key (Bearer token)"
sensitive? true
end

View file

@ -134,25 +134,15 @@ 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}"
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) ->
handle_find_contact_page_response(body, page, normalized)
parse_find_by_email_response(body, email)
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}}
@ -162,21 +152,15 @@ defmodule Mv.Vereinfacht.Client do
end
end
defp handle_find_contact_page_response(body, page, normalized) do
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
id when is_binary(id) -> {:ok, id}
nil -> maybe_find_contact_next_page(body, page, normalized)
nil -> {:error, :not_found}
id -> {:ok, id}
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}}
@ -265,28 +249,31 @@ 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"]}, receipt_attrs_allowlist(attrs || %{}))
Map.merge(%{id: id, type: r["type"]}, string_keys_to_atoms(attrs || %{}))
end)
end
defp extract_receipts_from_response(_), do: []
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()
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()

View file

@ -8,7 +8,6 @@ defmodule Mv.Vereinfacht do
- `sync_members_without_contact/0` Bulk sync of members without a contact ID.
"""
require Ash.Query
import Ash.Expr
alias Mv.Vereinfacht.Client
alias Mv.Membership.Member
alias Mv.Helpers.SystemActor
@ -129,9 +128,7 @@ defmodule Mv.Vereinfacht do
query =
Member
|> Ash.Query.filter(
expr(is_nil(^ref(:vereinfacht_contact_id)) or ^ref(:vereinfacht_contact_id) == "")
)
|> Ash.Query.filter(is_nil(vereinfacht_contact_id))
case Ash.read(query, opts) do
{:ok, members} ->

View file

@ -52,7 +52,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<%!-- Vereinfacht: contact info when synced, or warning when API is configured but no contact --%>
<%= if Mv.Config.vereinfacht_configured?() do %>
<%= if @vereinfacht_contact_present do %>
<%= if @member.vereinfacht_contact_id do %>
<div class="mb-4">
<div class="flex flex-col gap-2">
<.link
@ -515,7 +515,6 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:can_create_cycle, can_create_cycle)
|> assign(:can_destroy_cycle, can_destroy_cycle)
|> assign(:can_update_cycle, can_update_cycle)
|> assign(:vereinfacht_contact_present, present_contact_id?(member.vereinfacht_contact_id))
|> assign_new(:interval_warning, fn -> nil end)
|> assign_new(:editing_cycle, fn -> nil end)
|> assign_new(:deleting_cycle, fn -> nil end)
@ -1083,10 +1082,6 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_create_cycle_period(_date, _interval), do: ""
defp present_contact_id?(nil), do: false
defp present_contact_id?(id) when is_binary(id), do: String.trim(id) != ""
defp present_contact_id?(_), do: false
defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail),
do: "HTTP #{status} #{detail}"

View file

@ -2682,7 +2682,7 @@ msgstr "Vereinfacht-Integration"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
msgstr "Vereinfacht ist nicht konfiguriert. Bitte setze API-URL, API-Schlüssel und Vereins-ID."
msgstr "Vereinfacht ist nicht konfiguriert. Bitte API-URL, API-Schlüssel und Vereins-ID setzen."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
@ -2728,7 +2728,7 @@ msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
msgstr "Synchronisiere dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichere das Mitglied erneut, um den Kontakt anzulegen."
msgstr "Synchronisieren Sie dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichern Sie das Mitglied erneut, um den Kontakt anzulegen."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format