From b775f5f5c484612cabe55b7c2fd37eebbef434d2 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:32 +0100 Subject: [PATCH 01/17] 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 3a61699dd234c19ceb40e83391e43e02fc51a9c7 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:39 +0100 Subject: [PATCH 02/17] 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 9808dba007708b6532a733834ec7340d0856df30 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:46 +0100 Subject: [PATCH 03/17] 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 81bcd2bc4d32826522dc64a9e4d5d4641547efd6 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:51 +0100 Subject: [PATCH 04/17] 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 d0fa3991f724463d9377a563fd2fbfbfa97461aa Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:28:55 +0100 Subject: [PATCH 05/17] 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 5628de7bc698e486614fe4f7a5014a307e56a2c5 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:29:00 +0100 Subject: [PATCH 06/17] feat(vereinfacht): gettext and German translations - POT/PO: Vereinfacht UI and API error message strings --- priv/gettext/de/LC_MESSAGES/default.po | 180 +++++++++++++++++++++++-- priv/gettext/default.pot | 130 ++++++++++++++++++ priv/gettext/en/LC_MESSAGES/default.po | 179 ++++++++++++++++++++++-- 3 files changed, 465 insertions(+), 24 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 6dbb732..ff321b6 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2604,17 +2604,173 @@ msgstr "PDF" msgid "Import" msgstr "Import" -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Export Members (CSV)" -#~ msgstr "Mitglieder exportieren (CSV)" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API Key" +msgstr "API-Schlüssel" -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Export functionality will be available in a future release." -#~ msgstr "Export-Funktionalität ist im nächsten release verfügbar." +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API URL" +msgstr "API-URL" -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Import members from CSV files or export member data." -#~ msgstr "Importiere Mitglieder aus CSV-Dateien oder exportiere Mitgliederdaten." +#: 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 df282f3..70c462d 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2604,3 +2604,133 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Import" 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 56f897d..71cbe28 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2605,17 +2605,172 @@ msgstr "" msgid "Import" msgstr "" -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Export Members (CSV)" -#~ msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API Key" +msgstr "" -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Export functionality will be available in a future release." -#~ msgstr "" +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API URL" +msgstr "" -#~ #: lib/mv_web/live/import_export_live.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Import members from CSV files or export member data." -#~ 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 f168d3f0934d9ac88cf9d0dcea68f42086f23869 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:29:05 +0100 Subject: [PATCH 07/17] 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 81f62a7c85dabb34cf84eb001d0a9701007a3cb0 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 22:48:56 +0100 Subject: [PATCH 08/17] 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 | 59 ++++++++++--------- priv/gettext/default.pot | 5 ++ priv/gettext/en/LC_MESSAGES/default.po | 59 ++++++++++--------- 6 files changed, 97 insertions(+), 58 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index b754a08..132a8f5 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 ff321b6..3ed3f3d 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2735,42 +2735,47 @@ 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." -# 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/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Vereinfacht API response" +msgstr "Vereinfacht" -msgid "The city field is required." -msgstr "Das Ortsfeld ist erforderlich." +#~ # 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 must be at least 2 characters." -msgstr "Das Ortsfeld muss mindestens 2 Zeichen haben." +#~ msgid "The city field is required." +#~ msgstr "Das Ortsfeld ist erforderlich." -msgid "The country field is required." -msgstr "Das Ländfeld ist erforderlich." +#~ msgid "The city field must be at least 2 characters." +#~ msgstr "Das Ortsfeld muss mindestens 2 Zeichen haben." -msgid "The email field is required." -msgstr "Das E-Mail-Feld ist erforderlich." +#~ msgid "The country field is required." +#~ msgstr "Das Ländfeld 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 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 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 must be at least 2 characters." -msgstr "Das Vornamenfeld muss mindestens 2 Zeichen haben." +#~ 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 first name field must be at least 2 characters." +#~ msgstr "Das Vornamenfeld muss mindestens 2 Zeichen haben." -msgid "The last name field must be at least 2 characters." -msgstr "Das Nachnamenfeld muss mindestens 2 Zeichen haben." +#~ msgid "The last name field is required." +#~ msgstr "Das Nachnamenfeld ist erforderlich." -msgid "The street field is required." -msgstr "Das Straßenfeld ist erforderlich." +#~ msgid "The last name field must be at least 2 characters." +#~ msgstr "Das Nachnamenfeld muss mindestens 2 Zeichen haben." -msgid "The zip code field is required." -msgstr "Das Postleitzahlenfeld ist erforderlich." +#~ msgid "The street field is required." +#~ msgstr "Das Straßenfeld ist erforderlich." -msgid "The zip code field must be at least 2 characters." -msgstr "Das Postleitzahlenfeld muss mindestens 2 Zeichen haben." +#~ 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 70c462d..b151433 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2734,3 +2734,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 71cbe28..07caeb7 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2735,42 +2735,47 @@ 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." -# 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/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Vereinfacht API response" +msgstr "" -msgid "The city field is required." -msgstr "The city field is required." +#~ # 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 must be at least 2 characters." -msgstr "The city field must be at least 2 characters." +#~ msgid "The city field is required." +#~ msgstr "The city field is required." -msgid "The country field is required." -msgstr "The country 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 email field is required." -msgstr "The email field is required." +#~ msgid "The country field is required." +#~ msgstr "The country 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 email field is required." +#~ msgstr "The email field is required." -msgid "The first name field is required." -msgstr "The first name 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 must be at least 2 characters." -msgstr "The first name field must be at least 2 characters." +#~ msgid "The first name field is required." +#~ msgstr "The first name field is required." -msgid "The last name field is required." -msgstr "The last 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 must be at least 2 characters." -msgstr "The last name field must be at least 2 characters." +#~ msgid "The last name field is required." +#~ msgstr "The last name field is required." -msgid "The street field is required." -msgstr "The street 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 zip code field is required." -msgstr "The zip code field is required." +#~ msgid "The street field is required." +#~ msgstr "The street 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." +#~ 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 9db5b7f2920df4cebc2994be64a47f599712eeea Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 18 Feb 2026 23:27:40 +0100 Subject: [PATCH 09/17] 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 fb7d7589bb260ebddb25c45829bce5e749bbc661 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:13:47 +0100 Subject: [PATCH 10/17] 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 e864dee8fe4d74d6fdc92612ec404c8298da4d40 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:13:53 +0100 Subject: [PATCH 11/17] 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 329c2d50ec6253f516425f0e34e1be067b0c5709 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:14:01 +0100 Subject: [PATCH 12/17] 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 4cdd187b433f2e15751236519aadd8d9a3d1ad9b Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:14:03 +0100 Subject: [PATCH 13/17] 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 3ed3f3d..4ce5662 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2619,11 +2619,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}" @@ -2740,42 +2735,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 b151433..3de9e8e 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2620,11 +2620,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}" @@ -2739,3 +2734,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 07caeb7..edfa720 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2620,11 +2620,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}" @@ -2740,42 +2735,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 62000562f0731b50f32c2eb57c8074f5a4af07f9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:14:13 +0100 Subject: [PATCH 14/17] 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 361e33adaf1f289c8da2f9ac6be28c0482bdd3cb Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:14:15 +0100 Subject: [PATCH 15/17] 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 75567a1c0a661f1e4ccd133bc3af4f1e006940bb Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:53:41 +0100 Subject: [PATCH 16/17] 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 17ef8982743afd8c979f76cb85da18b4afeb93d7 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 19 Feb 2026 00:54:30 +0100 Subject: [PATCH 17/17] 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 4ce5662..fb706db 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2749,3 +2749,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 3de9e8e..ec9563b 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2749,3 +2749,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 edfa720..ff14c32 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2749,3 +2749,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 ""