diff --git a/.drone.yml b/.drone.yml
index 2c8d504..9eb78f0 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -84,7 +84,7 @@ steps:
# Fetch dependencies
- mix deps.get
# Run fast tests (excludes slow/performance and UI tests)
- - mix test --exclude slow --exclude ui
+ - mix test --exclude slow --exclude ui --max-cases 2
- name: rebuild-cache
image: drillster/drone-volume-cache
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/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index cc58ca9..439eee8 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -385,6 +385,8 @@ def process_user(user), do: {:ok, perform_action(user)}
### 2.3 Error Handling
+**No silent failures:** When an error path assigns a fallback (e.g. empty list, unchanged assigns), always log the error with enough context (e.g. `inspect(error)`, slug, action) and/or set a user-visible flash. Do not only assign the fallback without logging.
+
**Use Tagged Tuples:**
```elixir
@@ -623,6 +625,10 @@ defmodule MvWeb.MemberLive.Index do
end
```
+**LiveView load budget:** Keep UI-triggered events cheap. `phx-change`, `phx-focus`, and `phx-keydown` must **not** perform database reads by default; work from assigns (e.g. filter in memory) or defer reads to an explicit commit step (e.g. "Add", "Save", "Submit"). Perform DB reads or reloads only on commit events, not on every keystroke or focus. If a read in an event is unavoidable, do at most one deliberate read, document why, and prefer debounce/throttle.
+
+**LiveView size:** When a LiveView accumulates many features and event handlers (CRUD + add/remove + search + keyboard + modals), extract sub-flows into LiveComponents. The parent keeps auth, initial load, and a single reload after child actions; the component owns the sub-flow and notifies the parent when data changes.
+
**Component Design:**
```elixir
@@ -1258,6 +1264,8 @@ end
### 3.12 Internationalization: Gettext
+**German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”.
+
**Define Translations:**
```elixir
@@ -1267,6 +1275,9 @@ gettext("Welcome to Mila")
# With interpolation
gettext("Hello, %{name}!", name: user.name)
+# Plural: always pass count binding when message uses %{count}
+ngettext("Found %{count} member", "Found %{count} members", @count, count: @count)
+
# Domain-specific translations
dgettext("auth", "Sign in with email")
```
@@ -1507,6 +1518,8 @@ defmodule MvWeb.MemberLive.IndexTest do
end
```
+**LiveView test standards:** Prefer selector-based assertions (`has_element?` on `data-testid`, stable IDs, or semantic markers) over free-text matching (`html =~ "..."`) or broad regex. For repeated flows (e.g. "open add member", "search", "select"), use helpers in `test/support/`. If delete is a LiveView event, test that event and assert both UI and data; if delete uses JS `data-confirm` + non-LV submit, cover the real delete path in a context/service test and add at most one smoke test in the UI. Do not assert or document "single request" or "DB-level sort" without measuring (e.g. query count or timing).
+
#### 4.3.5 Component Tests
Test function components:
@@ -1876,6 +1889,8 @@ policies do
end
```
+**Record-based authorization:** When a concrete record is available, use `can?(actor, :action, record)` (e.g. `can?(@current_user, :update, @group)`). Use `can?(actor, :action, Resource)` only when no specific record exists (e.g. "can create any group"). In events: resolve the record from assigns, run `can?`, then mutate; avoid an extra DB read just for a "freshness" check if the assign is already the source of truth.
+
**Actor Handling in LiveViews:**
Always use the `current_actor/1` helper for consistent actor access:
@@ -2707,7 +2722,9 @@ Building accessible applications ensures that all users, including those with di
### 8.2 ARIA Labels and Roles
-**Use ARIA Attributes When Necessary:**
+**Terminology and semantics:** Use the same terms in UI, tests, and docs (e.g. "modal" vs "inline confirmation"). If the implementation is inline, do not call it a modal in tests or docs.
+
+**Use ARIA Attributes When Necessary:** Use `role="status"` only for live regions that present advisory status (e.g. search result count, progress). Do not use it on links, buttons, or purely static labels; use semantic HTML or the correct role for the widget. Attach `phx-debounce` / `phx-throttle` to the element that triggers the event (e.g. input), not to the whole form, unless the intent is form-wide.
```heex
@@ -2931,11 +2948,11 @@ end
**Announce Dynamic Content:**
```heex
-
+
<%= if @searched do %>
- <%= ngettext("Found %{count} member", "Found %{count} members", @count) %>
+ <%= ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) %>
<% end %>
diff --git a/Dockerfile b/Dockerfile
index 7a01d21..57d296f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,25 +7,25 @@
# This file is based on these images:
#
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
-# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20250317-slim - for the release image
+# - https://hub.docker.com/_/debian?tab=tags&page=1&name=trixie-20260202-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages
-# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim
+# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim
#
-ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim"
-ARG RUNNER_IMAGE="debian:bullseye-20250317-slim"
+ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim"
+ARG RUNNER_IMAGE="debian:trixie-20260202-slim"
FROM ${BUILDER_IMAGE} AS builder
# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
- && apt-get clean && rm -f /var/lib/apt/lists/*_*
+ && apt-get clean && rm -f /var/lib/apt/lists/*_*
# prepare build dir
WORKDIR /app
# install hex + rebar
RUN mix local.hex --force && \
- mix local.rebar --force
+ mix local.rebar --force
# set build ENV
ENV MIX_ENV="prod"
@@ -64,7 +64,7 @@ RUN mix release
FROM ${RUNNER_IMAGE}
RUN apt-get update -y && \
- apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
+ apt-get install -y libstdc++6 openssl libncurses6 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# Set the locale
diff --git a/assets/css/app.css b/assets/css/app.css
index b754a08..0149c5d 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -24,7 +24,7 @@
@plugin "../vendor/daisyui-theme" {
name: "dark";
default: false;
- prefersdark: true;
+ prefersdark: false;
color-scheme: "dark";
--color-base-100: oklch(30.33% 0.016 252.42);
--color-base-200: oklch(25.26% 0.014 253.1);
@@ -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/custom_field.ex b/lib/membership/custom_field.ex
index ab4ad60..411e95d 100644
--- a/lib/membership/custom_field.ex
+++ b/lib/membership/custom_field.ex
@@ -10,7 +10,7 @@ defmodule Mv.Membership.CustomField do
## Attributes
- `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday")
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
- - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
+ - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation.
- `description` - Optional human-readable description
- `required` - If true, all members must have this custom field (future feature)
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
@@ -28,6 +28,7 @@ defmodule Mv.Membership.CustomField do
## Constraints
- Name must be unique across all custom fields
- Name maximum length: 100 characters
+ - `value_type` cannot be changed after creation (immutable)
- Deleting a custom field will cascade delete all associated custom field values
## Calculations
@@ -59,7 +60,7 @@ defmodule Mv.Membership.CustomField do
end
actions do
- defaults [:read, :update]
+ defaults [:read]
default_accept [:name, :value_type, :description, :required, :show_in_overview]
create :create do
@@ -68,6 +69,19 @@ defmodule Mv.Membership.CustomField do
validate string_length(:slug, min: 1)
end
+ update :update do
+ accept [:name, :description, :required, :show_in_overview]
+ require_atomic? false
+
+ validate fn changeset, _context ->
+ if Ash.Changeset.changing_attribute?(changeset, :value_type) do
+ {:error, field: :value_type, message: "cannot be changed after creation"}
+ else
+ :ok
+ end
+ end
+ end
+
destroy :destroy_with_values do
primary? true
end
diff --git a/lib/membership/member.ex b/lib/membership/member.ex
index 476501c..76ed471 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
@@ -1275,7 +1302,10 @@ defmodule Mv.Membership.Member do
# Extracts custom field values from existing member data (update scenario)
defp extract_existing_values(member_data, changeset) do
- actor = Map.get(changeset.context, :actor)
+ actor =
+ Map.get(changeset.context, :actor) ||
+ Mv.Helpers.SystemActor.get_system_actor()
+
opts = Helpers.ash_actor_opts(actor)
case Ash.load(member_data, :custom_field_values, opts) do
diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex
index bb7d122..33445d3 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? true
+ 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/membership/member_export.ex b/lib/mv/membership/member_export.ex
index e243d40..b4272b0 100644
--- a/lib/mv/membership/member_export.ex
+++ b/lib/mv/membership/member_export.ex
@@ -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")),
diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex
index ce1e98c..9e0cc7b 100644
--- a/lib/mv/membership/member_export/build.ex
+++ b/lib/mv/membership/member_export/build.ex
@@ -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(),
diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex
index a0fd463..a47af8d 100644
--- a/lib/mv/membership/members_csv.ex
+++ b/lib/mv/membership/members_csv.ex
@@ -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
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..2aafc7f
--- /dev/null
+++ b/lib/mv/vereinfacht/client.ex
@@ -0,0 +1,351 @@
+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
+
+ defp do_find_contact_by_email(email) do
+ 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) ->
+ parse_find_by_email_response(body, email)
+
+ {:ok, %{status: status, body: body}} ->
+ {:error, {:http, status, extract_error_message(body)}}
+
+ {:error, reason} ->
+ {:error, {:request_failed, reason}}
+ end
+ end
+
+ 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
+ nil -> {:error, :not_found}
+ id -> {:ok, id}
+ end
+ 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
+
+ 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()
+
+ 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..b4b9282
--- /dev/null
+++ b/lib/mv/vereinfacht/vereinfacht.ex
@@ -0,0 +1,162 @@
+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
+ 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(is_nil(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/auth_overrides.ex b/lib/mv_web/auth_overrides.ex
index 1367150..b121c4e 100644
--- a/lib/mv_web/auth_overrides.ex
+++ b/lib/mv_web/auth_overrides.ex
@@ -45,4 +45,11 @@ defmodule MvWeb.AuthOverrides do
Gettext.gettext(MvWeb.Gettext, "or")
end)
end
+
+ # Hide AshAuthentication's Flash component since we use flash_group in root layout
+ # This prevents duplicate flash messages
+ override AshAuthentication.Phoenix.Components.Flash do
+ set :message_class_info, "hidden"
+ set :message_class_error, "hidden"
+ end
end
diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex
index 35e73ab..a47fcc7 100644
--- a/lib/mv_web/components/layouts/root.html.heex
+++ b/lib/mv_web/components/layouts/root.html.heex
@@ -15,24 +15,98 @@
+
+ <.flash id="flash-success-root" kind={:success} flash={@flash} />
+ <.flash id="flash-warning-root" kind={:warning} flash={@flash} />
+ <.flash id="flash-info-root" kind={:info} flash={@flash} />
+ <.flash id="flash-error-root" kind={:error} flash={@flash} />
+
+ <.flash
+ id="client-error-root"
+ kind={:error}
+ title={gettext("We can't find the internet")}
+ phx-disconnected={
+ show(".phx-client-error #client-error-root") |> JS.remove_attribute("hidden")
+ }
+ phx-connected={hide("#client-error-root") |> JS.set_attribute({"hidden", ""})}
+ hidden
+ >
+ {gettext("Attempting to reconnect")}
+ <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
+
+
+ <.flash
+ id="server-error-root"
+ kind={:error}
+ title={gettext("Something went wrong!")}
+ phx-disconnected={
+ show(".phx-server-error #server-error-root") |> JS.remove_attribute("hidden")
+ }
+ phx-connected={hide("#server-error-root") |> JS.set_attribute({"hidden", ""})}
+ hidden
+ >
+ {gettext("Attempting to reconnect")}
+ <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
+
+
{@inner_content}