diff --git a/.env.example b/.env.example
index d5d35ed..c9cc51e 100644
--- a/.env.example
+++ b/.env.example
@@ -30,3 +30,10 @@ ASSOCIATION_NAME="Sportsclub XYZ"
# OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list).
# OIDC_ADMIN_GROUP_NAME=admin
# OIDC_GROUPS_CLAIM=groups
+
+# Optional: Vereinfacht accounting integration (finance-contacts sync)
+# If set, these override values from Settings UI; those fields become read-only.
+# VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1
+# VEREINFACHT_API_KEY=your-api-key
+# VEREINFACHT_CLUB_ID=2
+# VEREINFACHT_APP_URL=https://app.verein.visuel.dev
diff --git a/assets/css/app.css b/assets/css/app.css
index 0219e1e..0149c5d 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -99,6 +99,25 @@
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session] { display: contents }
+/* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers.
+ Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline
+ spacing; use inherited values so custom stylesheets can override. */
+[popover] {
+ line-height: inherit;
+ letter-spacing: inherit;
+ word-spacing: inherit;
+}
+
+/* WCAG 2 AA: success/error text on light backgrounds (e.g. base-200). Use instead of
+ text-success/text-error when contrast ratio of theme colors is insufficient. */
+.text-success-aa {
+ color: oklch(0.35 0.12 165);
+}
+
+.text-error-aa {
+ color: oklch(0.45 0.2 25);
+}
+
/* ============================================
Sidebar Base Styles
============================================ */
diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex
index 92b9ef2..9ac7605 100644
--- a/lib/accounts/user.ex
+++ b/lib/accounts/user.ex
@@ -118,6 +118,8 @@ defmodule Mv.Accounts.User do
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
+
+ change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end
create :create_user do
@@ -145,6 +147,8 @@ defmodule Mv.Accounts.User do
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
+
+ change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end
update :update_user do
@@ -178,6 +182,8 @@ defmodule Mv.Accounts.User do
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where any([changing(:email), changing(:member)])
end
+
+ change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end
# Internal update used only by SystemActor/bootstrap and tests to assign role to system user.
@@ -211,6 +217,8 @@ defmodule Mv.Accounts.User do
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
+
+ change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end
# Action to link an OIDC account to an existing password-only user
@@ -248,6 +256,8 @@ defmodule Mv.Accounts.User do
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
+
+ change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end
read :get_by_subject do
@@ -328,6 +338,8 @@ defmodule Mv.Accounts.User do
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
+ change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
+
# Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated
change fn changeset, _ctx ->
user_info = Ash.Changeset.get_argument(changeset, :user_info)
diff --git a/lib/membership/member.ex b/lib/membership/member.ex
index 476501c..7b70e89 100644
--- a/lib/membership/member.ex
+++ b/lib/membership/member.ex
@@ -117,6 +117,9 @@ defmodule Mv.Membership.Member do
# Requires both join_date and membership_fee_type_id to be present
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
+ # Sync member to Vereinfacht as finance contact (if configured)
+ change Mv.Vereinfacht.Changes.SyncContact
+
# Trigger cycle generation after member creation
# Only runs if membership_fee_type_id is set
# Note: Cycle generation runs asynchronously to not block the action,
@@ -190,6 +193,9 @@ defmodule Mv.Membership.Member do
where [changing(:membership_fee_type_id)]
end
+ # Sync member to Vereinfacht as finance contact (if configured)
+ change Mv.Vereinfacht.Changes.SyncContact
+
# Trigger cycle regeneration when membership_fee_type_id changes
# This deletes future unpaid cycles and regenerates them with the new type/amount
# Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
@@ -243,6 +249,13 @@ defmodule Mv.Membership.Member do
end)
end
+ # Internal: set vereinfacht_contact_id after syncing with Vereinfacht API.
+ # Not exposed via code interface; used only by Mv.Vereinfacht.Changes.SyncContact.
+ update :set_vereinfacht_contact_id do
+ require_atomic? false
+ accept [:vereinfacht_contact_id]
+ end
+
# Action to handle fuzzy search on specific fields
read :search do
argument :query, :string, allow_nil?: true
@@ -320,6 +333,12 @@ defmodule Mv.Membership.Member do
authorize_if Mv.Authorization.Checks.HasPermission
end
+ # Internal sync action: only SystemActor may set vereinfacht_contact_id (used by SyncContact change).
+ policy action(:set_vereinfacht_contact_id) do
+ description "Only system actor may set Vereinfacht contact ID"
+ authorize_if Mv.Authorization.Checks.ActorIsSystemUser
+ end
+
# CREATE/UPDATE: Forbid member–user link unless admin, then check permissions
# ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
# HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all.
@@ -593,6 +612,14 @@ defmodule Mv.Membership.Member do
public? true
description "Date from which membership fees should be calculated"
end
+
+ # Vereinfacht accounting software integration: ID of the finance contact synced via API.
+ # Set by Mv.Vereinfacht.Changes.SyncContact; not accepted in create/update actions.
+ attribute :vereinfacht_contact_id, :string do
+ allow_nil? true
+ public? true
+ description "ID of the finance contact in Vereinfacht (set by sync)"
+ end
end
relationships do
@@ -1273,17 +1300,24 @@ defmodule Mv.Membership.Member do
end
end
- # Extracts custom field values from existing member data (update scenario)
+ # 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.
defp extract_existing_values(member_data, changeset) do
- actor = Map.get(changeset.context, :actor)
- opts = Helpers.ash_actor_opts(actor)
-
- case Ash.load(member_data, :custom_field_values, opts) do
- {:ok, %{custom_field_values: existing_values}} ->
- Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
-
- _ ->
+ case Map.get(changeset.context, :actor) do
+ nil ->
%{}
+
+ actor ->
+ opts = Helpers.ash_actor_opts(actor)
+
+ case Ash.load(member_data, :custom_field_values, opts) do
+ {:ok, %{custom_field_values: existing_values}} ->
+ Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
+
+ _ ->
+ %{}
+ end
end
end
diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex
index bb7d122..f56daa0 100644
--- a/lib/membership/setting.ex
+++ b/lib/membership/setting.ex
@@ -69,7 +69,11 @@ defmodule Mv.Membership.Setting do
:club_name,
:member_field_visibility,
:include_joining_cycle,
- :default_membership_fee_type_id
+ :default_membership_fee_type_id,
+ :vereinfacht_api_url,
+ :vereinfacht_api_key,
+ :vereinfacht_club_id,
+ :vereinfacht_app_url
]
end
@@ -81,7 +85,11 @@ defmodule Mv.Membership.Setting do
:club_name,
:member_field_visibility,
:include_joining_cycle,
- :default_membership_fee_type_id
+ :default_membership_fee_type_id,
+ :vereinfacht_api_url,
+ :vereinfacht_api_key,
+ :vereinfacht_club_id,
+ :vereinfacht_app_url
]
end
@@ -225,6 +233,33 @@ defmodule Mv.Membership.Setting do
description "Default membership fee type ID for new members"
end
+ # Vereinfacht accounting software integration (can be overridden by ENV)
+ attribute :vereinfacht_api_url, :string do
+ allow_nil? true
+ public? true
+ description "Vereinfacht API base URL (e.g. https://api.verein.visuel.dev/api/v1)"
+ end
+
+ attribute :vereinfacht_api_key, :string do
+ allow_nil? true
+ public? false
+ description "Vereinfacht API key (Bearer token)"
+ sensitive? true
+ end
+
+ attribute :vereinfacht_club_id, :string do
+ allow_nil? true
+ public? true
+ description "Vereinfacht club ID for multi-tenancy"
+ end
+
+ attribute :vereinfacht_app_url, :string do
+ allow_nil? true
+ public? true
+
+ description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)"
+ end
+
timestamps()
end
diff --git a/lib/mv/application.ex b/lib/mv/application.ex
index ea0c78e..1967ddd 100644
--- a/lib/mv/application.ex
+++ b/lib/mv/application.ex
@@ -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,
diff --git a/lib/mv/authorization/checks/actor_is_system_user.ex b/lib/mv/authorization/checks/actor_is_system_user.ex
new file mode 100644
index 0000000..a614a83
--- /dev/null
+++ b/lib/mv/authorization/checks/actor_is_system_user.ex
@@ -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
diff --git a/lib/mv/config.ex b/lib/mv/config.ex
index bcbc8d9..d2ad66c 100644
--- a/lib/mv/config.ex
+++ b/lib/mv/config.ex
@@ -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
diff --git a/lib/mv/vereinfacht/changes/sync_contact.ex b/lib/mv/vereinfacht/changes/sync_contact.ex
new file mode 100644
index 0000000..99875e0
--- /dev/null
+++ b/lib/mv/vereinfacht/changes/sync_contact.ex
@@ -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
diff --git a/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex
new file mode 100644
index 0000000..cffb079
--- /dev/null
+++ b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex
@@ -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
diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex
new file mode 100644
index 0000000..58e06a9
--- /dev/null
+++ b/lib/mv/vereinfacht/client.ex
@@ -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
diff --git a/lib/mv/vereinfacht/sync_flash.ex b/lib/mv/vereinfacht/sync_flash.ex
new file mode 100644
index 0000000..874a717
--- /dev/null
+++ b/lib/mv/vereinfacht/sync_flash.ex
@@ -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
diff --git a/lib/mv/vereinfacht/vereinfacht.ex b/lib/mv/vereinfacht/vereinfacht.ex
new file mode 100644
index 0000000..ce8005d
--- /dev/null
+++ b/lib/mv/vereinfacht/vereinfacht.ex
@@ -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
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex
index fafc955..b841931 100644
--- a/lib/mv_web/live/global_settings_live.ex
+++ b/lib/mv_web/live/global_settings_live.ex
@@ -23,6 +23,9 @@ defmodule MvWeb.GlobalSettingsLive do
"""
use MvWeb, :live_view
+ require Ash.Query
+ import Ash.Expr
+
alias Mv.Membership
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@@ -41,11 +44,23 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:settings, settings)
|> assign(:active_editing_section, nil)
|> assign(:locale, locale)
+ |> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
+ |> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
+ |> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
+ |> assign(:vereinfacht_club_id_env_set, Mv.Config.vereinfacht_club_id_env_set?())
+ |> assign(:vereinfacht_app_url_env_set, Mv.Config.vereinfacht_app_url_env_set?())
+ |> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key))
+ |> assign(:last_vereinfacht_sync_result, nil)
|> assign_form()
{:ok, socket}
end
+ defp present?(nil), do: false
+ defp present?(""), do: false
+ defp present?(s) when is_binary(s), do: String.trim(s) != ""
+ defp present?(_), do: false
+
@impl true
def render(assigns) do
~H"""
@@ -74,6 +89,98 @@ defmodule MvWeb.GlobalSettingsLive do
+ <%!-- Vereinfacht Integration Section --%>
+ <.form_section title={gettext("Vereinfacht Integration")}>
+ <%= if @vereinfacht_env_configured do %>
+
+ {gettext("Some values are set via environment variables. Those fields are read-only.")}
+