Merge branch 'main' into feat/299_plz
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
carla 2026-02-24 10:40:26 +01:00
commit 63040afee7
68 changed files with 4858 additions and 743 deletions

View file

@ -7,6 +7,8 @@ defmodule Mv.Application do
@impl true
def start(_type, _args) do
Mv.Vereinfacht.SyncFlash.create_table!()
children = [
MvWeb.Telemetry,
Mv.Repo,

View file

@ -0,0 +1,15 @@
defmodule Mv.Authorization.Checks.ActorIsSystemUser do
@moduledoc """
Policy check: true only when the actor is the system user (e.g. system@mila.local).
Used to restrict internal actions (e.g. Member.set_vereinfacht_contact_id) so that
only code paths using SystemActor can perform them, not regular admins.
"""
use Ash.Policy.SimpleCheck
@impl true
def describe(_opts), do: "actor is the system user"
@impl true
def match?(actor, _context, _opts), do: Mv.Helpers.SystemActor.system_user?(actor)
end

View file

@ -142,4 +142,160 @@ defmodule Mv.Config do
|> Keyword.get(key, default)
|> parse_and_validate_integer(default)
end
# ---------------------------------------------------------------------------
# Vereinfacht accounting software integration
# ENV variables take priority; fallback to Settings from database.
# ---------------------------------------------------------------------------
@doc """
Returns the Vereinfacht API base URL.
Reads from `VEREINFACHT_API_URL` env first, then from Settings.
"""
@spec vereinfacht_api_url() :: String.t() | nil
def vereinfacht_api_url do
env_or_setting("VEREINFACHT_API_URL", :vereinfacht_api_url)
end
@doc """
Returns the Vereinfacht API key (Bearer token).
Reads from `VEREINFACHT_API_KEY` env first, then from Settings.
"""
@spec vereinfacht_api_key() :: String.t() | nil
def vereinfacht_api_key do
env_or_setting("VEREINFACHT_API_KEY", :vereinfacht_api_key)
end
@doc """
Returns the Vereinfacht club ID for multi-tenancy.
Reads from `VEREINFACHT_CLUB_ID` env first, then from Settings.
"""
@spec vereinfacht_club_id() :: String.t() | nil
def vereinfacht_club_id do
env_or_setting("VEREINFACHT_CLUB_ID", :vereinfacht_club_id)
end
@doc """
Returns the Vereinfacht app base URL for contact view links (frontend, not API).
Reads from `VEREINFACHT_APP_URL` env first, then from Settings.
Used to build links like https://app.verein.visuel.dev/en/admin/finances/contacts/{id}.
If not set, derived from API URL by replacing host \"api.\" with \"app.\" when possible.
"""
@spec vereinfacht_app_url() :: String.t() | nil
def vereinfacht_app_url do
env_or_setting("VEREINFACHT_APP_URL", :vereinfacht_app_url) ||
derive_app_url_from_api_url(vereinfacht_api_url())
end
defp derive_app_url_from_api_url(nil), do: nil
defp derive_app_url_from_api_url(api_url) when is_binary(api_url) do
api_url = String.trim(api_url)
uri = URI.parse(api_url)
host = uri.host || ""
if String.starts_with?(host, "api.") do
app_host = "app." <> String.slice(host, 4..-1//1)
scheme = uri.scheme || "https"
"#{scheme}://#{app_host}"
else
nil
end
end
defp derive_app_url_from_api_url(_), do: nil
@doc """
Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set).
"""
@spec vereinfacht_configured?() :: boolean()
def vereinfacht_configured? do
present?(vereinfacht_api_url()) and present?(vereinfacht_api_key()) and
present?(vereinfacht_club_id())
end
@doc """
Returns true if any Vereinfacht ENV variable is set (used to show hint in Settings UI).
"""
@spec vereinfacht_env_configured?() :: boolean()
def vereinfacht_env_configured? do
vereinfacht_api_url_env_set?() or vereinfacht_api_key_env_set?() or
vereinfacht_club_id_env_set?()
end
@doc """
Returns true if VEREINFACHT_API_URL is set (field is read-only in Settings).
"""
def vereinfacht_api_url_env_set?, do: env_set?("VEREINFACHT_API_URL")
@doc """
Returns true if VEREINFACHT_API_KEY is set (field is read-only in Settings).
"""
def vereinfacht_api_key_env_set?, do: env_set?("VEREINFACHT_API_KEY")
@doc """
Returns true if VEREINFACHT_CLUB_ID is set (field is read-only in Settings).
"""
def vereinfacht_club_id_env_set?, do: env_set?("VEREINFACHT_CLUB_ID")
@doc """
Returns true if VEREINFACHT_APP_URL is set (field is read-only in Settings).
"""
def vereinfacht_app_url_env_set?, do: env_set?("VEREINFACHT_APP_URL")
defp env_set?(key) do
case System.get_env(key) do
nil -> false
v when is_binary(v) -> String.trim(v) != ""
_ -> false
end
end
defp env_or_setting(env_key, setting_key) do
case System.get_env(env_key) do
nil -> get_vereinfacht_from_settings(setting_key)
value -> trim_nil(value)
end
end
defp get_vereinfacht_from_settings(key) do
case Mv.Membership.get_settings() do
{:ok, settings} -> settings |> Map.get(key) |> trim_nil()
{:error, _} -> nil
end
end
defp trim_nil(nil), do: nil
defp trim_nil(s) when is_binary(s) do
t = String.trim(s)
if t == "", do: nil, else: t
end
@doc """
Returns the URL to view a finance contact in the Vereinfacht app (frontend).
Uses the configured app base URL (or derived from API URL) and appends
/en/admin/finances/contacts/{id}. Returns nil if no app URL can be determined.
"""
@spec vereinfacht_contact_view_url(String.t()) :: String.t() | nil
def vereinfacht_contact_view_url(contact_id) when is_binary(contact_id) do
base = vereinfacht_app_url()
if present?(base) do
base
|> String.trim_trailing("/")
|> then(&"#{&1}/en/admin/finances/contacts/#{contact_id}")
else
nil
end
end
defp present?(nil), do: false
defp present?(s) when is_binary(s), do: String.trim(s) != ""
defp present?(_), do: false
end

View file

@ -28,8 +28,26 @@ defmodule Mv.Constants do
@email_validator_checks [:html_input, :pow]
# Member fields that are required when Vereinfacht integration is active (contact sync)
@vereinfacht_required_member_fields [:first_name, :last_name, :street, :postal_code, :city]
def member_fields, do: @member_fields
@doc """
Returns member fields that are always required when Vereinfacht integration is configured.
Used for validation, member form required indicators, and settings UI (checkbox disabled).
"""
def vereinfacht_required_member_fields, do: @vereinfacht_required_member_fields
@doc """
Returns whether the given member field is required by Vereinfacht when integration is active.
"""
def vereinfacht_required_field?(field) when is_atom(field),
do: field in @vereinfacht_required_member_fields
def vereinfacht_required_field?(_), do: false
@doc """
Returns the prefix used for custom field keys in field visibility maps.

View file

@ -16,7 +16,7 @@ defmodule Mv.Membership.MemberExport do
alias MvWeb.MemberLive.Index.MembershipFeeStatus
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["membership_fee_status"]
["membership_fee_status", "groups"]
@computed_export_fields ["membership_fee_status"]
@computed_insert_after "membership_fee_start_date"
@custom_field_prefix Mv.Constants.custom_field_prefix()
@ -323,10 +323,14 @@ defmodule Mv.Membership.MemberExport do
|> Enum.filter(&(&1 in @domain_member_field_strings))
|> order_member_fields_like_table()
# final member_fields list (used for column specs order): table order + computed inserted
# Separate groups from other fields (groups is handled as a special field, not a member field)
groups_field = if "groups" in member_fields, do: ["groups"], else: []
# final member_fields list (used for column specs order): table order + computed inserted + groups
ordered_member_fields =
selectable_member_fields
|> insert_computed_fields_like_table(computed_fields)
|> then(fn fields -> fields ++ groups_field end)
%{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),

View file

@ -132,12 +132,15 @@ defmodule Mv.Membership.MemberExport.Build do
parsed.computed_fields != [] or
"membership_fee_status" in parsed.member_fields
need_groups = "groups" in parsed.member_fields
query =
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_groups(need_groups)
query =
if parsed.selected_ids != [] do
@ -241,16 +244,22 @@ defmodule Mv.Membership.MemberExport.Build do
defp maybe_sort(query, _field, nil), do: {query, false}
defp maybe_sort(query, field, order) when is_binary(field) do
if custom_field_sort?(field) do
{query, true}
else
field_atom = String.to_existing_atom(field)
cond do
field == "groups" ->
# Groups sort → in-memory nach dem Read (wie Tabelle)
{query, true}
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
custom_field_sort?(field) ->
{query, true}
true ->
field_atom = String.to_existing_atom(field)
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
end
rescue
ArgumentError -> {query, false}
@ -260,11 +269,25 @@ defmodule Mv.Membership.MemberExport.Build do
do: []
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
if field == "groups" do
sort_members_by_groups_export(members, order)
else
sort_by_custom_field_value(members, field, order, custom_fields)
end
end
defp sort_by_custom_field_value(members, field, order, custom_fields) do
id_str = String.trim_leading(field, @custom_field_prefix)
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
if is_nil(custom_field), do: members
if is_nil(custom_field) do
members
else
sort_members_with_custom_field(members, custom_field, order)
end
end
defp sort_members_with_custom_field(members, custom_field, order) do
key_fn = fn member ->
cfv = find_cfv(member, custom_field)
raw = if cfv, do: cfv.value, else: nil
@ -277,6 +300,26 @@ defmodule Mv.Membership.MemberExport.Build do
|> Enum.map(fn {m, _} -> m end)
end
defp sort_members_by_groups_export(members, order) do
# Members with groups first, then by first group name alphabetically (min = first by sort order)
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
first_group_name = fn member ->
(member.groups || [])
|> Enum.map(& &1.name)
|> Enum.min(fn -> nil end)
end
members
|> Enum.sort_by(fn member ->
name = first_group_name.(member)
# Nil (no groups) sorts last in asc, first in desc
{name == nil, name || ""}
end)
|> then(fn list ->
if order == "desc", do: Enum.reverse(list), else: list
end)
end
defp find_cfv(member, custom_field) do
(member.custom_field_values || [])
|> Enum.find(fn cfv ->
@ -294,6 +337,13 @@ defmodule Mv.Membership.MemberExport.Build do
MembershipFeeStatus.load_cycles_for_members(query, show_current)
end
defp maybe_load_groups(query, false), do: query
defp maybe_load_groups(query, true) do
# Load groups with id and name only (for export formatting)
Ash.Query.load(query, groups: [:id, :name])
end
defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
@ -343,6 +393,19 @@ defmodule Mv.Membership.MemberExport.Build do
}
end)
groups_col =
if "groups" in parsed.member_fields do
[
%{
key: :groups,
kind: :groups,
label: label_fn.(:groups)
}
]
else
[]
end
custom_cols =
parsed.custom_field_ids
|> Enum.map(fn id ->
@ -361,7 +424,7 @@ defmodule Mv.Membership.MemberExport.Build do
end)
|> Enum.reject(&is_nil/1)
member_cols ++ computed_cols ++ custom_cols
member_cols ++ computed_cols ++ groups_col ++ custom_cols
end
defp build_rows(members, columns, custom_fields_by_id) do
@ -391,6 +454,11 @@ defmodule Mv.Membership.MemberExport.Build do
if is_binary(value), do: value, else: ""
end
defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do
groups = Map.get(member, :groups) || []
format_groups(groups)
end
defp key_to_atom(k) when is_atom(k), do: k
defp key_to_atom(k) when is_binary(k) do
@ -424,6 +492,15 @@ defmodule Mv.Membership.MemberExport.Build do
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp format_member_value(value), do: to_string(value)
defp format_groups([]), do: ""
defp format_groups(groups) when is_list(groups) do
groups
|> Enum.map(fn group -> Map.get(group, :name) || "" end)
|> Enum.reject(&(&1 == ""))
|> Enum.join(", ")
end
defp build_meta(members) do
%{
generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),

View file

@ -59,6 +59,11 @@ defmodule Mv.Membership.MembersCSV do
if is_binary(value), do: value, else: ""
end
defp cell_value(member, %{kind: :groups, key: :groups}) do
groups = Map.get(member, :groups) || []
format_groups(groups)
end
defp key_to_atom(k) when is_atom(k), do: k
defp key_to_atom(k) when is_binary(k) do
@ -97,4 +102,13 @@ defmodule Mv.Membership.MembersCSV do
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp format_member_value(value), do: to_string(value)
defp format_groups([]), do: ""
defp format_groups(groups) when is_list(groups) do
groups
|> Enum.map(fn group -> Map.get(group, :name) || "" end)
|> Enum.reject(&(&1 == ""))
|> Enum.join(", ")
end
end

View file

@ -0,0 +1,91 @@
defmodule Mv.Vereinfacht.Changes.SyncContact do
@moduledoc """
Syncs a member to Vereinfacht as a finance contact after create/update.
- If the member has no `vereinfacht_contact_id`, creates a contact via API and saves the ID.
- If the member already has an ID, updates the contact via API.
Runs in `after_transaction` so the member is persisted first. API failures are logged
but do not block the member operation. Requires Vereinfacht to be configured
(Mv.Config.vereinfacht_configured?/0).
Only runs when relevant data changed: on create always; on update only when
first_name, last_name, email, street, house_number, postal_code, or city changed,
or when the member has no vereinfacht_contact_id yet (to avoid unnecessary API calls).
"""
use Ash.Resource.Change
require Logger
@synced_attributes [
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city
]
@impl true
def change(changeset, _opts, _context) do
if Mv.Config.vereinfacht_configured?() and sync_relevant?(changeset) do
Ash.Changeset.after_transaction(changeset, &sync_after_transaction/2)
else
changeset
end
end
defp sync_relevant?(changeset) do
case changeset.action_type do
:create -> true
:update -> relevant_update?(changeset)
_ -> false
end
end
defp relevant_update?(changeset) do
any_synced_attr_changed? =
Enum.any?(@synced_attributes, &Ash.Changeset.changing_attribute?(changeset, &1))
record = changeset.data
no_contact_id_yet? = record && blank_contact_id?(record.vereinfacht_contact_id)
any_synced_attr_changed? or no_contact_id_yet?
end
defp blank_contact_id?(nil), do: true
defp blank_contact_id?(""), do: true
defp blank_contact_id?(s) when is_binary(s), do: String.trim(s) == ""
defp blank_contact_id?(_), do: false
# Ash calls after_transaction with (changeset, result) only - 2 args.
defp sync_after_transaction(_changeset, {:ok, member}) do
case Mv.Vereinfacht.sync_member(member) do
:ok ->
Mv.Vereinfacht.SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.")
{:ok, member}
{:ok, member_updated} ->
Mv.Vereinfacht.SyncFlash.store(
to_string(member_updated.id),
:ok,
"Synced to Vereinfacht."
)
{:ok, member_updated}
{:error, reason} ->
Logger.warning("Vereinfacht sync failed for member #{member.id}: #{inspect(reason)}")
Mv.Vereinfacht.SyncFlash.store(
to_string(member.id),
:warning,
Mv.Vereinfacht.format_error(reason)
)
{:ok, member}
end
end
defp sync_after_transaction(_changeset, error), do: error
end

View file

@ -0,0 +1,71 @@
defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
@moduledoc """
Syncs the linked Member to Vereinfacht after a User action that may have updated
the member's email via Ecto (e.g. User email change → SyncUserEmailToMember).
Attach to any User action that uses SyncUserEmailToMember. After the transaction
commits, if the user has a linked member and Vereinfacht is configured, syncs
that member to the API. Failures are logged but do not affect the User result.
"""
use Ash.Resource.Change
require Logger
alias Mv.Membership.Member
alias Mv.Membership
alias Mv.Helpers.SystemActor
alias Mv.Helpers
@impl true
def change(changeset, _opts, _context) do
if Mv.Config.vereinfacht_configured?() and relevant_change?(changeset) do
Ash.Changeset.after_transaction(changeset, &sync_linked_member_after_transaction/2)
else
changeset
end
end
# Only sync when something that affects the linked member's data actually changed
# (email sync or member link), to avoid unnecessary API calls on every user update.
defp relevant_change?(changeset) do
Ash.Changeset.changing_attribute?(changeset, :email) or
Ash.Changeset.changing_relationship?(changeset, :member)
end
defp sync_linked_member_after_transaction(_changeset, {:ok, user}) do
case load_linked_member(user) do
nil ->
{:ok, user}
member ->
case Mv.Vereinfacht.sync_member(member) do
:ok ->
{:ok, user}
{:ok, _} ->
{:ok, user}
{:error, reason} ->
Logger.warning(
"Vereinfacht sync failed for member #{member.id} (linked to user #{user.id}): #{inspect(reason)}"
)
{:ok, user}
end
end
end
defp sync_linked_member_after_transaction(_changeset, result), do: result
defp load_linked_member(%{member_id: nil}), do: nil
defp load_linked_member(%{member_id: ""}), do: nil
defp load_linked_member(user) do
actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(actor)
case Ash.get(Member, user.member_id, [domain: Membership] ++ opts) do
{:ok, %Member{} = member} -> member
_ -> nil
end
end
end

View file

@ -0,0 +1,364 @@
defmodule Mv.Vereinfacht.Client do
@moduledoc """
HTTP client for the Vereinfacht accounting software JSON:API.
Creates and updates finance contacts. Uses Bearer token authentication and
requires club ID for multi-tenancy. Configuration via ENV or Settings
(see Mv.Config).
"""
require Logger
@content_type "application/vnd.api+json"
@doc """
Creates a finance contact in Vereinfacht for the given member.
Returns the contact ID on success. Does not update the member record;
the caller (e.g. SyncContact change) must persist `vereinfacht_contact_id`.
## Options
- None; URL, API key, and club ID are read from Mv.Config.
## Examples
iex> create_contact(member)
{:ok, "242"}
iex> create_contact(member)
{:error, {:http, 401, "Unauthenticated."}}
"""
@spec create_contact(struct()) :: {:ok, String.t()} | {:error, term()}
def create_contact(member) do
base_url = base_url()
api_key = api_key()
club_id = club_id()
if is_nil(base_url) or is_nil(api_key) or is_nil(club_id) do
{:error, :not_configured}
else
body = build_create_body(member, club_id)
url = base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts")
post_and_parse_contact(url, body, api_key)
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
encoded_body = Jason.encode!(body)
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}}
id -> {:ok, id}
end
{:ok, %{status: status, body: resp_body}} ->
{:error, {:http, status, extract_error_message(resp_body)}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
@doc """
Updates an existing finance contact in Vereinfacht.
Only sends attributes that are typically synced from the member (name, email,
address fields). Returns the same contact_id on success.
## Examples
iex> update_contact("242", member)
{:ok, "242"}
iex> update_contact("242", member)
{:error, {:http, 404, "Not Found"}}
"""
@spec update_contact(String.t(), struct()) :: {:ok, String.t()} | {:error, term()}
def update_contact(contact_id, member) when is_binary(contact_id) do
base_url = base_url()
api_key = api_key()
if is_nil(base_url) or is_nil(api_key) do
{:error, :not_configured}
else
body = build_update_body(contact_id, member)
encoded_body = Jason.encode!(body)
url =
base_url
|> String.trim_trailing("/")
|> then(&"#{&1}/finance-contacts/#{contact_id}")
case Req.patch(
url,
[
body: encoded_body,
headers: headers(api_key)
] ++ req_http_options()
) do
{:ok, %{status: 200, body: _resp_body}} ->
{:ok, contact_id}
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
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
@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}"
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)
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
defp handle_find_contact_page_response(body, page, normalized) do
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)
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}}
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
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).
Returns the full response body (decoded JSON) for debugging/display.
"""
@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
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) ->
{:ok, body}
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
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
# 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 || %{}))
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()
end
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()
defp headers(api_key) do
[
{"Accept", @content_type},
{"Content-Type", @content_type},
{"Authorization", "Bearer #{api_key}"}
]
end
defp build_create_body(member, club_id) do
attributes = member_to_attributes(member)
%{
"data" => %{
"type" => "finance-contacts",
"attributes" => attributes,
"relationships" => %{
"club" => %{
"data" => %{"type" => "clubs", "id" => club_id}
}
}
}
}
end
defp build_update_body(contact_id, member) do
attributes = member_to_attributes(member)
%{
"data" => %{
"type" => "finance-contacts",
"id" => contact_id,
"attributes" => attributes
}
}
end
defp member_to_attributes(member) do
address =
[member |> Map.get(:street), member |> Map.get(:house_number)]
|> Enum.reject(&is_nil/1)
|> Enum.map_join(" ", &to_string/1)
|> then(fn s -> if s == "", do: nil, else: s end)
%{}
|> put_attr("lastName", member |> Map.get(:last_name))
|> put_attr("firstName", member |> Map.get(:first_name))
|> put_attr("email", member |> Map.get(:email))
|> put_attr("address", address)
|> 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
defp put_attr(acc, _key, nil), do: acc
defp put_attr(acc, key, value), do: Map.put(acc, key, to_string(value))
defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_binary(id), do: id
defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_integer(id),
do: to_string(id)
defp get_contact_id_from_response(_), do: nil
defp extract_error_message(%{"errors" => [%{"detail" => d} | _]}) when is_binary(d), do: d
defp extract_error_message(%{"errors" => [%{"title" => t} | _]}) when is_binary(t), do: t
defp extract_error_message(body) when is_map(body), do: inspect(body)
defp extract_error_message(other), do: inspect(other)
end

View file

@ -0,0 +1,46 @@
defmodule Mv.Vereinfacht.SyncFlash do
@moduledoc """
Short-lived store for Vereinfacht sync results so the UI can show them after save.
The SyncContact change runs in after_transaction and cannot access the LiveView
socket. This module stores a message keyed by member_id; the form LiveView
calls `take/1` after a successful save and displays the message in flash.
"""
@table :vereinfacht_sync_flash
@doc """
Stores a sync result for the given member. Overwrites any previous message.
- `:ok` - Sync succeeded (optional user message).
- `:warning` - Sync failed; message should be shown as a warning.
"""
@spec store(String.t(), :ok | :warning, String.t()) :: :ok
def store(member_id, kind, message) when is_binary(member_id) do
:ets.insert(@table, {member_id, {kind, message}})
:ok
end
@doc """
Takes and removes the stored sync message for the given member.
Returns `{kind, message}` if present, otherwise `nil`.
"""
@spec take(String.t()) :: {:ok | :warning, String.t()} | nil
def take(member_id) when is_binary(member_id) do
case :ets.take(@table, member_id) do
[{^member_id, value}] -> value
[] -> nil
end
end
@doc false
def create_table! do
# :public so any process can write (SyncContact runs in LiveView/Ash transaction process,
# not the process that created the table). :protected would restrict writes to the creating process.
if :ets.whereis(@table) == :undefined do
:ets.new(@table, [:set, :public, :named_table])
end
:ok
end
end

View file

@ -0,0 +1,165 @@
defmodule Mv.Vereinfacht do
@moduledoc """
Business logic for Vereinfacht accounting software integration.
- `sync_member/1` Sync a single member to the API (create or update contact).
Used by Member create/update (SyncContact) and by User actions that update
the linked member's email via Ecto (e.g. user email change).
- `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
alias Mv.Helpers
@doc """
Syncs a single member to Vereinfacht (create or update finance contact).
If the member has no `vereinfacht_contact_id`, creates a contact and updates
the member with the new ID. If they already have an ID, updates the contact.
Uses system actor for any Ash update. Does nothing if Vereinfacht is not configured.
Returns:
- `:ok` Contact was updated.
- `{:ok, member}` Contact was created and member was updated with the new ID.
- `{:error, reason}` API or update failed.
"""
@spec sync_member(struct()) :: :ok | {:ok, struct()} | {:error, term()}
def sync_member(member) do
if Mv.Config.vereinfacht_configured?() do
do_sync_member(member)
else
:ok
end
end
defp do_sync_member(member) do
if present_contact_id?(member.vereinfacht_contact_id) do
sync_existing_contact(member)
else
ensure_contact_then_save(member)
end
end
defp sync_existing_contact(member) do
case Client.update_contact(member.vereinfacht_contact_id, member) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp ensure_contact_then_save(member) do
case get_or_create_contact_id(member) do
{:ok, contact_id} -> save_contact_id(member, contact_id)
{:error, _} = err -> err
end
end
# Before create: find by email to avoid duplicate contacts (idempotency).
# When an existing contact is found, update it with current member data.
defp get_or_create_contact_id(member) do
email = member |> Map.get(:email) |> to_string() |> String.trim()
if email == "" do
Client.create_contact(member)
else
case Client.find_contact_by_email(email) do
{:ok, existing_id} -> update_existing_contact_and_return_id(existing_id, member)
{:error, :not_found} -> Client.create_contact(member)
{:error, _} = err -> err
end
end
end
defp update_existing_contact_and_return_id(contact_id, member) do
case Client.update_contact(contact_id, member) do
{:ok, _} -> {:ok, contact_id}
{:error, _} = err -> err
end
end
defp save_contact_id(member, contact_id) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.update(member, %{vereinfacht_contact_id: contact_id}, [
{:action, :set_vereinfacht_contact_id} | opts
]) do
{:ok, updated} -> {:ok, updated}
{:error, reason} -> {:error, reason}
end
end
defp present_contact_id?(nil), do: false
defp present_contact_id?(""), do: false
defp present_contact_id?(s) when is_binary(s), do: String.trim(s) != ""
defp present_contact_id?(_), do: false
@doc """
Formats an API/request error reason into a short user-facing message.
Used by SyncContact (flash) and GlobalSettingsLive (sync result list).
"""
@spec format_error(term()) :: String.t()
def format_error({:http, _status, detail}) when is_binary(detail), do: "Vereinfacht: " <> detail
def format_error({:http, status, _}), do: "Vereinfacht: API error (HTTP #{status})."
def format_error({:request_failed, _}),
do: "Vereinfacht: Request failed (e.g. connection error)."
def format_error({:invalid_response, _}), do: "Vereinfacht: Invalid API response."
def format_error(other), do: "Vereinfacht: " <> inspect(other)
@doc """
Creates Vereinfacht contacts for all members that do not yet have a
`vereinfacht_contact_id`. Uses system actor for reads and updates.
Returns `{:ok, %{synced: count, errors: list}}` where errors is a list of
`{member_id, reason}`. Does nothing if Vereinfacht is not configured.
"""
@spec sync_members_without_contact() ::
{:ok, %{synced: non_neg_integer(), errors: [{String.t(), term()}]}}
| {:error, :not_configured}
def sync_members_without_contact do
if Mv.Config.vereinfacht_configured?() do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
query =
Member
|> Ash.Query.filter(
expr(is_nil(^ref(:vereinfacht_contact_id)) or ^ref(:vereinfacht_contact_id) == "")
)
case Ash.read(query, opts) do
{:ok, members} ->
do_sync_members(members, opts)
{:error, _} = err ->
err
end
else
{:error, :not_configured}
end
end
defp do_sync_members(members, opts) do
{synced, errors} =
Enum.reduce(members, {0, []}, fn member, {acc_synced, acc_errors} ->
{inc, new_errors} = sync_one_member(member, opts)
{acc_synced + inc, acc_errors ++ new_errors}
end)
{:ok, %{synced: synced, errors: errors}}
end
defp sync_one_member(member, _opts) do
case sync_member(member) do
:ok -> {1, []}
{:ok, _} -> {1, []}
{:error, reason} -> {0, [{member.id, reason}]}
end
end
end