From a5a4d6665521432dca13926e1dce58e7f057339c Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:32 +0100 Subject: [PATCH 01/31] feat(vereinfacht): add DB schema, config and setting attributes - Migrations: vereinfacht_contact_id on members, vereinfacht_* on settings - Mv.Config: Vereinfacht ENV/Settings helpers, vereinfacht_configured?, contact_view_url - Setting: vereinfacht_api_url, api_key, club_id --- lib/membership/setting.ex | 30 ++- lib/mv/config.ex | 94 +++++++ ..._add_vereinfacht_contact_id_to_members.exs | 21 ++ ...0260218185541_add_vereinfacht_settings.exs | 25 ++ .../repo/members/20260218185510.json | 234 ++++++++++++++++++ .../repo/settings/20260218185541.json | 140 +++++++++++ 6 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 priv/repo/migrations/20260218185510_add_vereinfacht_contact_id_to_members.exs create mode 100644 priv/repo/migrations/20260218185541_add_vereinfacht_settings.exs create mode 100644 priv/resource_snapshots/repo/members/20260218185510.json create mode 100644 priv/resource_snapshots/repo/settings/20260218185541.json diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index bb7d122..40ef985 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -69,7 +69,10 @@ 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 ] end @@ -81,7 +84,10 @@ 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 ] end @@ -225,6 +231,26 @@ 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 + timestamps() end diff --git a/lib/mv/config.ex b/lib/mv/config.ex index bcbc8d9..f1c7546 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -142,4 +142,98 @@ 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 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 gray out Settings UI). + """ + @spec vereinfacht_env_configured?() :: boolean() + def vereinfacht_env_configured? do + System.get_env("VEREINFACHT_API_URL") != nil or + System.get_env("VEREINFACHT_API_KEY") != nil or + System.get_env("VEREINFACHT_CLUB_ID") != nil + 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 (e.g. in Vereinfacht frontend or API). + + Uses the configured API base URL and appends /finance-contacts/{id}. + Can be extended later with a dedicated frontend URL setting. + """ + @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_api_url() + + if present?(base), + do: base |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}"), + else: nil + 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/priv/repo/migrations/20260218185510_add_vereinfacht_contact_id_to_members.exs b/priv/repo/migrations/20260218185510_add_vereinfacht_contact_id_to_members.exs new file mode 100644 index 0000000..b8bc8b4 --- /dev/null +++ b/priv/repo/migrations/20260218185510_add_vereinfacht_contact_id_to_members.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.AddVereinfachtContactIdToMembers do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:members) do + add :vereinfacht_contact_id, :text + end + end + + def down do + alter table(:members) do + remove :vereinfacht_contact_id + end + end +end diff --git a/priv/repo/migrations/20260218185541_add_vereinfacht_settings.exs b/priv/repo/migrations/20260218185541_add_vereinfacht_settings.exs new file mode 100644 index 0000000..72aac60 --- /dev/null +++ b/priv/repo/migrations/20260218185541_add_vereinfacht_settings.exs @@ -0,0 +1,25 @@ +defmodule Mv.Repo.Migrations.AddVereinfachtSettings do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:settings) do + add :vereinfacht_api_url, :text + add :vereinfacht_api_key, :text + add :vereinfacht_club_id, :text + end + end + + def down do + alter table(:settings) do + remove :vereinfacht_club_id + remove :vereinfacht_api_key + remove :vereinfacht_api_url + end + end +end diff --git a/priv/resource_snapshots/repo/members/20260218185510.json b/priv/resource_snapshots/repo/members/20260218185510.json new file mode 100644 index 0000000..ebfd40d --- /dev/null +++ b/priv/resource_snapshots/repo/members/20260218185510.json @@ -0,0 +1,234 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "first_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "exit_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "street", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "house_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "postal_code", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "search_vector", + "type": "tsvector" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "membership_fee_start_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_contact_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "members_membership_fee_type_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "membership_fee_types" + }, + "scale": null, + "size": null, + "source": "membership_fee_type_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "4DF7F20D4C8D91E229906D6ADF87A4B5EB410672799753012DE4F0F49B470A51", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "members_unique_email_index", + "keys": [ + { + "type": "atom", + "value": "email" + } + ], + "name": "unique_email", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "members" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/settings/20260218185541.json b/priv/resource_snapshots/repo/settings/20260218185541.json new file mode 100644 index 0000000..4334f9a --- /dev/null +++ b/priv/resource_snapshots/repo/settings/20260218185541.json @@ -0,0 +1,140 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "club_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "member_field_visibility", + "type": "map" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "include_joining_cycle", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "default_membership_fee_type_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_api_url", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_api_key", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "vereinfacht_club_id", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "1038A37F021DFC347E325042D613B0359FEB7DAFAE3286CBCEAA940A52B71217", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "settings" +} \ No newline at end of file From a008cf381a381ad341fba62e7c13234ea19a4d78 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:39 +0100 Subject: [PATCH 02/31] feat(vereinfacht): add client, sync flash and SyncContact change - Application: create SyncFlash ETS table on start - Vereinfacht: Client, SyncFlash, sync_member, format_error, sync_members_without_contact - SyncContact change on Member create_member and update_member - Member: attribute vereinfacht_contact_id, internal action set_vereinfacht_contact_id --- lib/membership/member.ex | 32 ++- lib/mv/application.ex | 2 + lib/mv/vereinfacht/changes/sync_contact.ex | 54 +++++ .../sync_linked_member_after_user_change.ex | 64 +++++ lib/mv/vereinfacht/client.ex | 222 ++++++++++++++++++ lib/mv/vereinfacht/sync_flash.ex | 44 ++++ lib/mv/vereinfacht/vereinfacht.ex | 134 +++++++++++ 7 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 lib/mv/vereinfacht/changes/sync_contact.ex create mode 100644 lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex create mode 100644 lib/mv/vereinfacht/client.ex create mode 100644 lib/mv/vereinfacht/sync_flash.ex create mode 100644 lib/mv/vereinfacht/vereinfacht.ex diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 476501c..6ab6668 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: allow setting vereinfacht_contact_id (used only by SyncContact change). + policy action(:set_vereinfacht_contact_id) do + description "Allow internal sync to set Vereinfacht contact ID" + authorize_if always() + 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/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/vereinfacht/changes/sync_contact.ex b/lib/mv/vereinfacht/changes/sync_contact.ex new file mode 100644 index 0000000..4ea6cc8 --- /dev/null +++ b/lib/mv/vereinfacht/changes/sync_contact.ex @@ -0,0 +1,54 @@ +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). + """ + use Ash.Resource.Change + + require Logger + + @impl true + def change(changeset, _opts, _context) do + if Mv.Config.vereinfacht_configured?() do + Ash.Changeset.after_transaction(changeset, &sync_after_transaction/2) + else + changeset + end + end + + # 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..e5cb599 --- /dev/null +++ b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex @@ -0,0 +1,64 @@ +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?() do + Ash.Changeset.after_transaction(changeset, &sync_linked_member_after_transaction/2) + else + changeset + end + 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..05eff58 --- /dev/null +++ b/lib/mv/vereinfacht/client.ex @@ -0,0 +1,222 @@ +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 + + defp post_and_parse_contact(url, body, api_key) do + # Req expects body to be iodata (e.g. string); a raw map causes ArgumentError. + encoded_body = Jason.encode!(body) + + case Req.post(url, + body: encoded_body, + headers: headers(api_key), + receive_timeout: 15_000 + ) 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), + receive_timeout: 15_000 + ) 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 """ + 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 + base_url = base_url() + api_key = api_key() + + if is_nil(base_url) or is_nil(api_key) do + {:error, :not_configured} + else + url = + base_url + |> String.trim_trailing("/") + |> then(&"#{&1}/finance-contacts/#{contact_id}") + + case Req.get(url, + headers: headers(api_key), + receive_timeout: 15_000 + ) 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 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") + |> 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..fb062cd --- /dev/null +++ b/lib/mv/vereinfacht/sync_flash.ex @@ -0,0 +1,44 @@ +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 + 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..7ca6d37 --- /dev/null +++ b/lib/mv/vereinfacht/vereinfacht.ex @@ -0,0 +1,134 @@ +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 + case Client.update_contact(member.vereinfacht_contact_id, member) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + else + case Client.create_contact(member) do + {:ok, contact_id} -> + save_contact_id(member, contact_id) + + {:error, reason} -> + {:error, reason} + end + 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 From 32efe380b77a19157ee8a830d49e5b0ac233b025 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:46 +0100 Subject: [PATCH 03/31] feat(vereinfacht): sync linked member after user email/link changes - SyncLinkedMemberAfterUserChange on update, create_user, update_user, admin_set_password, link_oidc_id, register_with_rauthy --- lib/accounts/user.ex | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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) From 5343b78750bc7e37813c34857b1f83c623df0f97 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:51 +0100 Subject: [PATCH 04/31] feat(vereinfacht): Settings UI and bulk sync - GlobalSettingsLive: Vereinfacht section, sync button, last sync result - Test: Vereinfacht Integration section visible --- lib/mv_web/live/global_settings_live.ex | 173 ++++++++++++++++++ .../live/global_settings_live_config_test.exs | 14 ++ 2 files changed, 187 insertions(+) diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index fafc955..fc91b03 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,6 +44,8 @@ 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(:last_vereinfacht_sync_result, nil) |> assign_form() {:ok, socket} @@ -74,6 +79,70 @@ defmodule MvWeb.GlobalSettingsLive do + <%!-- Vereinfacht Integration Section --%> + <.form_section title={gettext("Vereinfacht Integration")}> + <%= if @vereinfacht_env_configured do %> +

+ {gettext( + "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." + )} +

+ <% end %> + <.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save"> +
+ <.input + field={@form[:vereinfacht_api_url]} + type="text" + label={gettext("API URL")} + disabled={@vereinfacht_env_configured} + placeholder={ + if(@vereinfacht_env_configured, + do: gettext("From VEREINFACHT_API_URL"), + else: "https://api.verein.visuel.dev/api/v1" + ) + } + /> + <.input + field={@form[:vereinfacht_api_key]} + type="password" + label={gettext("API Key")} + disabled={@vereinfacht_env_configured} + placeholder={ + if(@vereinfacht_env_configured, do: gettext("From VEREINFACHT_API_KEY"), else: nil) + } + /> + <.input + field={@form[:vereinfacht_club_id]} + type="text" + label={gettext("Club ID")} + disabled={@vereinfacht_env_configured} + placeholder={ + if(@vereinfacht_env_configured, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2") + } + /> +
+ <.button + :if={not @vereinfacht_env_configured} + phx-disable-with={gettext("Saving...")} + variant="primary" + class="mt-2" + > + {gettext("Save Vereinfacht Settings")} + + <.button + :if={Mv.Config.vereinfacht_configured?()} + type="button" + phx-click="sync_vereinfacht_contacts" + phx-disable-with={gettext("Syncing...")} + class="mt-4 btn-outline" + > + {gettext("Sync all members without Vereinfacht contact")} + + <%= if @last_vereinfacht_sync_result do %> + <.vereinfacht_sync_result result={@last_vereinfacht_sync_result} /> + <% end %> + + <%!-- Memberdata Section --%> <.form_section title={gettext("Memberdata")}> <.live_component @@ -100,6 +169,40 @@ defmodule MvWeb.GlobalSettingsLive do assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))} end + @impl true + def handle_event("sync_vereinfacht_contacts", _params, socket) do + case Mv.Vereinfacht.sync_members_without_contact() do + {:ok, %{synced: synced, errors: errors}} -> + errors_with_names = enrich_sync_errors(errors) + result = %{synced: synced, errors: errors_with_names} + + socket = + socket + |> assign(:last_vereinfacht_sync_result, result) + |> put_flash( + :info, + if(errors_with_names == [], + do: gettext("Synced %{count} member(s) to Vereinfacht.", count: synced), + else: + gettext("Synced %{count} member(s). %{error_count} failed.", + count: synced, + error_count: length(errors_with_names) + ) + ) + ) + + {:noreply, socket} + + {:error, :not_configured} -> + {:noreply, + put_flash( + socket, + :error, + gettext("Vereinfacht is not configured. Set API URL, API Key, and Club ID.") + )} + end + end + @impl true def handle_event("save", %{"setting" => setting_params}, socket) do actor = MvWeb.LiveHelpers.current_actor(socket) @@ -213,4 +316,74 @@ defmodule MvWeb.GlobalSettingsLive do assign(socket, form: to_form(form)) end + + defp enrich_sync_errors([]), do: [] + + defp enrich_sync_errors(errors) when is_list(errors) do + name_by_id = fetch_member_names_by_ids(Enum.map(errors, fn {id, _} -> id end)) + + Enum.map(errors, fn {member_id, reason} -> + %{ + member_id: member_id, + member_name: Map.get(name_by_id, member_id) || to_string(member_id), + message: Mv.Vereinfacht.format_error(reason), + detail: extract_vereinfacht_detail(reason) + } + end) + end + + defp fetch_member_names_by_ids(ids) do + actor = Mv.Helpers.SystemActor.get_system_actor() + opts = Mv.Helpers.ash_actor_opts(actor) + query = Ash.Query.filter(Mv.Membership.Member, expr(id in ^ids)) + + case Ash.read(query, opts) do + {:ok, members} -> + Map.new(members, fn m -> {m.id, MvWeb.Helpers.MemberHelpers.display_name(m)} end) + + _ -> + %{} + end + end + + defp extract_vereinfacht_detail({:http, _status, detail}) when is_binary(detail), do: detail + defp extract_vereinfacht_detail(_), do: nil + + defp translate_vereinfacht_message(%{detail: detail}) when is_binary(detail) do + gettext("Vereinfacht: %{detail}", + detail: Gettext.dgettext(MvWeb.Gettext, "default", detail) + ) + end + + defp translate_vereinfacht_message(%{message: message}) do + Gettext.dgettext(MvWeb.Gettext, "default", message) + end + + attr :result, :map, required: true + + defp vereinfacht_sync_result(assigns) do + ~H""" +
+

+ {gettext("Last sync result:")} + {gettext("%{count} synced", count: @result.synced)} + <%= if @result.errors != [] do %> + + {gettext("%{count} failed", count: length(@result.errors))} + + <% end %> +

+ <%= if @result.errors != [] do %> +

{gettext("Failed members:")}

+
    + <%= for err <- @result.errors do %> +
  • + {err.member_name}: {translate_vereinfacht_message(err)} +
  • + <% end %> +
+ <% end %> +
+ """ + end end diff --git a/test/mv_web/live/global_settings_live_config_test.exs b/test/mv_web/live/global_settings_live_config_test.exs index 9ac75fd..106a020 100644 --- a/test/mv_web/live/global_settings_live_config_test.exs +++ b/test/mv_web/live/global_settings_live_config_test.exs @@ -71,4 +71,18 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do end end end + + describe "Vereinfacht Integration section" do + setup %{conn: conn} do + admin_user = Mv.Fixtures.user_with_role_fixture("admin") + conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user) + {:ok, conn: conn} + end + + @tag :ui + test "settings page shows Vereinfacht Integration section", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/settings") + assert html =~ "Vereinfacht" + end + end end From 376086ae0f4c5ea9017d12a7996ea4713469e1dd Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:55 +0100 Subject: [PATCH 05/31] feat(vereinfacht): member form flash and show page - Form: show Vereinfacht sync warning after save via SyncFlash - Show: load API debug response; MembershipFees: contact ID, link, no-contact warning --- lib/mv_web/live/member_live/form.ex | 29 ++++++++ lib/mv_web/live/member_live/show.ex | 16 +++- .../show/membership_fees_component.ex | 74 ++++++++++++++++++- 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index f9588c0..7c138c4 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -319,11 +319,40 @@ defmodule MvWeb.MemberLive.Form do socket = socket |> put_flash(:info, flash_message) + |> maybe_put_vereinfacht_sync_flash(member.id) |> push_navigate(to: return_path(socket.assigns.return_to, member)) {:noreply, socket} end + defp maybe_put_vereinfacht_sync_flash(socket, member_id) do + case Mv.Vereinfacht.SyncFlash.take(to_string(member_id)) do + {:warning, message} -> + put_flash(socket, :warning, translate_vereinfacht_flash(message)) + + {:ok, _message} -> + # Optionally show sync success; for now we keep only the main success message + socket + + nil -> + socket + end + end + + defp translate_vereinfacht_flash(message) when is_binary(message) do + prefix = "Vereinfacht: " + + if String.starts_with?(message, prefix) do + detail = message |> String.trim_leading(prefix) |> String.trim() + + Gettext.dgettext(MvWeb.Gettext, "default", "Vereinfacht: %{detail}", + detail: Gettext.dgettext(MvWeb.Gettext, "default", detail) + ) + else + Gettext.dgettext(MvWeb.Gettext, "default", message) + end + end + defp handle_save_error(socket, form) do # Always show a flash message when save fails # Field-level validation errors are displayed in form fields, but flash provides additional feedback diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 47e8878..93e18b4 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -256,6 +256,7 @@ defmodule MvWeb.MemberLive.Show do id={"membership-fees-#{@member.id}"} member={@member} current_user={@current_user} + vereinfacht_debug_response={@vereinfacht_debug_response} /> <% end %> @@ -264,7 +265,10 @@ defmodule MvWeb.MemberLive.Show do @impl true def mount(_params, _session, socket) do - {:ok, assign(socket, :active_tab, :contact)} + {:ok, + socket + |> assign(:active_tab, :contact) + |> assign(:vereinfacht_debug_response, nil)} end @impl true @@ -316,6 +320,16 @@ defmodule MvWeb.MemberLive.Show do {:noreply, assign(socket, :active_tab, :membership_fees)} end + def handle_event("load_vereinfacht_debug", %{"contact_id" => contact_id}, socket) do + response = + case Mv.Vereinfacht.Client.get_contact(contact_id) do + {:ok, body} -> {:ok, body} + {:error, reason} -> {:error, reason} + end + + {:noreply, assign(socket, :vereinfacht_debug_response, response)} + end + # Flash set in LiveComponent is not shown in parent layout; child sends this to display flash @impl true def handle_info({:put_flash, type, message}, socket) do diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index 0739b5e..ce14317 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -50,6 +50,60 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <% end %> + <%!-- Vereinfacht: contact info when synced, or warning when API is configured but no contact --%> + <%= if Mv.Config.vereinfacht_configured?() do %> + <%= if @member.vereinfacht_contact_id do %> +
+ +
+ + {gettext("Contact ID: %{id}", id: @member.vereinfacht_contact_id)} + + <.link + :if={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)} + href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)} + target="_blank" + rel="noopener noreferrer" + class="link link-primary inline-flex items-center gap-1" + > + {gettext("View contact in Vereinfacht")} + <.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" /> + +
+ {gettext("Debug:")} + +
+ <%= if @vereinfacht_debug_response do %> +
+
<%= format_vereinfacht_debug_response(@vereinfacht_debug_response) %>
+
+ <% end %> +
+
+ <% else %> +
+

+ <.icon name="hero-exclamation-triangle" class="size-5 shrink-0" /> + {gettext("No Vereinfacht contact exists for this member.")} +

+

+ {gettext( + "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." + )} +

+
+ <% end %> + <% end %> + <%!-- Action Buttons (only when user has permission) --%>
<.button @@ -439,7 +493,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign_new(:creating_cycle, fn -> false end) |> assign_new(:create_cycle_date, fn -> nil end) |> assign_new(:create_cycle_error, fn -> nil end) - |> assign_new(:regenerating, fn -> false end)} + |> assign_new(:regenerating, fn -> false end) + |> assign_new(:vereinfacht_debug_response, fn -> nil end)} end @impl true @@ -997,6 +1052,23 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp format_create_cycle_period(_date, _interval), do: "" + defp format_vereinfacht_debug_response({:ok, body}) when is_map(body) do + Jason.encode!(body, pretty: true) + end + + defp format_vereinfacht_debug_response({:error, {:http, status, detail}}) + when is_binary(detail) do + "Error: HTTP #{status} – #{detail}" + end + + defp format_vereinfacht_debug_response({:error, {:http, status, _}}) do + "Error: HTTP #{status}" + end + + defp format_vereinfacht_debug_response({:error, reason}) do + "Error: " <> inspect(reason) + end + # Helper component for section box attr :title, :string, required: true slot :inner_block, required: true From c46365576db88be53645e560f8ea98b824fd4947 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:29:00 +0100 Subject: [PATCH 06/31] feat(vereinfacht): gettext and German translations - POT/PO: Vereinfacht UI and API error message strings --- priv/gettext/de/LC_MESSAGES/default.po | 171 +++++++++++++++++++++++++ priv/gettext/default.pot | 130 +++++++++++++++++++ priv/gettext/en/LC_MESSAGES/default.po | 170 ++++++++++++++++++++++++ 3 files changed, 471 insertions(+) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index b7145f0..c3fbc0c 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2622,3 +2622,174 @@ msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden." #, elixir-autogen, elixir-format, fuzzy msgid "Could not load member list. Please try again." msgstr "Mitgliederliste konnte nicht geladen werden. Bitte versuche es erneut." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API Key" +msgstr "API-Schlüssel" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API URL" +msgstr "API-URL" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Club ID" +msgstr "Vereins-ID" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." +msgstr "Konfiguriert über Umgebungsvariablen (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Die Felder sind schreibgeschützt." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Contact ID: %{id}" +msgstr "Kontakt-ID: %{id}" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_API_KEY" +msgstr "Aus VEREINFACHT_API_KEY" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_API_URL" +msgstr "Aus VEREINFACHT_API_URL" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_CLUB_ID" +msgstr "Aus VEREINFACHT_CLUB_ID" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Save Vereinfacht Settings" +msgstr "Vereinfacht-Einstellungen speichern" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sync all members without Vereinfacht contact" +msgstr "Alle Mitglieder ohne Vereinfacht-Kontakt synchronisieren" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Synced %{count} member(s) to Vereinfacht." +msgstr "%{count} Mitglied(er) mit Vereinfacht synchronisiert." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Syncing..." +msgstr "Synchronisiere..." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht" +msgstr "Vereinfacht" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht Integration" +msgstr "Vereinfacht-Integration" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." +msgstr "Vereinfacht ist nicht konfiguriert. Bitte API-URL, API-Schlüssel und Vereins-ID setzen." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "View contact in Vereinfacht" +msgstr "Kontakt in Vereinfacht anzeigen" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Debug:" +msgstr "Debug:" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Load API response" +msgstr "API-Antwort laden" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "%{count} failed" +msgstr "%{count} fehlgeschlagen" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "%{count} synced" +msgstr "%{count} synchronisiert" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Failed members:" +msgstr "Fehlgeschlagene Mitglieder:" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Last sync result:" +msgstr "Letztes Sync-Ergebnis:" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Synced %{count} member(s). %{error_count} failed." +msgstr "%{count} Mitglied(er) synchronisiert. %{error_count} Fehler." + +# Vereinfacht API error messages (translated for UI) +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht: %{detail}" +msgstr "Vereinfacht: %{detail}" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No Vereinfacht contact exists for this member." +msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." +msgstr "Synchronisieren Sie dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichern Sie das Mitglied erneut, um den Kontakt anzulegen." + +#~ # Vereinfacht API validation messages (Laravel-style, shown when creating/editing members or syncing) +#~ msgid "The address field is required." +#~ msgstr "Das Adressfeld ist erforderlich." + +#~ msgid "The city field is required." +#~ msgstr "Das Ortsfeld ist erforderlich." + +#~ msgid "The city field must be at least 2 characters." +#~ msgstr "Das Ortsfeld muss mindestens 2 Zeichen haben." + +#~ msgid "The country field is required." +#~ msgstr "Das Ländfeld ist erforderlich." + +#~ msgid "The email field is required." +#~ msgstr "Das E-Mail-Feld ist erforderlich." + +#~ msgid "The email field must be a valid email address." +#~ msgstr "Das E-Mail-Feld muss eine gültige E-Mail-Adresse sein." + +#~ msgid "The first name field is required." +#~ msgstr "Das Vornamenfeld ist erforderlich." + +#~ msgid "The first name field must be at least 2 characters." +#~ msgstr "Das Vornamenfeld muss mindestens 2 Zeichen haben." + +#~ msgid "The last name field is required." +#~ msgstr "Das Nachnamenfeld ist erforderlich." + +#~ msgid "The last name field must be at least 2 characters." +#~ msgstr "Das Nachnamenfeld muss mindestens 2 Zeichen haben." + +#~ msgid "The street field is required." +#~ msgstr "Das Straßenfeld ist erforderlich." + +#~ msgid "The zip code field is required." +#~ msgstr "Das Postleitzahlenfeld ist erforderlich." + +#~ msgid "The zip code field must be at least 2 characters." +#~ msgstr "Das Postleitzahlenfeld muss mindestens 2 Zeichen haben." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index c1aafd9..0fa186c 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2623,3 +2623,133 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Could not load member list. Please try again." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API Key" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API URL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Club ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Contact ID: %{id}" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_API_KEY" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_API_URL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_CLUB_ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Save Vereinfacht Settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sync all members without Vereinfacht contact" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Synced %{count} member(s) to Vereinfacht." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Syncing..." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht Integration" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "View contact in Vereinfacht" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Debug:" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Load API response" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "%{count} failed" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "%{count} synced" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Failed members:" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Last sync result:" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Synced %{count} member(s). %{error_count} failed." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht: %{detail}" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No Vereinfacht contact exists for this member." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 12c1666..e1f8ec6 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2623,3 +2623,173 @@ msgstr "" #, elixir-autogen, elixir-format, fuzzy msgid "Could not load member list. Please try again." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API Key" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API URL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Club ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Contact ID: %{id}" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_API_KEY" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_API_URL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_CLUB_ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Save Vereinfacht Settings" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Sync all members without Vereinfacht contact" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Synced %{count} member(s) to Vereinfacht." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Syncing..." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht Integration" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "View contact in Vereinfacht" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Debug:" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Load API response" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "%{count} failed" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "%{count} synced" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Failed members:" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Last sync result:" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Synced %{count} member(s). %{error_count} failed." +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Vereinfacht: %{detail}" +msgstr "Vereinfacht: %{detail}" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No Vereinfacht contact exists for this member." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." +msgstr "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." + +#~ # Vereinfacht API validation messages (Laravel-style, shown when creating/editing members or syncing) +#~ msgid "The address field is required." +#~ msgstr "The address field is required." + +#~ msgid "The city field is required." +#~ msgstr "The city field is required." + +#~ msgid "The city field must be at least 2 characters." +#~ msgstr "The city field must be at least 2 characters." + +#~ msgid "The country field is required." +#~ msgstr "The country field is required." + +#~ msgid "The email field is required." +#~ msgstr "The email field is required." + +#~ msgid "The email field must be a valid email address." +#~ msgstr "The email field must be a valid email address." + +#~ msgid "The first name field is required." +#~ msgstr "The first name field is required." + +#~ msgid "The first name field must be at least 2 characters." +#~ msgstr "The first name field must be at least 2 characters." + +#~ msgid "The last name field is required." +#~ msgstr "The last name field is required." + +#~ msgid "The last name field must be at least 2 characters." +#~ msgstr "The last name field must be at least 2 characters." + +#~ msgid "The street field is required." +#~ msgstr "The street field is required." + +#~ msgid "The zip code field is required." +#~ msgstr "The zip code field is required." + +#~ msgid "The zip code field must be at least 2 characters." +#~ msgstr "The zip code field must be at least 2 characters." From e4e6cfdd47e98d38473b44dd07c6ea852d178c4f Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:29:05 +0100 Subject: [PATCH 07/31] test(vereinfacht): add tests and scope README - Config, Client, SyncContact, Vereinfacht module tests (no real API) - vereinfacht_test_README: document test scope --- test/mv/config_vereinfacht_test.exs | 61 ++++++++++++ .../vereinfacht/changes/sync_contact_test.exs | 92 +++++++++++++++++++ test/mv/vereinfacht/client_test.exs | 50 ++++++++++ test/mv/vereinfacht/vereinfacht_test.exs | 59 ++++++++++++ .../mv/vereinfacht/vereinfacht_test_README.md | 29 ++++++ 5 files changed, 291 insertions(+) create mode 100644 test/mv/config_vereinfacht_test.exs create mode 100644 test/mv/vereinfacht/changes/sync_contact_test.exs create mode 100644 test/mv/vereinfacht/client_test.exs create mode 100644 test/mv/vereinfacht/vereinfacht_test.exs create mode 100644 test/mv/vereinfacht/vereinfacht_test_README.md diff --git a/test/mv/config_vereinfacht_test.exs b/test/mv/config_vereinfacht_test.exs new file mode 100644 index 0000000..08b8104 --- /dev/null +++ b/test/mv/config_vereinfacht_test.exs @@ -0,0 +1,61 @@ +defmodule Mv.ConfigVereinfachtTest do + @moduledoc """ + Tests for Mv.Config Vereinfacht-related helpers. + """ + use Mv.DataCase, async: false + + describe "vereinfacht_env_configured?/0" do + test "returns false when no Vereinfacht ENV variables are set" do + clear_vereinfacht_env() + refute Mv.Config.vereinfacht_env_configured?() + end + + test "returns true when VEREINFACHT_API_URL is set" do + set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com") + assert Mv.Config.vereinfacht_env_configured?() + after + clear_vereinfacht_env() + end + + test "returns true when VEREINFACHT_CLUB_ID is set" do + set_vereinfacht_env("VEREINFACHT_CLUB_ID", "2") + assert Mv.Config.vereinfacht_env_configured?() + after + clear_vereinfacht_env() + end + end + + describe "vereinfacht_configured?/0" do + test "returns false when no config is set" do + clear_vereinfacht_env() + # Settings may have nil for vereinfacht fields + refute Mv.Config.vereinfacht_configured?() + end + end + + describe "vereinfacht_contact_view_url/1" do + test "returns nil when API URL is not configured" do + clear_vereinfacht_env() + assert Mv.Config.vereinfacht_contact_view_url("123") == nil + end + + test "returns URL when API URL is set" do + set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com/api/v1") + + assert Mv.Config.vereinfacht_contact_view_url("42") == + "https://api.example.com/api/v1/finance-contacts/42" + after + clear_vereinfacht_env() + end + end + + defp set_vereinfacht_env(key, value) do + System.put_env(key, value) + end + + defp clear_vereinfacht_env do + System.delete_env("VEREINFACHT_API_URL") + System.delete_env("VEREINFACHT_API_KEY") + System.delete_env("VEREINFACHT_CLUB_ID") + end +end diff --git a/test/mv/vereinfacht/changes/sync_contact_test.exs b/test/mv/vereinfacht/changes/sync_contact_test.exs new file mode 100644 index 0000000..aa102a5 --- /dev/null +++ b/test/mv/vereinfacht/changes/sync_contact_test.exs @@ -0,0 +1,92 @@ +defmodule Mv.Vereinfacht.Changes.SyncContactTest do + @moduledoc """ + Tests for Mv.Vereinfacht.Changes.SyncContact. + + When Vereinfacht is not configured, member create/update should succeed + and vereinfacht_contact_id remains nil. + """ + use Mv.DataCase, async: false + + alias Mv.Membership + + setup do + clear_vereinfacht_env() + :ok + end + + describe "member create when Vereinfacht not configured" do + test "member is created and vereinfacht_contact_id is nil" do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + attrs = %{ + first_name: "Sync", + last_name: "Test", + email: "sync_test_#{System.unique_integer([:positive])}@example.com" + } + + assert {:ok, member} = Membership.create_member(attrs, actor: system_actor) + assert member.vereinfacht_contact_id == nil + end + end + + describe "member update when Vereinfacht not configured" do + test "member is updated and vereinfacht_contact_id is unchanged" do + member = Mv.Fixtures.member_fixture() + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + assert {:ok, updated} = + Membership.update_member(member, %{first_name: "Updated"}, actor: system_actor) + + assert updated.vereinfacht_contact_id == nil + end + end + + describe "when Vereinfacht is configured" do + # Regression: after_transaction callback receives 2 args (changeset, result), not 3. + # If the callback had arity 3, create_member would raise BadArityError. + # Also: Client must send JSON-encoded body (iodata); raw map causes ArgumentError + # when the request is sent. With an unreachable URL we get :econnrefused before + # that, so this test would not catch the iodata bug; a Bypass/stub server would. + test "create_member succeeds and after_transaction runs without error (API may fail)" do + set_vereinfacht_env() + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + attrs = %{ + first_name: "API", + last_name: "Test", + email: "api_test_#{System.unique_integer([:positive])}@example.com" + } + + assert {:ok, member} = Membership.create_member(attrs, actor: system_actor) + assert member.id + # Sync may fail (e.g. connection refused), so contact_id can stay nil + after + clear_vereinfacht_env() + end + + test "update_member succeeds and after_transaction runs without error (API may fail)" do + set_vereinfacht_env() + member = Mv.Fixtures.member_fixture() + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + assert {:ok, updated} = + Membership.update_member(member, %{first_name: "Updated"}, actor: system_actor) + + assert updated.id == member.id + after + clear_vereinfacht_env() + end + end + + defp set_vereinfacht_env do + System.put_env("VEREINFACHT_API_URL", "http://127.0.0.1:1/api/v1") + System.put_env("VEREINFACHT_API_KEY", "test-key") + System.put_env("VEREINFACHT_CLUB_ID", "2") + end + + defp clear_vereinfacht_env do + System.delete_env("VEREINFACHT_API_URL") + System.delete_env("VEREINFACHT_API_KEY") + System.delete_env("VEREINFACHT_CLUB_ID") + end +end diff --git a/test/mv/vereinfacht/client_test.exs b/test/mv/vereinfacht/client_test.exs new file mode 100644 index 0000000..d936adc --- /dev/null +++ b/test/mv/vereinfacht/client_test.exs @@ -0,0 +1,50 @@ +defmodule Mv.Vereinfacht.ClientTest do + @moduledoc """ + Tests for Mv.Vereinfacht.Client. + + Only tests the "not configured" path; no real HTTP calls. Config reads from + ENV first, then from Settings (DB), so we use DataCase so get_settings() is available. + """ + use Mv.DataCase, async: false + + alias Mv.Vereinfacht.Client + + setup do + clear_vereinfacht_env() + :ok + end + + describe "create_contact/1" do + test "returns {:error, :not_configured} when Vereinfacht is not configured" do + member = build_member_struct() + + assert Client.create_contact(member) == {:error, :not_configured} + end + end + + describe "update_contact/2" do + test "returns {:error, :not_configured} when Vereinfacht is not configured" do + member = build_member_struct() + + assert Client.update_contact("123", member) == {:error, :not_configured} + end + end + + defp build_member_struct do + %{ + first_name: "Test", + last_name: "User", + email: "test@example.com", + street: "Street 1", + house_number: "2", + postal_code: "12345", + city: "Berlin" + } + end + + defp clear_vereinfacht_env do + System.delete_env("VEREINFACHT_API_URL") + System.delete_env("VEREINFACHT_API_KEY") + System.delete_env("VEREINFACHT_CLUB_ID") + end +end diff --git a/test/mv/vereinfacht/vereinfacht_test.exs b/test/mv/vereinfacht/vereinfacht_test.exs new file mode 100644 index 0000000..08f73b9 --- /dev/null +++ b/test/mv/vereinfacht/vereinfacht_test.exs @@ -0,0 +1,59 @@ +defmodule Mv.VereinfachtTest do + @moduledoc """ + Tests for Mv.Vereinfacht business logic. + + No real API calls; tests "not configured" path and pure helpers (format_error). + """ + use Mv.DataCase, async: false + + alias Mv.Vereinfacht + + setup do + clear_vereinfacht_env() + :ok + end + + describe "sync_member/1" do + test "returns :ok when Vereinfacht is not configured (no-op)" do + member = Mv.Fixtures.member_fixture() + + assert Vereinfacht.sync_member(member) == :ok + end + end + + describe "sync_members_without_contact/0" do + test "returns {:error, :not_configured} when Vereinfacht is not configured" do + assert Vereinfacht.sync_members_without_contact() == {:error, :not_configured} + end + end + + describe "format_error/1" do + test "formats HTTP error with detail" do + assert Vereinfacht.format_error({:http, 422, "The email field is required."}) == + "Vereinfacht: The email field is required." + end + + test "formats HTTP error without detail" do + assert Vereinfacht.format_error({:http, 500, nil}) == + "Vereinfacht: API error (HTTP 500)." + end + + test "formats request_failed" do + assert Vereinfacht.format_error({:request_failed, %{reason: :econnrefused}}) == + "Vereinfacht: Request failed (e.g. connection error)." + end + + test "formats invalid_response and other terms" do + assert Vereinfacht.format_error({:invalid_response, %{}}) == + "Vereinfacht: Invalid API response." + + assert Vereinfacht.format_error(:timeout) == "Vereinfacht: :timeout" + end + end + + defp clear_vereinfacht_env do + System.delete_env("VEREINFACHT_API_URL") + System.delete_env("VEREINFACHT_API_KEY") + System.delete_env("VEREINFACHT_CLUB_ID") + end +end diff --git a/test/mv/vereinfacht/vereinfacht_test_README.md b/test/mv/vereinfacht/vereinfacht_test_README.md new file mode 100644 index 0000000..993af47 --- /dev/null +++ b/test/mv/vereinfacht/vereinfacht_test_README.md @@ -0,0 +1,29 @@ +# Vereinfacht tests – scope and rationale + +## Constraint: no real API in CI + +Tests do **not** call the real Vereinfacht API or a shared test endpoint. All tests use dummy data and either: + +- Assert behaviour when **Vereinfacht is not configured** (ENV + Settings unset), or +- Run the **full Member/User flow** with a **unreachable URL** (e.g. `http://127.0.0.1:1`) so the HTTP client fails fast (e.g. `:econnrefused`) and we only assert that the application path does not crash. + +## What the tests cover + +| Test file | What it tests | Why it’s enough without an API | +|-----------|----------------|---------------------------------| +| **ConfigVereinfachtTest** | `vereinfacht_env_configured?`, `vereinfacht_configured?`, `vereinfacht_contact_view_url` with ENV set/cleared | Pure config logic; no HTTP. | +| **ClientTest** | `create_contact/1` and `update_contact/2` return `{:error, :not_configured}` when nothing is configured | Ensures the client does not call Req when config is missing. | +| **VereinfachtTest** | `sync_members_without_contact/0` returns `{:error, :not_configured}` when not configured | Ensures bulk sync is a no-op when config is missing. | +| **SyncContactTest** | Member create/update with SyncContact change: not configured → no sync; configured with bad URL → action still succeeds, sync may fail | Ensures the Ash change and after_transaction arity are correct and the action result is not broken by sync failures. | + +## What is *not* tested (and would need a stub or real endpoint) + +- Actual HTTP request shape (body, headers) and response handling (201/200, error codes). +- Persistence of `vereinfacht_contact_id` after a successful create. +- Translation of specific API error payloads into user messages. + +Those would require either a **Bypass** (or similar) stub in front of Req or a dedicated test endpoint; both are out of scope for the current “no real API” setup. + +## Conclusion + +Given the constraint that the API is not called in CI, the tests are **meaningful**: they cover config, “not configured” paths, and integration of SyncContact with Member create/update without crashing. They are **sufficient** for regression safety and refactoring; extending them with a Bypass stub would be an optional next step if we want to assert on request/response shape without hitting the real API. From a23f999eeeb7102dee860e48a050cb7c138afcc9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:48:56 +0100 Subject: [PATCH 08/31] fix(a11y): WCAG 2 AA contrast and keyboard access --- assets/css/app.css | 19 +++++++++++++++++++ lib/mv_web/live/global_settings_live.ex | 4 ++-- .../show/membership_fees_component.ex | 9 +++++++-- priv/gettext/de/LC_MESSAGES/default.po | 5 +++++ priv/gettext/default.pot | 5 +++++ priv/gettext/en/LC_MESSAGES/default.po | 5 +++++ 6 files changed, 43 insertions(+), 4 deletions(-) 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/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index fc91b03..1a7e13b 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -366,9 +366,9 @@ defmodule MvWeb.GlobalSettingsLive do

{gettext("Last sync result:")} - {gettext("%{count} synced", count: @result.synced)} + {gettext("%{count} synced", count: @result.synced)} <%= if @result.errors != [] do %> - + {gettext("%{count} failed", count: length(@result.errors))} <% end %> diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index ce14317..02c9d66 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -66,7 +66,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)} target="_blank" rel="noopener noreferrer" - class="link link-primary inline-flex items-center gap-1" + class="link link-accent underline inline-flex items-center gap-1" > {gettext("View contact in Vereinfacht")} <.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" /> @@ -83,7 +83,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do

<%= if @vereinfacht_debug_response do %> -
+
<%= format_vereinfacht_debug_response(@vereinfacht_debug_response) %>
<% end %> diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index c3fbc0c..b9bc053 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2754,6 +2754,11 @@ msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt." msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgstr "Synchronisieren Sie dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichern Sie das Mitglied erneut, um den Kontakt anzulegen." +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Vereinfacht API response" +msgstr "Vereinfacht" + #~ # Vereinfacht API validation messages (Laravel-style, shown when creating/editing members or syncing) #~ msgid "The address field is required." #~ msgstr "Das Adressfeld ist erforderlich." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 0fa186c..4e103b5 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2753,3 +2753,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht API response" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index e1f8ec6..7cf8ab9 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2754,6 +2754,11 @@ msgstr "" msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgstr "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Vereinfacht API response" +msgstr "" + #~ # Vereinfacht API validation messages (Laravel-style, shown when creating/editing members or syncing) #~ msgid "The address field is required." #~ msgstr "The address field is required." From a94c0c0b1434904d2db7d87e066616e1690c1b92 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 23:27:40 +0100 Subject: [PATCH 09/31] Vereinfacht: sync linked member only when email or member changed Run SyncLinkedMemberAfterUserChange only when email or member relationship changed to avoid unnecessary API calls. --- .../changes/sync_linked_member_after_user_change.ex | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 index e5cb599..cffb079 100644 --- a/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex +++ b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex @@ -17,13 +17,20 @@ defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do @impl true def change(changeset, _opts, _context) do - if Mv.Config.vereinfacht_configured?() 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 -> From 17488a6f42af2cded967abd2c3b1bccd36dd1f56 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:13:47 +0100 Subject: [PATCH 10/31] Add Vereinfacht ENV vars to .env.example VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID with short comment that they override Settings when set. --- .env.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.env.example b/.env.example index d5d35ed..04e9dbd 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,9 @@ 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 From f2bcf68da202509a1645a8042dd668640fc2b361 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:13:53 +0100 Subject: [PATCH 11/31] Config: per-field Vereinfacht ENV helpers vereinfacht_api_url_env_set?, vereinfacht_api_key_env_set?, vereinfacht_club_id_env_set? for read-only Settings fields when set. --- lib/mv/config.ex | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/mv/config.ex b/lib/mv/config.ex index f1c7546..f6f6ec7 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -188,13 +188,35 @@ defmodule Mv.Config do end @doc """ - Returns true if any Vereinfacht ENV variable is set (used to gray out Settings UI). + 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 - System.get_env("VEREINFACHT_API_URL") != nil or - System.get_env("VEREINFACHT_API_KEY") != nil or - System.get_env("VEREINFACHT_CLUB_ID") != nil + 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") + + 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 From e1e0469e41f2385dc5e13890edfdad53807a8755 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:14:01 +0100 Subject: [PATCH 12/31] Global settings: API key redaction and per-field ENV Never put API key in form/DOM; show (set) badge, drop blank on save. Per-field disabled when ENV set; save button only when not all from ENV. --- lib/mv_web/live/global_settings_live.ex | 80 ++++++++++++++++++------- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 1a7e13b..3da4aa6 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -45,12 +45,21 @@ defmodule MvWeb.GlobalSettingsLive do |> 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_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""" @@ -83,9 +92,7 @@ defmodule MvWeb.GlobalSettingsLive do <.form_section title={gettext("Vereinfacht Integration")}> <%= if @vereinfacht_env_configured do %>

- {gettext( - "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." - )} + {gettext("Some values are set via environment variables. Those fields are read-only.")}

<% end %> <.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save"> @@ -94,35 +101,53 @@ defmodule MvWeb.GlobalSettingsLive do field={@form[:vereinfacht_api_url]} type="text" label={gettext("API URL")} - disabled={@vereinfacht_env_configured} + disabled={@vereinfacht_api_url_env_set} placeholder={ - if(@vereinfacht_env_configured, + if(@vereinfacht_api_url_env_set, do: gettext("From VEREINFACHT_API_URL"), else: "https://api.verein.visuel.dev/api/v1" ) } /> - <.input - field={@form[:vereinfacht_api_key]} - type="password" - label={gettext("API Key")} - disabled={@vereinfacht_env_configured} - placeholder={ - if(@vereinfacht_env_configured, do: gettext("From VEREINFACHT_API_KEY"), else: nil) - } - /> +
+ + <.input + field={@form[:vereinfacht_api_key]} + type="password" + label="" + disabled={@vereinfacht_api_key_env_set} + placeholder={ + if(@vereinfacht_api_key_env_set, + do: gettext("From VEREINFACHT_API_KEY"), + else: + if(@vereinfacht_api_key_set, + do: gettext("Leave blank to keep current"), + else: nil + ) + ) + } + /> +
<.input field={@form[:vereinfacht_club_id]} type="text" label={gettext("Club ID")} - disabled={@vereinfacht_env_configured} + disabled={@vereinfacht_club_id_env_set} placeholder={ - if(@vereinfacht_env_configured, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2") + if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2") } />
<.button - :if={not @vereinfacht_env_configured} + :if={ + not (@vereinfacht_api_url_env_set and @vereinfacht_api_key_env_set and + @vereinfacht_club_id_env_set) + } phx-disable-with={gettext("Saving...")} variant="primary" class="mt-2" @@ -206,15 +231,17 @@ defmodule MvWeb.GlobalSettingsLive do @impl true def handle_event("save", %{"setting" => setting_params}, socket) do actor = MvWeb.LiveHelpers.current_actor(socket) + # Never send blank API key so we do not overwrite the stored secret (security) + setting_params_clean = drop_blank_vereinfacht_api_key(setting_params) - case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do + case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do {:ok, _updated_settings} -> - # Reload settings from database to ensure all dependent data is updated {:ok, fresh_settings} = Membership.get_settings() socket = socket |> assign(:settings, fresh_settings) + |> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key)) |> put_flash(:info, gettext("Settings updated successfully")) |> assign_form() @@ -225,6 +252,16 @@ defmodule MvWeb.GlobalSettingsLive do end end + defp drop_blank_vereinfacht_api_key(params) when is_map(params) do + case params do + %{"vereinfacht_api_key" => v} when v in [nil, ""] -> + Map.delete(params, "vereinfacht_api_key") + + _ -> + params + end + end + @impl true def handle_info({:custom_field_saved, _custom_field, action}, socket) do send_update(MvWeb.CustomFieldLive.IndexComponent, @@ -305,9 +342,12 @@ defmodule MvWeb.GlobalSettingsLive do end defp assign_form(%{assigns: %{settings: settings}} = socket) do + # Never put API key into form/DOM to avoid secret leak in source or DevTools + settings_for_form = %{settings | vereinfacht_api_key: nil} + form = AshPhoenix.Form.for_update( - settings, + settings_for_form, :update, api: Membership, as: "setting", From c33199465c448fabb76f8dce01316c65dc99a82d Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:14:03 +0100 Subject: [PATCH 13/31] Gettext: new Vereinfacht UI strings and German translations (set), Leave blank to keep current, env hint; DE msgstr added. --- priv/gettext/de/LC_MESSAGES/default.po | 54 ++++++-------------------- priv/gettext/default.pot | 20 +++++++--- priv/gettext/en/LC_MESSAGES/default.po | 54 ++++++-------------------- 3 files changed, 39 insertions(+), 89 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index b9bc053..0f0bc04 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2638,11 +2638,6 @@ msgstr "API-URL" msgid "Club ID" msgstr "Vereins-ID" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." -msgstr "Konfiguriert über Umgebungsvariablen (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Die Felder sind schreibgeschützt." - #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Contact ID: %{id}" @@ -2759,42 +2754,17 @@ msgstr "Synchronisieren Sie dieses Mitglied unter Einstellungen (Bereich Vereinf msgid "Vereinfacht API response" msgstr "Vereinfacht" -#~ # Vereinfacht API validation messages (Laravel-style, shown when creating/editing members or syncing) -#~ msgid "The address field is required." -#~ msgstr "Das Adressfeld ist erforderlich." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "(set)" +msgstr "(gesetzt)" -#~ msgid "The city field is required." -#~ msgstr "Das Ortsfeld ist erforderlich." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Leave blank to keep current" +msgstr "Leer lassen, um den aktuellen Wert beizubehalten" -#~ msgid "The city field must be at least 2 characters." -#~ msgstr "Das Ortsfeld muss mindestens 2 Zeichen haben." - -#~ msgid "The country field is required." -#~ msgstr "Das Ländfeld ist erforderlich." - -#~ msgid "The email field is required." -#~ msgstr "Das E-Mail-Feld ist erforderlich." - -#~ msgid "The email field must be a valid email address." -#~ msgstr "Das E-Mail-Feld muss eine gültige E-Mail-Adresse sein." - -#~ msgid "The first name field is required." -#~ msgstr "Das Vornamenfeld ist erforderlich." - -#~ msgid "The first name field must be at least 2 characters." -#~ msgstr "Das Vornamenfeld muss mindestens 2 Zeichen haben." - -#~ msgid "The last name field is required." -#~ msgstr "Das Nachnamenfeld ist erforderlich." - -#~ msgid "The last name field must be at least 2 characters." -#~ msgstr "Das Nachnamenfeld muss mindestens 2 Zeichen haben." - -#~ msgid "The street field is required." -#~ msgstr "Das Straßenfeld ist erforderlich." - -#~ msgid "The zip code field is required." -#~ msgstr "Das Postleitzahlenfeld ist erforderlich." - -#~ msgid "The zip code field must be at least 2 characters." -#~ msgstr "Das Postleitzahlenfeld muss mindestens 2 Zeichen haben." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Some values are set via environment variables. Those fields are read-only." +msgstr "Einige Werte werden über Umgebungsvariablen gesetzt. Diese Felder sind schreibgeschützt." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 4e103b5..78b0b73 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2639,11 +2639,6 @@ msgstr "" msgid "Club ID" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." -msgstr "" - #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Contact ID: %{id}" @@ -2758,3 +2753,18 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Vereinfacht API response" msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "(set)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Leave blank to keep current" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Some values are set via environment variables. Those fields are read-only." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 7cf8ab9..82253d8 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2639,11 +2639,6 @@ msgstr "" msgid "Club ID" msgstr "" -#: lib/mv_web/live/global_settings_live.ex -#, elixir-autogen, elixir-format -msgid "Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only." -msgstr "" - #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Contact ID: %{id}" @@ -2759,42 +2754,17 @@ msgstr "Sync this member from Settings (Vereinfacht section) or save the member msgid "Vereinfacht API response" msgstr "" -#~ # Vereinfacht API validation messages (Laravel-style, shown when creating/editing members or syncing) -#~ msgid "The address field is required." -#~ msgstr "The address field is required." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "(set)" +msgstr "" -#~ msgid "The city field is required." -#~ msgstr "The city field is required." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Leave blank to keep current" +msgstr "" -#~ msgid "The city field must be at least 2 characters." -#~ msgstr "The city field must be at least 2 characters." - -#~ msgid "The country field is required." -#~ msgstr "The country field is required." - -#~ msgid "The email field is required." -#~ msgstr "The email field is required." - -#~ msgid "The email field must be a valid email address." -#~ msgstr "The email field must be a valid email address." - -#~ msgid "The first name field is required." -#~ msgstr "The first name field is required." - -#~ msgid "The first name field must be at least 2 characters." -#~ msgstr "The first name field must be at least 2 characters." - -#~ msgid "The last name field is required." -#~ msgstr "The last name field is required." - -#~ msgid "The last name field must be at least 2 characters." -#~ msgstr "The last name field must be at least 2 characters." - -#~ msgid "The street field is required." -#~ msgstr "The street field is required." - -#~ msgid "The zip code field is required." -#~ msgstr "The zip code field is required." - -#~ msgid "The zip code field must be at least 2 characters." -#~ msgstr "The zip code field must be at least 2 characters." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Some values are set via environment variables. Those fields are read-only." +msgstr "" From bc2d91f9e78832b887134703b39d471cd5683f31 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:14:13 +0100 Subject: [PATCH 14/31] Vereinfacht client: find by email in response, no retries in test API does not allow filter[email]; fetch list and match client-side. Disable Req retries in test for fast failure and less log noise. --- lib/mv/vereinfacht/client.ex | 97 ++++++++++++++++++++++++++++++------ 1 file changed, 83 insertions(+), 14 deletions(-) diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 05eff58..72859ac 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -42,15 +42,18 @@ defmodule Mv.Vereinfacht.Client do 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 - # Req expects body to be iodata (e.g. string); a raw map causes ArgumentError. encoded_body = Jason.encode!(body) - case Req.post(url, - body: encoded_body, - headers: headers(api_key), - receive_timeout: 15_000 - ) do + 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}} @@ -95,10 +98,12 @@ defmodule Mv.Vereinfacht.Client do |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}") - case Req.patch(url, - body: encoded_body, - headers: headers(api_key), - receive_timeout: 15_000 + case Req.patch( + url, + [ + body: encoded_body, + headers: headers(api_key) + ] ++ req_http_options() ) do {:ok, %{status: 200, body: _resp_body}} -> {:ok, contact_id} @@ -112,6 +117,73 @@ defmodule Mv.Vereinfacht.Client do 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}} 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). @@ -130,10 +202,7 @@ defmodule Mv.Vereinfacht.Client do |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}") - case Req.get(url, - headers: headers(api_key), - receive_timeout: 15_000 - ) do + case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do {:ok, %{status: 200, body: body}} when is_map(body) -> {:ok, body} From 124857cc9c77e6b90263aaed305886ab37653ab7 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:14:15 +0100 Subject: [PATCH 15/31] Vereinfacht: update existing contact when found by email Before saving contact_id to member, sync current data to the existing contact so Vereinfacht stays up to date. --- lib/mv/vereinfacht/vereinfacht.ex | 46 +++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/lib/mv/vereinfacht/vereinfacht.ex b/lib/mv/vereinfacht/vereinfacht.ex index 7ca6d37..b4b9282 100644 --- a/lib/mv/vereinfacht/vereinfacht.ex +++ b/lib/mv/vereinfacht/vereinfacht.ex @@ -36,21 +36,49 @@ defmodule Mv.Vereinfacht do defp do_sync_member(member) do if present_contact_id?(member.vereinfacht_contact_id) do - case Client.update_contact(member.vereinfacht_contact_id, member) do - {:ok, _} -> :ok - {:error, reason} -> {:error, reason} - end + sync_existing_contact(member) else - case Client.create_contact(member) do - {:ok, contact_id} -> - save_contact_id(member, contact_id) + ensure_contact_then_save(member) + end + end - {:error, reason} -> - {:error, reason} + 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) From 02245e66849512e69e35368ee9b36063790731e3 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:53:41 +0100 Subject: [PATCH 16/31] Clear Vereinfacht ENV in test_helper so tests never hit real API --- test/test_helper.exs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/test_helper.exs b/test/test_helper.exs index c6cb1b8..01a0613 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,9 @@ +# Ensure tests never hit the real Vereinfacht API (e.g. when .env is loaded by just). +# Tests that need "configured" sync set a fake URL (127.0.0.1:1) in their own setup. +System.delete_env("VEREINFACHT_API_URL") +System.delete_env("VEREINFACHT_API_KEY") +System.delete_env("VEREINFACHT_CLUB_ID") + ExUnit.start( # shows 10 slowest tests at the end of the test run # slowest: 10 From 7db609deec4773318ea699c94f4a9d730f821adf Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:54:30 +0100 Subject: [PATCH 17/31] Gettext: translate Vereinfacht API validation messages to German --- priv/gettext/de/LC_MESSAGES/default.po | 22 ++++++++++++++++++++++ priv/gettext/default.pot | 22 ++++++++++++++++++++++ priv/gettext/en/LC_MESSAGES/default.po | 22 ++++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 0f0bc04..d16cdb7 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2768,3 +2768,25 @@ msgstr "Leer lassen, um den aktuellen Wert beizubehalten" #, elixir-autogen, elixir-format msgid "Some values are set via environment variables. Those fields are read-only." msgstr "Einige Werte werden über Umgebungsvariablen gesetzt. Diese Felder sind schreibgeschützt." + +# Vereinfacht API validation messages (looked up at runtime via dgettext) +msgid "The address field is required." +msgstr "Das Adressfeld ist erforderlich." + +msgid "The city field is required." +msgstr "Das Stadtfeld ist erforderlich." + +msgid "The email field is required." +msgstr "Das E-Mail-Feld ist erforderlich." + +msgid "The first name field is required." +msgstr "Das Vornamenfeld ist erforderlich." + +msgid "The last name field is required." +msgstr "Das Nachnamenfeld ist erforderlich." + +msgid "The zip code field is required." +msgstr "Das Postleitzahlenfeld ist erforderlich." + +msgid "Too Many Attempts." +msgstr "Zu viele Versuche." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 78b0b73..0f60052 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2768,3 +2768,25 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Some values are set via environment variables. Those fields are read-only." msgstr "" + +# Vereinfacht API validation messages (looked up at runtime via dgettext) +msgid "The address field is required." +msgstr "" + +msgid "The city field is required." +msgstr "" + +msgid "The email field is required." +msgstr "" + +msgid "The first name field is required." +msgstr "" + +msgid "The last name field is required." +msgstr "" + +msgid "The zip code field is required." +msgstr "" + +msgid "Too Many Attempts." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 82253d8..1e43715 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2768,3 +2768,25 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Some values are set via environment variables. Those fields are read-only." msgstr "" + +# Vereinfacht API validation messages (looked up at runtime via dgettext) +msgid "The address field is required." +msgstr "" + +msgid "The city field is required." +msgstr "" + +msgid "The email field is required." +msgstr "" + +msgid "The first name field is required." +msgstr "" + +msgid "The last name field is required." +msgstr "" + +msgid "The zip code field is required." +msgstr "" + +msgid "Too Many Attempts." +msgstr "" From 9d3c72acff5d4dac870e2703a12733dbf7f8d680 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 19:21:09 +0100 Subject: [PATCH 18/31] Add Vereinfacht app URL setting and contact view URL - Setting attribute vereinfacht_app_url, migration, .env.example - Config: vereinfacht_app_url() from env/setting or derived from API URL - Contact view URL uses app URL with /en/admin/finances/contacts/{id} - Global settings: App URL field, read-only when VEREINFACHT_APP_URL set - Tests: update contact view URL expectations --- .env.example | 1 + lib/membership/setting.ex | 13 ++++- lib/mv/config.ex | 54 ++++++++++++++++--- lib/mv_web/live/global_settings_live.ex | 13 +++++ ...20260218190000_add_vereinfacht_app_url.exs | 15 ++++++ test/mv/config_vereinfacht_test.exs | 13 ++++- 6 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 priv/repo/migrations/20260218190000_add_vereinfacht_app_url.exs diff --git a/.env.example b/.env.example index 04e9dbd..c9cc51e 100644 --- a/.env.example +++ b/.env.example @@ -36,3 +36,4 @@ ASSOCIATION_NAME="Sportsclub XYZ" # 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/lib/membership/setting.ex b/lib/membership/setting.ex index 40ef985..33445d3 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -72,7 +72,8 @@ defmodule Mv.Membership.Setting do :default_membership_fee_type_id, :vereinfacht_api_url, :vereinfacht_api_key, - :vereinfacht_club_id + :vereinfacht_club_id, + :vereinfacht_app_url ] end @@ -87,7 +88,8 @@ defmodule Mv.Membership.Setting do :default_membership_fee_type_id, :vereinfacht_api_url, :vereinfacht_api_key, - :vereinfacht_club_id + :vereinfacht_club_id, + :vereinfacht_app_url ] end @@ -251,6 +253,13 @@ defmodule Mv.Membership.Setting do 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/config.ex b/lib/mv/config.ex index f6f6ec7..d2ad66c 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -178,6 +178,37 @@ defmodule Mv.Config 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). """ @@ -211,6 +242,11 @@ defmodule Mv.Config do """ 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 @@ -241,18 +277,22 @@ defmodule Mv.Config do end @doc """ - Returns the URL to view a finance contact (e.g. in Vereinfacht frontend or API). + Returns the URL to view a finance contact in the Vereinfacht app (frontend). - Uses the configured API base URL and appends /finance-contacts/{id}. - Can be extended later with a dedicated frontend URL setting. + 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_api_url() + base = vereinfacht_app_url() - if present?(base), - do: base |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}"), - else: nil + 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 diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 3da4aa6..b841931 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -48,6 +48,7 @@ defmodule MvWeb.GlobalSettingsLive do |> 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() @@ -142,6 +143,18 @@ defmodule MvWeb.GlobalSettingsLive do if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2") } /> + <.input + field={@form[:vereinfacht_app_url]} + type="text" + label={gettext("App URL (contact view link)")} + disabled={@vereinfacht_app_url_env_set} + placeholder={ + if(@vereinfacht_app_url_env_set, + do: gettext("From VEREINFACHT_APP_URL"), + else: "https://app.verein.visuel.dev" + ) + } + />
<.button :if={ diff --git a/priv/repo/migrations/20260218190000_add_vereinfacht_app_url.exs b/priv/repo/migrations/20260218190000_add_vereinfacht_app_url.exs new file mode 100644 index 0000000..f10728c --- /dev/null +++ b/priv/repo/migrations/20260218190000_add_vereinfacht_app_url.exs @@ -0,0 +1,15 @@ +defmodule Mv.Repo.Migrations.AddVereinfachtAppUrl do + use Ecto.Migration + + def up do + alter table(:settings) do + add :vereinfacht_app_url, :text + end + end + + def down do + alter table(:settings) do + remove :vereinfacht_app_url + end + end +end diff --git a/test/mv/config_vereinfacht_test.exs b/test/mv/config_vereinfacht_test.exs index 08b8104..07260c2 100644 --- a/test/mv/config_vereinfacht_test.exs +++ b/test/mv/config_vereinfacht_test.exs @@ -39,14 +39,23 @@ defmodule Mv.ConfigVereinfachtTest do assert Mv.Config.vereinfacht_contact_view_url("123") == nil end - test "returns URL when API URL is set" do + test "returns app contact view URL when API URL is set (derived app URL)" do set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com/api/v1") assert Mv.Config.vereinfacht_contact_view_url("42") == - "https://api.example.com/api/v1/finance-contacts/42" + "https://app.example.com/en/admin/finances/contacts/42" after clear_vereinfacht_env() end + + test "returns app contact view URL when VEREINFACHT_APP_URL is set" do + set_vereinfacht_env("VEREINFACHT_APP_URL", "https://app.verein.visuel.dev") + + assert Mv.Config.vereinfacht_contact_view_url("abc") == + "https://app.verein.visuel.dev/en/admin/finances/contacts/abc" + after + System.delete_env("VEREINFACHT_APP_URL") + end end defp set_vereinfacht_env(key, value) do From 1188320844a8f6b22f34c6f8c64dc45186e73b84 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 19:21:13 +0100 Subject: [PATCH 19/31] Restrict set_vereinfacht_contact_id to system actor - Add ActorIsSystemUser policy check - Member set_vereinfacht_contact_id only allowed for system user --- lib/membership/member.ex | 6 +++--- .../authorization/checks/actor_is_system_user.ex | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 lib/mv/authorization/checks/actor_is_system_user.ex diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 6ab6668..76ed471 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -333,10 +333,10 @@ defmodule Mv.Membership.Member do authorize_if Mv.Authorization.Checks.HasPermission end - # Internal sync action: allow setting vereinfacht_contact_id (used only by SyncContact change). + # Internal sync action: only SystemActor may set vereinfacht_contact_id (used by SyncContact change). policy action(:set_vereinfacht_contact_id) do - description "Allow internal sync to set Vereinfacht contact ID" - authorize_if always() + 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 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 From 140e4a905485949a0c6b559601a7e78072dc567c Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 19:21:18 +0100 Subject: [PATCH 20/31] SyncContact: only run when relevant attributes changed - Sync on create; on update only when synced attrs changed or no contact_id yet - Reduces unnecessary API calls on unrelated member updates --- lib/mv/vereinfacht/changes/sync_contact.ex | 39 +++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/mv/vereinfacht/changes/sync_contact.ex b/lib/mv/vereinfacht/changes/sync_contact.ex index 4ea6cc8..99875e0 100644 --- a/lib/mv/vereinfacht/changes/sync_contact.ex +++ b/lib/mv/vereinfacht/changes/sync_contact.ex @@ -7,20 +7,57 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do 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?() 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 From 6c22d889a1aa2ec9e3c7097ffbc3422fe499470d Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 19:21:19 +0100 Subject: [PATCH 21/31] Vereinfacht client: receipts API, fetch_contact refactor, isExternal - get_contact_with_receipts(contact_id) with ?include=receipts - fetch_contact/2, build_url_with_params, extract_receipts_from_response - Filter external contacts by isExternal in find_contact_id_by_email - Send isExternal: true in create/update payloads --- lib/mv/vereinfacht/client.ex | 70 +++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 72859ac..2aafc7f 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -163,7 +163,16 @@ defmodule Mv.Vereinfacht.Client do defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do Enum.find_value(list, fn - %{"id" => id, "attributes" => %{"email" => att_email}} when is_binary(att_email) -> + %{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => true}} + when is_binary(att_email) -> + if att_email |> String.trim() |> String.downcase() == normalized do + normalize_contact_id(id) + else + nil + end + + %{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => "true"}} + when is_binary(att_email) -> if att_email |> String.trim() |> String.downcase() == normalized do normalize_contact_id(id) else @@ -191,16 +200,34 @@ defmodule Mv.Vereinfacht.Client do """ @spec get_contact(String.t()) :: {:ok, map()} | {:error, term()} def get_contact(contact_id) when is_binary(contact_id) do + fetch_contact(contact_id, []) + end + + @doc """ + Fetches a finance contact with receipts (GET /finance-contacts/:id?include=receipts). + + Returns {:ok, receipts} where receipts is a list of maps with :id and :attributes + (and optional :type) for each receipt, or {:error, reason}. + """ + @spec get_contact_with_receipts(String.t()) :: {:ok, [map()]} | {:error, term()} + def get_contact_with_receipts(contact_id) when is_binary(contact_id) do + case fetch_contact(contact_id, include: "receipts") do + {:ok, body} -> {:ok, extract_receipts_from_response(body)} + {:error, _} = err -> err + end + end + + defp fetch_contact(contact_id, query_params) do base_url = base_url() api_key = api_key() if is_nil(base_url) or is_nil(api_key) do {:error, :not_configured} else - url = - base_url - |> String.trim_trailing("/") - |> then(&"#{&1}/finance-contacts/#{contact_id}") + path = + base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}") + + url = build_url_with_params(path, query_params) case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do {:ok, %{status: 200, body: body}} when is_map(body) -> @@ -215,6 +242,38 @@ defmodule Mv.Vereinfacht.Client do end end + defp build_url_with_params(base, []), do: base + + defp build_url_with_params(base, include: value) do + sep = if String.contains?(base, "?"), do: "&", else: "?" + base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1) + end + + defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do + included + |> Enum.filter(&match?(%{"type" => "receipts"}, &1)) + |> Enum.map(fn %{"id" => id, "attributes" => attrs} = r -> + Map.merge(%{id: id, type: r["type"]}, string_keys_to_atoms(attrs || %{})) + end) + end + + defp extract_receipts_from_response(_), do: [] + + defp string_keys_to_atoms(map) when is_map(map) do + Map.new(map, fn {k, v} -> {to_atom_key(k), v} end) + end + + defp to_atom_key(k) when is_binary(k) do + try do + String.to_existing_atom(k) + rescue + ArgumentError -> String.to_atom(k) + end + end + + defp to_atom_key(k) when is_atom(k), do: k + defp to_atom_key(k), do: to_atom_key(to_string(k)) + defp base_url, do: Mv.Config.vereinfacht_api_url() defp api_key, do: Mv.Config.vereinfacht_api_key() defp club_id, do: Mv.Config.vereinfacht_club_id() @@ -270,6 +329,7 @@ defmodule Mv.Vereinfacht.Client do |> put_attr("zipCode", member |> Map.get(:postal_code)) |> put_attr("city", member |> Map.get(:city)) |> Map.put("contactType", "person") + |> Map.put("isExternal", true) |> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Map.new() end From ede3df12efa4197e419560859ba77c7616622c30 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 19:21:19 +0100 Subject: [PATCH 22/31] SyncFlash: document :public ETS table option --- lib/mv/vereinfacht/sync_flash.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/mv/vereinfacht/sync_flash.ex b/lib/mv/vereinfacht/sync_flash.ex index fb062cd..874a717 100644 --- a/lib/mv/vereinfacht/sync_flash.ex +++ b/lib/mv/vereinfacht/sync_flash.ex @@ -35,6 +35,8 @@ defmodule Mv.Vereinfacht.SyncFlash do @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 From b60ab3f392774c1fbd97c58a3b095cafa4d6e5b1 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 19:21:24 +0100 Subject: [PATCH 23/31] Member show: Vereinfacht link only, receipts table from API - Show only 'Kontakt in Vereinfacht anzeigen' link (no Contact ID / Debug) - Button loads receipts via get_contact_with_receipts, table with formatted columns --- lib/mv_web/live/member_live/show.ex | 12 +- .../show/membership_fees_component.ex | 192 +++++++++++++++--- 2 files changed, 172 insertions(+), 32 deletions(-) diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 93e18b4..a85bf69 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -256,7 +256,7 @@ defmodule MvWeb.MemberLive.Show do id={"membership-fees-#{@member.id}"} member={@member} current_user={@current_user} - vereinfacht_debug_response={@vereinfacht_debug_response} + vereinfacht_receipts={@vereinfacht_receipts} /> <% end %> @@ -268,7 +268,7 @@ defmodule MvWeb.MemberLive.Show do {:ok, socket |> assign(:active_tab, :contact) - |> assign(:vereinfacht_debug_response, nil)} + |> assign(:vereinfacht_receipts, nil)} end @impl true @@ -320,14 +320,14 @@ defmodule MvWeb.MemberLive.Show do {:noreply, assign(socket, :active_tab, :membership_fees)} end - def handle_event("load_vereinfacht_debug", %{"contact_id" => contact_id}, socket) do + def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do response = - case Mv.Vereinfacht.Client.get_contact(contact_id) do - {:ok, body} -> {:ok, body} + case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do + {:ok, receipts} -> {:ok, receipts} {:error, reason} -> {:error, reason} end - {:noreply, assign(socket, :vereinfacht_debug_response, response)} + {:noreply, assign(socket, :vereinfacht_receipts, response)} end # Flash set in LiveComponent is not shown in parent layout; child sends this to display flash diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index 02c9d66..946f249 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -54,42 +54,67 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <%= if Mv.Config.vereinfacht_configured?() do %> <%= if @member.vereinfacht_contact_id do %>
- -
- - {gettext("Contact ID: %{id}", id: @member.vereinfacht_contact_id)} - +
<.link :if={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)} href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)} target="_blank" rel="noopener noreferrer" - class="link link-accent underline inline-flex items-center gap-1" + class="link link-accent underline inline-flex items-center gap-1 w-fit" > {gettext("View contact in Vereinfacht")} <.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" /> -
- {gettext("Debug:")} +
- <%= if @vereinfacht_debug_response do %> + <%= if @vereinfacht_receipts do %>
-
<%= format_vereinfacht_debug_response(@vereinfacht_debug_response) %>
+ <%= if match?({:ok, _}, @vereinfacht_receipts) do %> + <% {_, receipts} = @vereinfacht_receipts %> + <%= if receipts == [] do %> +

{gettext("No receipts")}

+ <% else %> + <% cols = receipt_display_columns(receipts) %> + + + + <%= for {_key, translated_label} <- cols do %> + + <% end %> + + + + <%= for r <- receipts do %> + + <%= for {col_key, _header_key} <- cols do %> + + <% end %> + + <% end %> + +
{translated_label}
{format_receipt_cell(col_key, r[col_key])}
+ <% end %> + <% else %> + <% {:error, reason} = @vereinfacht_receipts %> +

+ {gettext("Error loading receipts: %{reason}", + reason: format_vereinfacht_error(reason) + )} +

+ <% end %>
<% end %>
@@ -499,7 +524,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign_new(:create_cycle_date, fn -> nil end) |> assign_new(:create_cycle_error, fn -> nil end) |> assign_new(:regenerating, fn -> false end) - |> assign_new(:vereinfacht_debug_response, fn -> nil end)} + |> assign_new(:vereinfacht_receipts, fn -> nil end)} end @impl true @@ -1057,23 +1082,138 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp format_create_cycle_period(_date, _interval), do: "" - defp format_vereinfacht_debug_response({:ok, body}) when is_map(body) do - Jason.encode!(body, pretty: true) + defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail), + do: "HTTP #{status} – #{detail}" + + defp format_vereinfacht_error({:http, status, _}), do: "HTTP #{status}" + defp format_vereinfacht_error(reason), do: inspect(reason) + + # Ordered receipt columns: {api_key, gettext key for header}. Only columns present in data are shown. + @receipt_column_spec [ + {:amount, "Amount"}, + {:bookingDate, "Booking date"}, + {:createdAt, "Created at"}, + {:receiptType, "Receipt type"}, + {:referenceNumber, "Reference number"}, + {:status, "Status"}, + {:updatedAt, "Updated at"} + ] + + defp receipt_display_columns(receipts) when is_list(receipts) do + keys_in_data = receipts |> Enum.flat_map(&Map.keys/1) |> MapSet.new() + + Enum.filter(@receipt_column_spec, fn {key, _} -> MapSet.member?(keys_in_data, key) end) + |> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end) end - defp format_vereinfacht_debug_response({:error, {:http, status, detail}}) - when is_binary(detail) do - "Error: HTTP #{status} – #{detail}" + defp format_receipt_cell(:amount, nil), do: "—" + + defp format_receipt_cell(:amount, val) when is_number(val) do + case Decimal.cast(val) do + {:ok, d} -> MembershipFeeHelpers.format_currency(d) + _ -> to_string(val) + end end - defp format_vereinfacht_debug_response({:error, {:http, status, _}}) do - "Error: HTTP #{status}" + defp format_receipt_cell(:amount, val) when is_binary(val) do + case Decimal.parse(val) do + {d, _} -> MembershipFeeHelpers.format_currency(d) + :error -> val + end end - defp format_vereinfacht_debug_response({:error, reason}) do - "Error: " <> inspect(reason) + defp format_receipt_cell(:amount, val), do: to_string(val) + + defp format_receipt_cell(:status, nil), do: "—" + + defp format_receipt_cell(:status, val) when is_binary(val) do + translate_receipt_status(val) end + defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val)) + + defp format_receipt_cell(:receiptType, nil), do: "—" + + defp format_receipt_cell(:receiptType, val) when is_binary(val) do + translate_receipt_type(val) + end + + defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val)) + + defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt], + do: "—" + + defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do + format_receipt_date(val) + end + + defp format_receipt_cell(_col_key, val) when is_binary(val), do: val + defp format_receipt_cell(_col_key, val) when is_number(val), do: to_string(val) + + defp format_receipt_cell(_col_key, val) when is_boolean(val), + do: if(val, do: gettext("Yes"), else: gettext("No")) + + defp format_receipt_cell(_col_key, %Date{} = d), do: format_receipt_date_short(d) + defp format_receipt_cell(_col_key, val) when is_map(val) or is_list(val), do: Jason.encode!(val) + defp format_receipt_cell(_col_key, val), do: to_string(val) + + defp format_receipt_date(%Date{} = d), do: format_receipt_date_short(d) + + defp format_receipt_date(val) when is_binary(val) do + case parse_receipt_date(val) do + {:ok, d} -> format_receipt_date_short(d) + _ -> val + end + end + + defp format_receipt_date(val), do: to_string(val) + + # Parses ISO date or datetime string to Date (uses first 10 chars for datetime strings) + defp parse_receipt_date(val) when is_binary(val) do + date_str = if String.length(val) >= 10, do: String.slice(val, 0, 10), else: val + Date.from_iso8601(date_str) + end + + # Format as "12. Dez. 2025" (day. abbreviated month. year) with translated month + defp format_receipt_date_short(%Date{day: day, month: month, year: year}) do + "#{day}. #{receipt_month_abbr(month)} #{year}" + end + + defp receipt_month_abbr(1), do: gettext("Jan.") + defp receipt_month_abbr(2), do: gettext("Feb.") + defp receipt_month_abbr(3), do: gettext("Mar.") + defp receipt_month_abbr(4), do: gettext("Apr.") + defp receipt_month_abbr(5), do: gettext("May") + defp receipt_month_abbr(6), do: gettext("Jun.") + defp receipt_month_abbr(7), do: gettext("Jul.") + defp receipt_month_abbr(8), do: gettext("Aug.") + defp receipt_month_abbr(9), do: gettext("Sep.") + defp receipt_month_abbr(10), do: gettext("Oct.") + defp receipt_month_abbr(11), do: gettext("Nov.") + defp receipt_month_abbr(12), do: gettext("Dec.") + defp receipt_month_abbr(_), do: "" + + # Translate API status values for display (extend as API returns more values) + defp translate_receipt_status("paid"), do: gettext("Paid") + defp translate_receipt_status("unpaid"), do: gettext("Unpaid") + defp translate_receipt_status("suspended"), do: gettext("Suspended") + defp translate_receipt_status("open"), do: gettext("Open") + defp translate_receipt_status("cancelled"), do: gettext("Cancelled") + defp translate_receipt_status("draft"), do: gettext("Draft") + defp translate_receipt_status("incompleted"), do: gettext("Incompleted") + defp translate_receipt_status("completed"), do: gettext("Completed") + defp translate_receipt_status("empty"), do: "—" + defp translate_receipt_status(other), do: other + + # Translate API receipt type values (extend as API returns more values) + defp translate_receipt_type("invoice"), do: gettext("Invoice") + defp translate_receipt_type("receipt"), do: gettext("Receipt") + defp translate_receipt_type("credit_note"), do: gettext("Credit note") + defp translate_receipt_type("credit"), do: gettext("Credit") + defp translate_receipt_type("expense"), do: gettext("Expense") + defp translate_receipt_type("income"), do: gettext("Income") + defp translate_receipt_type(other), do: other + # Helper component for section box attr :title, :string, required: true slot :inner_block, required: true From 68e6c74a67092fe72da00ef4898d62140c034e9a Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 19:21:24 +0100 Subject: [PATCH 24/31] Gettext: add DE translations for Vereinfacht receipts and app URL --- priv/gettext/de/LC_MESSAGES/default.po | 171 +++++++++++++++++++++---- priv/gettext/default.pot | 171 +++++++++++++++++++++---- priv/gettext/en/LC_MESSAGES/default.po | 171 +++++++++++++++++++++---- 3 files changed, 438 insertions(+), 75 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index d16cdb7..d42857b 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -200,6 +200,7 @@ msgstr "Straße" #: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "No" @@ -2638,11 +2639,6 @@ msgstr "API-URL" msgid "Club ID" msgstr "Vereins-ID" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Contact ID: %{id}" -msgstr "Kontakt-ID: %{id}" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From VEREINFACHT_API_KEY" @@ -2678,11 +2674,6 @@ msgstr "%{count} Mitglied(er) mit Vereinfacht synchronisiert." msgid "Syncing..." msgstr "Synchronisiere..." -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Vereinfacht" -msgstr "Vereinfacht" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Vereinfacht Integration" @@ -2698,16 +2689,6 @@ msgstr "Vereinfacht ist nicht konfiguriert. Bitte API-URL, API-Schlüssel und Ve msgid "View contact in Vereinfacht" msgstr "Kontakt in Vereinfacht anzeigen" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Debug:" -msgstr "Debug:" - -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Load API response" -msgstr "API-Antwort laden" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "%{count} failed" @@ -2749,11 +2730,6 @@ msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt." msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgstr "Synchronisieren Sie dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichern Sie das Mitglied erneut, um den Kontakt anzulegen." -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Vereinfacht API response" -msgstr "Vereinfacht" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "(set)" @@ -2790,3 +2766,148 @@ msgstr "Das Postleitzahlenfeld ist erforderlich." msgid "Too Many Attempts." msgstr "Zu viele Versuche." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "App URL (contact view link)" +msgstr "App-URL (Link zur Kontaktansicht)" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "From VEREINFACHT_APP_URL" +msgstr "Aus VEREINFACHT_APP_URL" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Error loading receipts: %{reason}" +msgstr "Belege konnten nicht geladen werden: %{reason}" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No receipts" +msgstr "Keine Belege" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Show bookings/receipts from Vereinfacht" +msgstr "Buchungen/Belege aus Vereinfacht anzeigen" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht receipts" +msgstr "Vereinfacht-Belege" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Cancelled" +msgstr "Storniert" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit" +msgstr "Gutschrift" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit note" +msgstr "Gutschrift" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Draft" +msgstr "Entwurf" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Invoice" +msgstr "Rechnung" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Open" +msgstr "Offen" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Receipt" +msgstr "Beleg" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Apr." +msgstr "Apr." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Aug." +msgstr "Aug." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Completed" +msgstr "Abgeschlossen" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Dec." +msgstr "Dez." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Expense" +msgstr "Ausgabe" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Feb." +msgstr "Feb." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Income" +msgstr "Einnahme" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Incompleted" +msgstr "Unvollständig" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jan." +msgstr "Jan." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jul." +msgstr "Jul." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jun." +msgstr "Jun." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Mar." +msgstr "Mär." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "May" +msgstr "Mai" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Nov." +msgstr "Nov." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Oct." +msgstr "Okt." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sep." +msgstr "Sep." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 0f60052..ff466ab 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -201,6 +201,7 @@ msgstr "" #: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "No" @@ -2639,11 +2640,6 @@ msgstr "" msgid "Club ID" msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Contact ID: %{id}" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From VEREINFACHT_API_KEY" @@ -2679,11 +2675,6 @@ msgstr "" msgid "Syncing..." msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Vereinfacht" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Vereinfacht Integration" @@ -2699,16 +2690,6 @@ msgstr "" msgid "View contact in Vereinfacht" msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Debug:" -msgstr "" - -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Load API response" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "%{count} failed" @@ -2749,11 +2730,6 @@ msgstr "" msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Vereinfacht API response" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "(set)" @@ -2790,3 +2766,148 @@ msgstr "" msgid "Too Many Attempts." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "App URL (contact view link)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_APP_URL" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Error loading receipts: %{reason}" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No receipts" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Show bookings/receipts from Vereinfacht" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht receipts" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Cancelled" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit note" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Draft" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Invoice" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Open" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Receipt" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Apr." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Aug." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Completed" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Dec." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Expense" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Feb." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Income" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Incompleted" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jan." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jul." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jun." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Mar." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "May" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Nov." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Oct." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sep." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 1e43715..e5e0181 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -201,6 +201,7 @@ msgstr "" #: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "No" @@ -2639,11 +2640,6 @@ msgstr "" msgid "Club ID" msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Contact ID: %{id}" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "From VEREINFACHT_API_KEY" @@ -2679,11 +2675,6 @@ msgstr "" msgid "Syncing..." msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Vereinfacht" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Vereinfacht Integration" @@ -2699,16 +2690,6 @@ msgstr "" msgid "View contact in Vereinfacht" msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Debug:" -msgstr "" - -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Load API response" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "%{count} failed" @@ -2749,11 +2730,6 @@ msgstr "" msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgstr "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Vereinfacht API response" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "(set)" @@ -2790,3 +2766,148 @@ msgstr "" msgid "Too Many Attempts." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "App URL (contact view link)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "From VEREINFACHT_APP_URL" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Error loading receipts: %{reason}" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No receipts" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Show bookings/receipts from Vereinfacht" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Vereinfacht receipts" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Cancelled" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit note" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Draft" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Invoice" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Open" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Receipt" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Apr." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Aug." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Completed" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Dec." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Expense" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Feb." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Income" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Incompleted" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jan." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jul." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jun." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Mar." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "May" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Nov." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Oct." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sep." +msgstr "" From 482a335d3634aa70e1e143ca9ea00ddc93032e62 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 19:34:55 +0100 Subject: [PATCH 25/31] Fix config test: clear vereinfacht_app_url from settings so derived URL is used --- test/mv/config_vereinfacht_test.exs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/test/mv/config_vereinfacht_test.exs b/test/mv/config_vereinfacht_test.exs index 07260c2..d7a3360 100644 --- a/test/mv/config_vereinfacht_test.exs +++ b/test/mv/config_vereinfacht_test.exs @@ -40,6 +40,8 @@ defmodule Mv.ConfigVereinfachtTest do end test "returns app contact view URL when API URL is set (derived app URL)" do + clear_vereinfacht_env() + clear_vereinfacht_app_url_from_settings() set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com/api/v1") assert Mv.Config.vereinfacht_contact_view_url("42") == @@ -54,7 +56,7 @@ defmodule Mv.ConfigVereinfachtTest do assert Mv.Config.vereinfacht_contact_view_url("abc") == "https://app.verein.visuel.dev/en/admin/finances/contacts/abc" after - System.delete_env("VEREINFACHT_APP_URL") + clear_vereinfacht_env() end end @@ -66,5 +68,16 @@ defmodule Mv.ConfigVereinfachtTest do System.delete_env("VEREINFACHT_API_URL") System.delete_env("VEREINFACHT_API_KEY") System.delete_env("VEREINFACHT_CLUB_ID") + System.delete_env("VEREINFACHT_APP_URL") + end + + defp clear_vereinfacht_app_url_from_settings do + case Mv.Membership.get_settings() do + {:ok, settings} -> + Mv.Membership.update_settings(settings, %{vereinfacht_app_url: nil}) + + _ -> + :ok + end end end From 3cdaa75fc1351513a36b83cd53d062ddac9ce20d Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 20:48:13 +0100 Subject: [PATCH 26/31] Member: remove system-actor fallback in extract_existing_values Per guidelines: actor must come from context. When nil, skip load and return empty map. --- lib/membership/member.ex | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 76ed471..7b70e89 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -1300,20 +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) || - Mv.Helpers.SystemActor.get_system_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 From 1f21afeb72a84589b3983b64849cf3d6737445f3 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 20:48:14 +0100 Subject: [PATCH 27/31] Setting: vereinfacht_api_key public? false Reduce exposure of API key; keep sensitive? true. --- lib/membership/setting.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 33445d3..f56daa0 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -242,7 +242,7 @@ defmodule Mv.Membership.Setting do attribute :vereinfacht_api_key, :string do allow_nil? true - public? true + public? false description "Vereinfacht API key (Bearer token)" sensitive? true end From 8ffd842c386e4971ab4cadafd0d893ef40f0e808 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 20:48:16 +0100 Subject: [PATCH 28/31] Vereinfacht client: receipt allowlist, find_contact pagination, flatten nesting - Receipt attrs: allowlist only (no String.to_atom on API input / DoS) - find_contact_by_email: paginate through all pages (page[size]=100) - Extract helpers to satisfy Credo max nesting depth --- lib/mv/vereinfacht/client.ex | 61 ++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex index 2aafc7f..58e06a9 100644 --- a/lib/mv/vereinfacht/client.ex +++ b/lib/mv/vereinfacht/client.ex @@ -134,15 +134,25 @@ defmodule Mv.Vereinfacht.Client do end end + @find_contact_page_size 100 + @find_contact_max_pages 100 + defp do_find_contact_by_email(email) do - url = - base_url() - |> String.trim_trailing("/") - |> then(&"#{&1}/finance-contacts") + 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) -> - parse_find_by_email_response(body, email) + handle_find_contact_page_response(body, page, normalized) {:ok, %{status: status, body: body}} -> {:error, {:http, status, extract_error_message(body)}} @@ -152,15 +162,21 @@ defmodule Mv.Vereinfacht.Client do end end - defp parse_find_by_email_response(body, email) do - normalized = String.trim(email) |> String.downcase() - + defp handle_find_contact_page_response(body, page, normalized) do case find_contact_id_by_email_in_list(body, normalized) do - nil -> {:error, :not_found} - id -> {:ok, id} + 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}} @@ -249,31 +265,28 @@ defmodule Mv.Vereinfacht.Client do base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1) end + # Allowlist of receipt attribute keys we expose (avoids String.to_atom on arbitrary API input / DoS). + @receipt_attr_allowlist ~w[amount bookingDate createdAt receiptType referenceNumber status updatedAt]a + defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do included |> Enum.filter(&match?(%{"type" => "receipts"}, &1)) |> Enum.map(fn %{"id" => id, "attributes" => attrs} = r -> - Map.merge(%{id: id, type: r["type"]}, string_keys_to_atoms(attrs || %{})) + Map.merge(%{id: id, type: r["type"]}, receipt_attrs_allowlist(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) + 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 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() From daaa4dc3458ecb7808a704cecabe308b8f5319bd Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 20:48:17 +0100 Subject: [PATCH 29/31] Vereinfacht: filter blank vereinfacht_contact_id in sync_members Include members with empty string; use expr with ref for Ash filter. --- lib/mv/vereinfacht/vereinfacht.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/mv/vereinfacht/vereinfacht.ex b/lib/mv/vereinfacht/vereinfacht.ex index b4b9282..ce8005d 100644 --- a/lib/mv/vereinfacht/vereinfacht.ex +++ b/lib/mv/vereinfacht/vereinfacht.ex @@ -8,6 +8,7 @@ defmodule Mv.Vereinfacht do - `sync_members_without_contact/0` – Bulk sync of members without a contact ID. """ require Ash.Query + import Ash.Expr alias Mv.Vereinfacht.Client alias Mv.Membership.Member alias Mv.Helpers.SystemActor @@ -128,7 +129,9 @@ defmodule Mv.Vereinfacht do query = Member - |> Ash.Query.filter(is_nil(vereinfacht_contact_id)) + |> Ash.Query.filter( + expr(is_nil(^ref(:vereinfacht_contact_id)) or ^ref(:vereinfacht_contact_id) == "") + ) case Ash.read(query, opts) do {:ok, members} -> From d9491dea9cde8b17e2962a523c867c3b270626a2 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 20:48:18 +0100 Subject: [PATCH 30/31] Member show: present? check for vereinfacht_contact_id in UI Use vereinfacht_contact_present assign so empty string is not treated as present. --- .../live/member_live/show/membership_fees_component.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index 946f249..1ce6f77 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -52,7 +52,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <%!-- Vereinfacht: contact info when synced, or warning when API is configured but no contact --%> <%= if Mv.Config.vereinfacht_configured?() do %> - <%= if @member.vereinfacht_contact_id do %> + <%= if @vereinfacht_contact_present do %>
<.link @@ -515,6 +515,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign(:can_create_cycle, can_create_cycle) |> assign(:can_destroy_cycle, can_destroy_cycle) |> assign(:can_update_cycle, can_update_cycle) + |> assign(:vereinfacht_contact_present, present_contact_id?(member.vereinfacht_contact_id)) |> assign_new(:interval_warning, fn -> nil end) |> assign_new(:editing_cycle, fn -> nil end) |> assign_new(:deleting_cycle, fn -> nil end) @@ -1082,6 +1083,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp format_create_cycle_period(_date, _interval), do: "" + defp present_contact_id?(nil), do: false + defp present_contact_id?(id) when is_binary(id), do: String.trim(id) != "" + defp present_contact_id?(_), do: false + defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail), do: "HTTP #{status} – #{detail}" From 0f20e459e9dca0b125720a5890ef028536291ee5 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 23 Feb 2026 20:48:19 +0100 Subject: [PATCH 31/31] Gettext: Vereinfacht strings in du-form (i18n guidelines) --- priv/gettext/de/LC_MESSAGES/default.po | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index d42857b..d39d86b 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2682,7 +2682,7 @@ msgstr "Vereinfacht-Integration" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID." -msgstr "Vereinfacht ist nicht konfiguriert. Bitte API-URL, API-Schlüssel und Vereins-ID setzen." +msgstr "Vereinfacht ist nicht konfiguriert. Bitte setze API-URL, API-Schlüssel und Vereins-ID." #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format @@ -2728,7 +2728,7 @@ msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt." #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." -msgstr "Synchronisieren Sie dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichern Sie das Mitglied erneut, um den Kontakt anzulegen." +msgstr "Synchronisiere dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichere das Mitglied erneut, um den Kontakt anzulegen." #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format