diff --git a/.env.example b/.env.example index d5d35ed..c9cc51e 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,10 @@ ASSOCIATION_NAME="Sportsclub XYZ" # OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list). # OIDC_ADMIN_GROUP_NAME=admin # OIDC_GROUPS_CLAIM=groups + +# Optional: Vereinfacht accounting integration (finance-contacts sync) +# If set, these override values from Settings UI; those fields become read-only. +# VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1 +# VEREINFACHT_API_KEY=your-api-key +# VEREINFACHT_CLUB_ID=2 +# VEREINFACHT_APP_URL=https://app.verein.visuel.dev diff --git a/assets/css/app.css b/assets/css/app.css index 0219e1e..0149c5d 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -99,6 +99,25 @@ /* Make LiveView wrapper divs transparent for layout */ [data-phx-session] { display: contents } +/* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers. + Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline + spacing; use inherited values so custom stylesheets can override. */ +[popover] { + line-height: inherit; + letter-spacing: inherit; + word-spacing: inherit; +} + +/* WCAG 2 AA: success/error text on light backgrounds (e.g. base-200). Use instead of + text-success/text-error when contrast ratio of theme colors is insufficient. */ +.text-success-aa { + color: oklch(0.35 0.12 165); +} + +.text-error-aa { + color: oklch(0.45 0.2 25); +} + /* ============================================ Sidebar Base Styles ============================================ */ diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 92b9ef2..9ac7605 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -118,6 +118,8 @@ defmodule Mv.Accounts.User do change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:email)] end + + change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange end create :create_user do @@ -145,6 +147,8 @@ defmodule Mv.Accounts.User do # Sync user email to member when linking (User → Member) change Mv.EmailSync.Changes.SyncUserEmailToMember + + change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange end update :update_user do @@ -178,6 +182,8 @@ defmodule Mv.Accounts.User do change Mv.EmailSync.Changes.SyncUserEmailToMember do where any([changing(:email), changing(:member)]) end + + change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange end # Internal update used only by SystemActor/bootstrap and tests to assign role to system user. @@ -211,6 +217,8 @@ defmodule Mv.Accounts.User do change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:email)] end + + change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange end # Action to link an OIDC account to an existing password-only user @@ -248,6 +256,8 @@ defmodule Mv.Accounts.User do change Mv.EmailSync.Changes.SyncUserEmailToMember do where [changing(:email)] end + + change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange end read :get_by_subject do @@ -328,6 +338,8 @@ defmodule Mv.Accounts.User do # Sync user email to member when linking (User → Member) change Mv.EmailSync.Changes.SyncUserEmailToMember + change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange + # Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated change fn changeset, _ctx -> user_info = Ash.Changeset.get_argument(changeset, :user_info) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 476501c..7b70e89 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -117,6 +117,9 @@ defmodule Mv.Membership.Member do # Requires both join_date and membership_fee_type_id to be present change Mv.MembershipFees.Changes.SetMembershipFeeStartDate + # Sync member to Vereinfacht as finance contact (if configured) + change Mv.Vereinfacht.Changes.SyncContact + # Trigger cycle generation after member creation # Only runs if membership_fee_type_id is set # Note: Cycle generation runs asynchronously to not block the action, @@ -190,6 +193,9 @@ defmodule Mv.Membership.Member do where [changing(:membership_fee_type_id)] end + # Sync member to Vereinfacht as finance contact (if configured) + change Mv.Vereinfacht.Changes.SyncContact + # Trigger cycle regeneration when membership_fee_type_id changes # This deletes future unpaid cycles and regenerates them with the new type/amount # Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity @@ -243,6 +249,13 @@ defmodule Mv.Membership.Member do end) end + # Internal: set vereinfacht_contact_id after syncing with Vereinfacht API. + # Not exposed via code interface; used only by Mv.Vereinfacht.Changes.SyncContact. + update :set_vereinfacht_contact_id do + require_atomic? false + accept [:vereinfacht_contact_id] + end + # Action to handle fuzzy search on specific fields read :search do argument :query, :string, allow_nil?: true @@ -320,6 +333,12 @@ defmodule Mv.Membership.Member do authorize_if Mv.Authorization.Checks.HasPermission end + # Internal sync action: only SystemActor may set vereinfacht_contact_id (used by SyncContact change). + policy action(:set_vereinfacht_contact_id) do + description "Only system actor may set Vereinfacht contact ID" + authorize_if Mv.Authorization.Checks.ActorIsSystemUser + end + # CREATE/UPDATE: Forbid member–user link unless admin, then check permissions # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty). # HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all. @@ -593,6 +612,14 @@ defmodule Mv.Membership.Member do public? true description "Date from which membership fees should be calculated" end + + # Vereinfacht accounting software integration: ID of the finance contact synced via API. + # Set by Mv.Vereinfacht.Changes.SyncContact; not accepted in create/update actions. + attribute :vereinfacht_contact_id, :string do + allow_nil? true + public? true + description "ID of the finance contact in Vereinfacht (set by sync)" + end end relationships do @@ -1273,17 +1300,24 @@ defmodule Mv.Membership.Member do end end - # Extracts custom field values from existing member data (update scenario) + # Extracts custom field values from existing member data (update scenario). + # Actor must come from context; no system-actor fallback (per guidelines). + # When no actor is present we skip the load and return empty map. defp extract_existing_values(member_data, changeset) do - actor = Map.get(changeset.context, :actor) - opts = Helpers.ash_actor_opts(actor) - - case Ash.load(member_data, :custom_field_values, opts) do - {:ok, %{custom_field_values: existing_values}} -> - Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2) - - _ -> + case Map.get(changeset.context, :actor) do + nil -> %{} + + actor -> + opts = Helpers.ash_actor_opts(actor) + + case Ash.load(member_data, :custom_field_values, opts) do + {:ok, %{custom_field_values: existing_values}} -> + Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2) + + _ -> + %{} + end end end diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index bb7d122..f56daa0 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -69,7 +69,11 @@ defmodule Mv.Membership.Setting do :club_name, :member_field_visibility, :include_joining_cycle, - :default_membership_fee_type_id + :default_membership_fee_type_id, + :vereinfacht_api_url, + :vereinfacht_api_key, + :vereinfacht_club_id, + :vereinfacht_app_url ] end @@ -81,7 +85,11 @@ defmodule Mv.Membership.Setting do :club_name, :member_field_visibility, :include_joining_cycle, - :default_membership_fee_type_id + :default_membership_fee_type_id, + :vereinfacht_api_url, + :vereinfacht_api_key, + :vereinfacht_club_id, + :vereinfacht_app_url ] end @@ -225,6 +233,33 @@ defmodule Mv.Membership.Setting do description "Default membership fee type ID for new members" end + # Vereinfacht accounting software integration (can be overridden by ENV) + attribute :vereinfacht_api_url, :string do + allow_nil? true + public? true + description "Vereinfacht API base URL (e.g. https://api.verein.visuel.dev/api/v1)" + end + + attribute :vereinfacht_api_key, :string do + allow_nil? true + public? false + description "Vereinfacht API key (Bearer token)" + sensitive? true + end + + attribute :vereinfacht_club_id, :string do + allow_nil? true + public? true + description "Vereinfacht club ID for multi-tenancy" + end + + attribute :vereinfacht_app_url, :string do + allow_nil? true + public? true + + description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)" + end + timestamps() end diff --git a/lib/mv/application.ex b/lib/mv/application.ex index ea0c78e..1967ddd 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -7,6 +7,8 @@ defmodule Mv.Application do @impl true def start(_type, _args) do + Mv.Vereinfacht.SyncFlash.create_table!() + children = [ MvWeb.Telemetry, Mv.Repo, diff --git a/lib/mv/authorization/checks/actor_is_system_user.ex b/lib/mv/authorization/checks/actor_is_system_user.ex new file mode 100644 index 0000000..a614a83 --- /dev/null +++ b/lib/mv/authorization/checks/actor_is_system_user.ex @@ -0,0 +1,15 @@ +defmodule Mv.Authorization.Checks.ActorIsSystemUser do + @moduledoc """ + Policy check: true only when the actor is the system user (e.g. system@mila.local). + + Used to restrict internal actions (e.g. Member.set_vereinfacht_contact_id) so that + only code paths using SystemActor can perform them, not regular admins. + """ + use Ash.Policy.SimpleCheck + + @impl true + def describe(_opts), do: "actor is the system user" + + @impl true + def match?(actor, _context, _opts), do: Mv.Helpers.SystemActor.system_user?(actor) +end diff --git a/lib/mv/config.ex b/lib/mv/config.ex index bcbc8d9..d2ad66c 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -142,4 +142,160 @@ defmodule Mv.Config do |> Keyword.get(key, default) |> parse_and_validate_integer(default) end + + # --------------------------------------------------------------------------- + # Vereinfacht accounting software integration + # ENV variables take priority; fallback to Settings from database. + # --------------------------------------------------------------------------- + + @doc """ + Returns the Vereinfacht API base URL. + + Reads from `VEREINFACHT_API_URL` env first, then from Settings. + """ + @spec vereinfacht_api_url() :: String.t() | nil + def vereinfacht_api_url do + env_or_setting("VEREINFACHT_API_URL", :vereinfacht_api_url) + end + + @doc """ + Returns the Vereinfacht API key (Bearer token). + + Reads from `VEREINFACHT_API_KEY` env first, then from Settings. + """ + @spec vereinfacht_api_key() :: String.t() | nil + def vereinfacht_api_key do + env_or_setting("VEREINFACHT_API_KEY", :vereinfacht_api_key) + end + + @doc """ + Returns the Vereinfacht club ID for multi-tenancy. + + Reads from `VEREINFACHT_CLUB_ID` env first, then from Settings. + """ + @spec vereinfacht_club_id() :: String.t() | nil + def vereinfacht_club_id do + env_or_setting("VEREINFACHT_CLUB_ID", :vereinfacht_club_id) + end + + @doc """ + Returns the Vereinfacht app base URL for contact view links (frontend, not API). + + Reads from `VEREINFACHT_APP_URL` env first, then from Settings. + Used to build links like https://app.verein.visuel.dev/en/admin/finances/contacts/{id}. + If not set, derived from API URL by replacing host \"api.\" with \"app.\" when possible. + """ + @spec vereinfacht_app_url() :: String.t() | nil + def vereinfacht_app_url do + env_or_setting("VEREINFACHT_APP_URL", :vereinfacht_app_url) || + derive_app_url_from_api_url(vereinfacht_api_url()) + end + + defp derive_app_url_from_api_url(nil), do: nil + + defp derive_app_url_from_api_url(api_url) when is_binary(api_url) do + api_url = String.trim(api_url) + uri = URI.parse(api_url) + host = uri.host || "" + + if String.starts_with?(host, "api.") do + app_host = "app." <> String.slice(host, 4..-1//1) + scheme = uri.scheme || "https" + "#{scheme}://#{app_host}" + else + nil + end + end + + defp derive_app_url_from_api_url(_), do: nil + + @doc """ + Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set). + """ + @spec vereinfacht_configured?() :: boolean() + def vereinfacht_configured? do + present?(vereinfacht_api_url()) and present?(vereinfacht_api_key()) and + present?(vereinfacht_club_id()) + end + + @doc """ + Returns true if any Vereinfacht ENV variable is set (used to show hint in Settings UI). + """ + @spec vereinfacht_env_configured?() :: boolean() + def vereinfacht_env_configured? do + vereinfacht_api_url_env_set?() or vereinfacht_api_key_env_set?() or + vereinfacht_club_id_env_set?() + end + + @doc """ + Returns true if VEREINFACHT_API_URL is set (field is read-only in Settings). + """ + def vereinfacht_api_url_env_set?, do: env_set?("VEREINFACHT_API_URL") + + @doc """ + Returns true if VEREINFACHT_API_KEY is set (field is read-only in Settings). + """ + def vereinfacht_api_key_env_set?, do: env_set?("VEREINFACHT_API_KEY") + + @doc """ + Returns true if VEREINFACHT_CLUB_ID is set (field is read-only in Settings). + """ + def vereinfacht_club_id_env_set?, do: env_set?("VEREINFACHT_CLUB_ID") + + @doc """ + Returns true if VEREINFACHT_APP_URL is set (field is read-only in Settings). + """ + def vereinfacht_app_url_env_set?, do: env_set?("VEREINFACHT_APP_URL") + + defp env_set?(key) do + case System.get_env(key) do + nil -> false + v when is_binary(v) -> String.trim(v) != "" + _ -> false + end + end + + defp env_or_setting(env_key, setting_key) do + case System.get_env(env_key) do + nil -> get_vereinfacht_from_settings(setting_key) + value -> trim_nil(value) + end + end + + defp get_vereinfacht_from_settings(key) do + case Mv.Membership.get_settings() do + {:ok, settings} -> settings |> Map.get(key) |> trim_nil() + {:error, _} -> nil + end + end + + defp trim_nil(nil), do: nil + + defp trim_nil(s) when is_binary(s) do + t = String.trim(s) + if t == "", do: nil, else: t + end + + @doc """ + Returns the URL to view a finance contact in the Vereinfacht app (frontend). + + Uses the configured app base URL (or derived from API URL) and appends + /en/admin/finances/contacts/{id}. Returns nil if no app URL can be determined. + """ + @spec vereinfacht_contact_view_url(String.t()) :: String.t() | nil + def vereinfacht_contact_view_url(contact_id) when is_binary(contact_id) do + base = vereinfacht_app_url() + + if present?(base) do + base + |> String.trim_trailing("/") + |> then(&"#{&1}/en/admin/finances/contacts/#{contact_id}") + else + nil + end + end + + defp present?(nil), do: false + defp present?(s) when is_binary(s), do: String.trim(s) != "" + defp present?(_), do: false end diff --git a/lib/mv/vereinfacht/changes/sync_contact.ex b/lib/mv/vereinfacht/changes/sync_contact.ex new file mode 100644 index 0000000..99875e0 --- /dev/null +++ b/lib/mv/vereinfacht/changes/sync_contact.ex @@ -0,0 +1,91 @@ +defmodule Mv.Vereinfacht.Changes.SyncContact do + @moduledoc """ + Syncs a member to Vereinfacht as a finance contact after create/update. + + - If the member has no `vereinfacht_contact_id`, creates a contact via API and saves the ID. + - If the member already has an ID, updates the contact via API. + Runs in `after_transaction` so the member is persisted first. API failures are logged + but do not block the member operation. Requires Vereinfacht to be configured + (Mv.Config.vereinfacht_configured?/0). + + Only runs when relevant data changed: on create always; on update only when + first_name, last_name, email, street, house_number, postal_code, or city changed, + or when the member has no vereinfacht_contact_id yet (to avoid unnecessary API calls). + """ + use Ash.Resource.Change + + require Logger + + @synced_attributes [ + :first_name, + :last_name, + :email, + :street, + :house_number, + :postal_code, + :city + ] + + @impl true + def change(changeset, _opts, _context) do + if Mv.Config.vereinfacht_configured?() and sync_relevant?(changeset) do + Ash.Changeset.after_transaction(changeset, &sync_after_transaction/2) + else + changeset + end + end + + defp sync_relevant?(changeset) do + case changeset.action_type do + :create -> true + :update -> relevant_update?(changeset) + _ -> false + end + end + + defp relevant_update?(changeset) do + any_synced_attr_changed? = + Enum.any?(@synced_attributes, &Ash.Changeset.changing_attribute?(changeset, &1)) + + record = changeset.data + no_contact_id_yet? = record && blank_contact_id?(record.vereinfacht_contact_id) + + any_synced_attr_changed? or no_contact_id_yet? + end + + defp blank_contact_id?(nil), do: true + defp blank_contact_id?(""), do: true + defp blank_contact_id?(s) when is_binary(s), do: String.trim(s) == "" + defp blank_contact_id?(_), do: false + + # Ash calls after_transaction with (changeset, result) only - 2 args. + defp sync_after_transaction(_changeset, {:ok, member}) do + case Mv.Vereinfacht.sync_member(member) do + :ok -> + Mv.Vereinfacht.SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.") + {:ok, member} + + {:ok, member_updated} -> + Mv.Vereinfacht.SyncFlash.store( + to_string(member_updated.id), + :ok, + "Synced to Vereinfacht." + ) + + {:ok, member_updated} + + {:error, reason} -> + Logger.warning("Vereinfacht sync failed for member #{member.id}: #{inspect(reason)}") + + Mv.Vereinfacht.SyncFlash.store( + to_string(member.id), + :warning, + Mv.Vereinfacht.format_error(reason) + ) + + {:ok, member} + end + end + + defp sync_after_transaction(_changeset, error), do: error +end diff --git a/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex new file mode 100644 index 0000000..cffb079 --- /dev/null +++ b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex @@ -0,0 +1,71 @@ +defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do + @moduledoc """ + Syncs the linked Member to Vereinfacht after a User action that may have updated + the member's email via Ecto (e.g. User email change → SyncUserEmailToMember). + + Attach to any User action that uses SyncUserEmailToMember. After the transaction + commits, if the user has a linked member and Vereinfacht is configured, syncs + that member to the API. Failures are logged but do not affect the User result. + """ + use Ash.Resource.Change + + require Logger + alias Mv.Membership.Member + alias Mv.Membership + alias Mv.Helpers.SystemActor + alias Mv.Helpers + + @impl true + def change(changeset, _opts, _context) do + if Mv.Config.vereinfacht_configured?() and relevant_change?(changeset) do + Ash.Changeset.after_transaction(changeset, &sync_linked_member_after_transaction/2) + else + changeset + end + end + + # Only sync when something that affects the linked member's data actually changed + # (email sync or member link), to avoid unnecessary API calls on every user update. + defp relevant_change?(changeset) do + Ash.Changeset.changing_attribute?(changeset, :email) or + Ash.Changeset.changing_relationship?(changeset, :member) + end + + defp sync_linked_member_after_transaction(_changeset, {:ok, user}) do + case load_linked_member(user) do + nil -> + {:ok, user} + + member -> + case Mv.Vereinfacht.sync_member(member) do + :ok -> + {:ok, user} + + {:ok, _} -> + {:ok, user} + + {:error, reason} -> + Logger.warning( + "Vereinfacht sync failed for member #{member.id} (linked to user #{user.id}): #{inspect(reason)}" + ) + + {:ok, user} + end + end + end + + defp sync_linked_member_after_transaction(_changeset, result), do: result + + defp load_linked_member(%{member_id: nil}), do: nil + defp load_linked_member(%{member_id: ""}), do: nil + + defp load_linked_member(user) do + actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(actor) + + case Ash.get(Member, user.member_id, [domain: Membership] ++ opts) do + {:ok, %Member{} = member} -> member + _ -> nil + end + end +end diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex new file mode 100644 index 0000000..58e06a9 --- /dev/null +++ b/lib/mv/vereinfacht/client.ex @@ -0,0 +1,364 @@ +defmodule Mv.Vereinfacht.Client do + @moduledoc """ + HTTP client for the Vereinfacht accounting software JSON:API. + + Creates and updates finance contacts. Uses Bearer token authentication and + requires club ID for multi-tenancy. Configuration via ENV or Settings + (see Mv.Config). + """ + require Logger + + @content_type "application/vnd.api+json" + + @doc """ + Creates a finance contact in Vereinfacht for the given member. + + Returns the contact ID on success. Does not update the member record; + the caller (e.g. SyncContact change) must persist `vereinfacht_contact_id`. + + ## Options + - None; URL, API key, and club ID are read from Mv.Config. + + ## Examples + + iex> create_contact(member) + {:ok, "242"} + + iex> create_contact(member) + {:error, {:http, 401, "Unauthenticated."}} + """ + @spec create_contact(struct()) :: {:ok, String.t()} | {:error, term()} + def create_contact(member) do + base_url = base_url() + api_key = api_key() + club_id = club_id() + + if is_nil(base_url) or is_nil(api_key) or is_nil(club_id) do + {:error, :not_configured} + else + body = build_create_body(member, club_id) + url = base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts") + post_and_parse_contact(url, body, api_key) + end + end + + @sync_timeout_ms 5_000 + + # In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits). + defp req_http_options do + opts = [receive_timeout: @sync_timeout_ms] + if Mix.env() == :test, do: [retry: false] ++ opts, else: opts + end + + defp post_and_parse_contact(url, body, api_key) do + encoded_body = Jason.encode!(body) + + case Req.post(url, [body: encoded_body, headers: headers(api_key)] ++ req_http_options()) do + {:ok, %{status: 201, body: resp_body}} -> + case get_contact_id_from_response(resp_body) do + nil -> {:error, {:invalid_response, resp_body}} + id -> {:ok, id} + end + + {:ok, %{status: status, body: resp_body}} -> + {:error, {:http, status, extract_error_message(resp_body)}} + + {:error, reason} -> + {:error, {:request_failed, reason}} + end + end + + @doc """ + Updates an existing finance contact in Vereinfacht. + + Only sends attributes that are typically synced from the member (name, email, + address fields). Returns the same contact_id on success. + + ## Examples + + iex> update_contact("242", member) + {:ok, "242"} + + iex> update_contact("242", member) + {:error, {:http, 404, "Not Found"}} + """ + @spec update_contact(String.t(), struct()) :: {:ok, String.t()} | {:error, term()} + def update_contact(contact_id, member) when is_binary(contact_id) do + base_url = base_url() + api_key = api_key() + + if is_nil(base_url) or is_nil(api_key) do + {:error, :not_configured} + else + body = build_update_body(contact_id, member) + encoded_body = Jason.encode!(body) + + url = + base_url + |> String.trim_trailing("/") + |> then(&"#{&1}/finance-contacts/#{contact_id}") + + case Req.patch( + url, + [ + body: encoded_body, + headers: headers(api_key) + ] ++ req_http_options() + ) do + {:ok, %{status: 200, body: _resp_body}} -> + {:ok, contact_id} + + {:ok, %{status: status, body: body}} -> + {:error, {:http, status, extract_error_message(body)}} + + {:error, reason} -> + {:error, {:request_failed, reason}} + end + end + end + + @doc """ + Finds a finance contact by email (GET /finance-contacts, then match in response). + + The Vereinfacht API does not allow filter by email on this endpoint, so we + fetch the first page and find the contact client-side. Returns {:ok, contact_id} + if a contact with that email exists, {:error, :not_found} if none, or + {:error, reason} on API/network failure. Used before create for idempotency. + """ + @spec find_contact_by_email(String.t()) :: {:ok, String.t()} | {:error, :not_found | term()} + def find_contact_by_email(email) when is_binary(email) do + if is_nil(base_url()) or is_nil(api_key()) or is_nil(club_id()) do + {:error, :not_configured} + else + do_find_contact_by_email(email) + end + end + + @find_contact_page_size 100 + @find_contact_max_pages 100 + + defp do_find_contact_by_email(email) do + normalized = String.trim(email) |> String.downcase() + do_find_contact_by_email_page(1, normalized) + end + + defp do_find_contact_by_email_page(page, _normalized) when page > @find_contact_max_pages do + {:error, :not_found} + end + + defp do_find_contact_by_email_page(page, normalized) do + base = base_url() |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts") + url = base <> "?page[size]=#{@find_contact_page_size}&page[number]=#{page}" + + case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do + {:ok, %{status: 200, body: body}} when is_map(body) -> + handle_find_contact_page_response(body, page, normalized) + + {:ok, %{status: status, body: body}} -> + {:error, {:http, status, extract_error_message(body)}} + + {:error, reason} -> + {:error, {:request_failed, reason}} + end + end + + defp handle_find_contact_page_response(body, page, normalized) do + case find_contact_id_by_email_in_list(body, normalized) do + id when is_binary(id) -> {:ok, id} + nil -> maybe_find_contact_next_page(body, page, normalized) + end + end + + defp maybe_find_contact_next_page(body, page, normalized) do + data = Map.get(body, "data") || [] + + if length(data) < @find_contact_page_size, + do: {:error, :not_found}, + else: do_find_contact_by_email_page(page + 1, normalized) + end + + defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do + Enum.find_value(list, fn + %{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => true}} + when is_binary(att_email) -> + if att_email |> String.trim() |> String.downcase() == normalized do + normalize_contact_id(id) + else + nil + end + + %{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => "true"}} + when is_binary(att_email) -> + if att_email |> String.trim() |> String.downcase() == normalized do + normalize_contact_id(id) + else + nil + end + + %{"id" => _id, "attributes" => _} -> + nil + + _ -> + nil + end) + end + + defp find_contact_id_by_email_in_list(_, _), do: nil + + defp normalize_contact_id(id) when is_binary(id), do: id + defp normalize_contact_id(id) when is_integer(id), do: to_string(id) + defp normalize_contact_id(_), do: nil + + @doc """ + Fetches a single finance contact from Vereinfacht (GET /finance-contacts/:id). + + Returns the full response body (decoded JSON) for debugging/display. + """ + @spec get_contact(String.t()) :: {:ok, map()} | {:error, term()} + def get_contact(contact_id) when is_binary(contact_id) do + fetch_contact(contact_id, []) + end + + @doc """ + Fetches a finance contact with receipts (GET /finance-contacts/:id?include=receipts). + + Returns {:ok, receipts} where receipts is a list of maps with :id and :attributes + (and optional :type) for each receipt, or {:error, reason}. + """ + @spec get_contact_with_receipts(String.t()) :: {:ok, [map()]} | {:error, term()} + def get_contact_with_receipts(contact_id) when is_binary(contact_id) do + case fetch_contact(contact_id, include: "receipts") do + {:ok, body} -> {:ok, extract_receipts_from_response(body)} + {:error, _} = err -> err + end + end + + defp fetch_contact(contact_id, query_params) do + base_url = base_url() + api_key = api_key() + + if is_nil(base_url) or is_nil(api_key) do + {:error, :not_configured} + else + path = + base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}") + + url = build_url_with_params(path, query_params) + + case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do + {:ok, %{status: 200, body: body}} when is_map(body) -> + {:ok, body} + + {:ok, %{status: status, body: body}} -> + {:error, {:http, status, extract_error_message(body)}} + + {:error, reason} -> + {:error, {:request_failed, reason}} + end + end + end + + defp build_url_with_params(base, []), do: base + + defp build_url_with_params(base, include: value) do + sep = if String.contains?(base, "?"), do: "&", else: "?" + base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1) + end + + # Allowlist of receipt attribute keys we expose (avoids String.to_atom on arbitrary API input / DoS). + @receipt_attr_allowlist ~w[amount bookingDate createdAt receiptType referenceNumber status updatedAt]a + + defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do + included + |> Enum.filter(&match?(%{"type" => "receipts"}, &1)) + |> Enum.map(fn %{"id" => id, "attributes" => attrs} = r -> + Map.merge(%{id: id, type: r["type"]}, receipt_attrs_allowlist(attrs || %{})) + end) + end + + defp extract_receipts_from_response(_), do: [] + + defp receipt_attrs_allowlist(attrs) when is_map(attrs) do + Map.new(@receipt_attr_allowlist, fn key -> + str_key = to_string(key) + {key, Map.get(attrs, str_key)} + end) + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() + end + + defp base_url, do: Mv.Config.vereinfacht_api_url() + defp api_key, do: Mv.Config.vereinfacht_api_key() + defp club_id, do: Mv.Config.vereinfacht_club_id() + + defp headers(api_key) do + [ + {"Accept", @content_type}, + {"Content-Type", @content_type}, + {"Authorization", "Bearer #{api_key}"} + ] + end + + defp build_create_body(member, club_id) do + attributes = member_to_attributes(member) + + %{ + "data" => %{ + "type" => "finance-contacts", + "attributes" => attributes, + "relationships" => %{ + "club" => %{ + "data" => %{"type" => "clubs", "id" => club_id} + } + } + } + } + end + + defp build_update_body(contact_id, member) do + attributes = member_to_attributes(member) + + %{ + "data" => %{ + "type" => "finance-contacts", + "id" => contact_id, + "attributes" => attributes + } + } + end + + defp member_to_attributes(member) do + address = + [member |> Map.get(:street), member |> Map.get(:house_number)] + |> Enum.reject(&is_nil/1) + |> Enum.map_join(" ", &to_string/1) + |> then(fn s -> if s == "", do: nil, else: s end) + + %{} + |> put_attr("lastName", member |> Map.get(:last_name)) + |> put_attr("firstName", member |> Map.get(:first_name)) + |> put_attr("email", member |> Map.get(:email)) + |> put_attr("address", address) + |> put_attr("zipCode", member |> Map.get(:postal_code)) + |> put_attr("city", member |> Map.get(:city)) + |> Map.put("contactType", "person") + |> Map.put("isExternal", true) + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() + end + + defp put_attr(acc, _key, nil), do: acc + defp put_attr(acc, key, value), do: Map.put(acc, key, to_string(value)) + + defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_binary(id), do: id + + defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_integer(id), + do: to_string(id) + + defp get_contact_id_from_response(_), do: nil + + defp extract_error_message(%{"errors" => [%{"detail" => d} | _]}) when is_binary(d), do: d + defp extract_error_message(%{"errors" => [%{"title" => t} | _]}) when is_binary(t), do: t + defp extract_error_message(body) when is_map(body), do: inspect(body) + defp extract_error_message(other), do: inspect(other) +end diff --git a/lib/mv/vereinfacht/sync_flash.ex b/lib/mv/vereinfacht/sync_flash.ex new file mode 100644 index 0000000..874a717 --- /dev/null +++ b/lib/mv/vereinfacht/sync_flash.ex @@ -0,0 +1,46 @@ +defmodule Mv.Vereinfacht.SyncFlash do + @moduledoc """ + Short-lived store for Vereinfacht sync results so the UI can show them after save. + + The SyncContact change runs in after_transaction and cannot access the LiveView + socket. This module stores a message keyed by member_id; the form LiveView + calls `take/1` after a successful save and displays the message in flash. + """ + @table :vereinfacht_sync_flash + + @doc """ + Stores a sync result for the given member. Overwrites any previous message. + + - `:ok` - Sync succeeded (optional user message). + - `:warning` - Sync failed; message should be shown as a warning. + """ + @spec store(String.t(), :ok | :warning, String.t()) :: :ok + def store(member_id, kind, message) when is_binary(member_id) do + :ets.insert(@table, {member_id, {kind, message}}) + :ok + end + + @doc """ + Takes and removes the stored sync message for the given member. + + Returns `{kind, message}` if present, otherwise `nil`. + """ + @spec take(String.t()) :: {:ok | :warning, String.t()} | nil + def take(member_id) when is_binary(member_id) do + case :ets.take(@table, member_id) do + [{^member_id, value}] -> value + [] -> nil + end + end + + @doc false + def create_table! do + # :public so any process can write (SyncContact runs in LiveView/Ash transaction process, + # not the process that created the table). :protected would restrict writes to the creating process. + if :ets.whereis(@table) == :undefined do + :ets.new(@table, [:set, :public, :named_table]) + end + + :ok + end +end diff --git a/lib/mv/vereinfacht/vereinfacht.ex b/lib/mv/vereinfacht/vereinfacht.ex new file mode 100644 index 0000000..ce8005d --- /dev/null +++ b/lib/mv/vereinfacht/vereinfacht.ex @@ -0,0 +1,165 @@ +defmodule Mv.Vereinfacht do + @moduledoc """ + Business logic for Vereinfacht accounting software integration. + + - `sync_member/1` – Sync a single member to the API (create or update contact). + Used by Member create/update (SyncContact) and by User actions that update + the linked member's email via Ecto (e.g. user email change). + - `sync_members_without_contact/0` – Bulk sync of members without a contact ID. + """ + require Ash.Query + import Ash.Expr + alias Mv.Vereinfacht.Client + alias Mv.Membership.Member + alias Mv.Helpers.SystemActor + alias Mv.Helpers + + @doc """ + Syncs a single member to Vereinfacht (create or update finance contact). + + If the member has no `vereinfacht_contact_id`, creates a contact and updates + the member with the new ID. If they already have an ID, updates the contact. + Uses system actor for any Ash update. Does nothing if Vereinfacht is not configured. + + Returns: + - `:ok` – Contact was updated. + - `{:ok, member}` – Contact was created and member was updated with the new ID. + - `{:error, reason}` – API or update failed. + """ + @spec sync_member(struct()) :: :ok | {:ok, struct()} | {:error, term()} + def sync_member(member) do + if Mv.Config.vereinfacht_configured?() do + do_sync_member(member) + else + :ok + end + end + + defp do_sync_member(member) do + if present_contact_id?(member.vereinfacht_contact_id) do + sync_existing_contact(member) + else + ensure_contact_then_save(member) + end + end + + defp sync_existing_contact(member) do + case Client.update_contact(member.vereinfacht_contact_id, member) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp ensure_contact_then_save(member) do + case get_or_create_contact_id(member) do + {:ok, contact_id} -> save_contact_id(member, contact_id) + {:error, _} = err -> err + end + end + + # Before create: find by email to avoid duplicate contacts (idempotency). + # When an existing contact is found, update it with current member data. + defp get_or_create_contact_id(member) do + email = member |> Map.get(:email) |> to_string() |> String.trim() + + if email == "" do + Client.create_contact(member) + else + case Client.find_contact_by_email(email) do + {:ok, existing_id} -> update_existing_contact_and_return_id(existing_id, member) + {:error, :not_found} -> Client.create_contact(member) + {:error, _} = err -> err + end + end + end + + defp update_existing_contact_and_return_id(contact_id, member) do + case Client.update_contact(contact_id, member) do + {:ok, _} -> {:ok, contact_id} + {:error, _} = err -> err + end + end + + defp save_contact_id(member, contact_id) do + system_actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(system_actor) + + case Ash.update(member, %{vereinfacht_contact_id: contact_id}, [ + {:action, :set_vereinfacht_contact_id} | opts + ]) do + {:ok, updated} -> {:ok, updated} + {:error, reason} -> {:error, reason} + end + end + + defp present_contact_id?(nil), do: false + defp present_contact_id?(""), do: false + defp present_contact_id?(s) when is_binary(s), do: String.trim(s) != "" + defp present_contact_id?(_), do: false + + @doc """ + Formats an API/request error reason into a short user-facing message. + + Used by SyncContact (flash) and GlobalSettingsLive (sync result list). + """ + @spec format_error(term()) :: String.t() + def format_error({:http, _status, detail}) when is_binary(detail), do: "Vereinfacht: " <> detail + def format_error({:http, status, _}), do: "Vereinfacht: API error (HTTP #{status})." + + def format_error({:request_failed, _}), + do: "Vereinfacht: Request failed (e.g. connection error)." + + def format_error({:invalid_response, _}), do: "Vereinfacht: Invalid API response." + def format_error(other), do: "Vereinfacht: " <> inspect(other) + + @doc """ + Creates Vereinfacht contacts for all members that do not yet have a + `vereinfacht_contact_id`. Uses system actor for reads and updates. + + Returns `{:ok, %{synced: count, errors: list}}` where errors is a list of + `{member_id, reason}`. Does nothing if Vereinfacht is not configured. + """ + @spec sync_members_without_contact() :: + {:ok, %{synced: non_neg_integer(), errors: [{String.t(), term()}]}} + | {:error, :not_configured} + def sync_members_without_contact do + if Mv.Config.vereinfacht_configured?() do + system_actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(system_actor) + + query = + Member + |> Ash.Query.filter( + expr(is_nil(^ref(:vereinfacht_contact_id)) or ^ref(:vereinfacht_contact_id) == "") + ) + + case Ash.read(query, opts) do + {:ok, members} -> + do_sync_members(members, opts) + + {:error, _} = err -> + err + end + else + {:error, :not_configured} + end + end + + defp do_sync_members(members, opts) do + {synced, errors} = + Enum.reduce(members, {0, []}, fn member, {acc_synced, acc_errors} -> + {inc, new_errors} = sync_one_member(member, opts) + {acc_synced + inc, acc_errors ++ new_errors} + end) + + {:ok, %{synced: synced, errors: errors}} + end + + defp sync_one_member(member, _opts) do + case sync_member(member) do + :ok -> {1, []} + {:ok, _} -> {1, []} + {:error, reason} -> {0, [{member.id, reason}]} + end + end +end diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index fafc955..b841931 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -23,6 +23,9 @@ defmodule MvWeb.GlobalSettingsLive do """ use MvWeb, :live_view + require Ash.Query + import Ash.Expr + alias Mv.Membership on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded} @@ -41,11 +44,23 @@ defmodule MvWeb.GlobalSettingsLive do |> assign(:settings, settings) |> assign(:active_editing_section, nil) |> assign(:locale, locale) + |> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?()) + |> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?()) + |> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?()) + |> assign(:vereinfacht_club_id_env_set, Mv.Config.vereinfacht_club_id_env_set?()) + |> assign(:vereinfacht_app_url_env_set, Mv.Config.vereinfacht_app_url_env_set?()) + |> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key)) + |> assign(:last_vereinfacht_sync_result, nil) |> assign_form() {:ok, socket} end + defp present?(nil), do: false + defp present?(""), do: false + defp present?(s) when is_binary(s), do: String.trim(s) != "" + defp present?(_), do: false + @impl true def render(assigns) do ~H""" @@ -74,6 +89,98 @@ defmodule MvWeb.GlobalSettingsLive do + <%!-- Vereinfacht Integration Section --%> + <.form_section title={gettext("Vereinfacht Integration")}> + <%= if @vereinfacht_env_configured do %> +

+ {gettext("Some values are set via environment variables. Those fields are read-only.")} +

+ <% 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_api_url_env_set} + placeholder={ + 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="" + 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_club_id_env_set} + placeholder={ + if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2") + } + /> + <.input + field={@form[:vereinfacht_app_url]} + type="text" + label={gettext("App URL (contact view link)")} + disabled={@vereinfacht_app_url_env_set} + placeholder={ + if(@vereinfacht_app_url_env_set, + do: gettext("From VEREINFACHT_APP_URL"), + else: "https://app.verein.visuel.dev" + ) + } + /> +
+ <.button + :if={ + 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" + > + {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,18 +207,54 @@ 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) + # 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() @@ -122,6 +265,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, @@ -202,9 +355,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", @@ -213,4 +369,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:")}

+ + <% end %> +
+ """ + end end 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..a85bf69 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_receipts={@vereinfacht_receipts} /> <% 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_receipts, 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_receipts", %{"contact_id" => contact_id}, socket) do + response = + case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do + {:ok, receipts} -> {:ok, receipts} + {:error, reason} -> {:error, reason} + end + + {:noreply, assign(socket, :vereinfacht_receipts, 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..1ce6f77 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -50,6 +50,90 @@ 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 @vereinfacht_contact_present do %> +
+
+ <.link + :if={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)} + href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)} + target="_blank" + rel="noopener noreferrer" + class="link link-accent underline inline-flex items-center gap-1 w-fit" + > + {gettext("View contact in Vereinfacht")} + <.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" /> + +
+ +
+ <%= if @vereinfacht_receipts do %> +
+ <%= if match?({:ok, _}, @vereinfacht_receipts) do %> + <% {_, receipts} = @vereinfacht_receipts %> + <%= if receipts == [] do %> +

{gettext("No receipts")}

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

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

+ <% end %> +
+ <% end %> +
+
+ <% 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 @@ -431,6 +515,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign(:can_create_cycle, can_create_cycle) |> assign(:can_destroy_cycle, can_destroy_cycle) |> assign(:can_update_cycle, can_update_cycle) + |> assign(:vereinfacht_contact_present, present_contact_id?(member.vereinfacht_contact_id)) |> assign_new(:interval_warning, fn -> nil end) |> assign_new(:editing_cycle, fn -> nil end) |> assign_new(:deleting_cycle, fn -> nil end) @@ -439,7 +524,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_receipts, fn -> nil end)} end @impl true @@ -997,6 +1083,142 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do defp format_create_cycle_period(_date, _interval), do: "" + defp present_contact_id?(nil), do: false + defp present_contact_id?(id) when is_binary(id), do: String.trim(id) != "" + defp present_contact_id?(_), do: false + + defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail), + do: "HTTP #{status} – #{detail}" + + defp format_vereinfacht_error({:http, status, _}), do: "HTTP #{status}" + defp format_vereinfacht_error(reason), do: inspect(reason) + + # Ordered receipt columns: {api_key, gettext key for header}. Only columns present in data are shown. + @receipt_column_spec [ + {:amount, "Amount"}, + {:bookingDate, "Booking date"}, + {:createdAt, "Created at"}, + {:receiptType, "Receipt type"}, + {:referenceNumber, "Reference number"}, + {:status, "Status"}, + {:updatedAt, "Updated at"} + ] + + defp receipt_display_columns(receipts) when is_list(receipts) do + keys_in_data = receipts |> Enum.flat_map(&Map.keys/1) |> MapSet.new() + + Enum.filter(@receipt_column_spec, fn {key, _} -> MapSet.member?(keys_in_data, key) end) + |> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end) + end + + defp format_receipt_cell(:amount, nil), do: "—" + + defp format_receipt_cell(:amount, val) when is_number(val) do + case Decimal.cast(val) do + {:ok, d} -> MembershipFeeHelpers.format_currency(d) + _ -> to_string(val) + end + end + + defp format_receipt_cell(:amount, val) when is_binary(val) do + case Decimal.parse(val) do + {d, _} -> MembershipFeeHelpers.format_currency(d) + :error -> val + end + end + + defp format_receipt_cell(:amount, val), do: to_string(val) + + defp format_receipt_cell(:status, nil), do: "—" + + defp format_receipt_cell(:status, val) when is_binary(val) do + translate_receipt_status(val) + end + + defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val)) + + defp format_receipt_cell(:receiptType, nil), do: "—" + + defp format_receipt_cell(:receiptType, val) when is_binary(val) do + translate_receipt_type(val) + end + + defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val)) + + defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt], + do: "—" + + defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do + format_receipt_date(val) + end + + defp format_receipt_cell(_col_key, val) when is_binary(val), do: val + defp format_receipt_cell(_col_key, val) when is_number(val), do: to_string(val) + + defp format_receipt_cell(_col_key, val) when is_boolean(val), + do: if(val, do: gettext("Yes"), else: gettext("No")) + + defp format_receipt_cell(_col_key, %Date{} = d), do: format_receipt_date_short(d) + defp format_receipt_cell(_col_key, val) when is_map(val) or is_list(val), do: Jason.encode!(val) + defp format_receipt_cell(_col_key, val), do: to_string(val) + + defp format_receipt_date(%Date{} = d), do: format_receipt_date_short(d) + + defp format_receipt_date(val) when is_binary(val) do + case parse_receipt_date(val) do + {:ok, d} -> format_receipt_date_short(d) + _ -> val + end + end + + defp format_receipt_date(val), do: to_string(val) + + # Parses ISO date or datetime string to Date (uses first 10 chars for datetime strings) + defp parse_receipt_date(val) when is_binary(val) do + date_str = if String.length(val) >= 10, do: String.slice(val, 0, 10), else: val + Date.from_iso8601(date_str) + end + + # Format as "12. Dez. 2025" (day. abbreviated month. year) with translated month + defp format_receipt_date_short(%Date{day: day, month: month, year: year}) do + "#{day}. #{receipt_month_abbr(month)} #{year}" + end + + defp receipt_month_abbr(1), do: gettext("Jan.") + defp receipt_month_abbr(2), do: gettext("Feb.") + defp receipt_month_abbr(3), do: gettext("Mar.") + defp receipt_month_abbr(4), do: gettext("Apr.") + defp receipt_month_abbr(5), do: gettext("May") + defp receipt_month_abbr(6), do: gettext("Jun.") + defp receipt_month_abbr(7), do: gettext("Jul.") + defp receipt_month_abbr(8), do: gettext("Aug.") + defp receipt_month_abbr(9), do: gettext("Sep.") + defp receipt_month_abbr(10), do: gettext("Oct.") + defp receipt_month_abbr(11), do: gettext("Nov.") + defp receipt_month_abbr(12), do: gettext("Dec.") + defp receipt_month_abbr(_), do: "" + + # Translate API status values for display (extend as API returns more values) + defp translate_receipt_status("paid"), do: gettext("Paid") + defp translate_receipt_status("unpaid"), do: gettext("Unpaid") + defp translate_receipt_status("suspended"), do: gettext("Suspended") + defp translate_receipt_status("open"), do: gettext("Open") + defp translate_receipt_status("cancelled"), do: gettext("Cancelled") + defp translate_receipt_status("draft"), do: gettext("Draft") + defp translate_receipt_status("incompleted"), do: gettext("Incompleted") + defp translate_receipt_status("completed"), do: gettext("Completed") + defp translate_receipt_status("empty"), do: "—" + defp translate_receipt_status(other), do: other + + # Translate API receipt type values (extend as API returns more values) + defp translate_receipt_type("invoice"), do: gettext("Invoice") + defp translate_receipt_type("receipt"), do: gettext("Receipt") + defp translate_receipt_type("credit_note"), do: gettext("Credit note") + defp translate_receipt_type("credit"), do: gettext("Credit") + defp translate_receipt_type("expense"), do: gettext("Expense") + defp translate_receipt_type("income"), do: gettext("Income") + defp translate_receipt_type(other), do: other + # Helper component for section box attr :title, :string, required: true slot :inner_block, required: true diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index b7145f0..d39d86b 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -200,6 +200,7 @@ msgstr "Straße" #: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "No" @@ -2622,3 +2623,291 @@ msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden." #, elixir-autogen, elixir-format, fuzzy msgid "Could not load member list. Please try again." msgstr "Mitgliederliste konnte nicht geladen werden. Bitte versuche es erneut." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API Key" +msgstr "API-Schlüssel" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API URL" +msgstr "API-URL" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Club ID" +msgstr "Vereins-ID" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "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/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 setze API-URL, API-Schlüssel und Vereins-ID." + +#: 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/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 "Synchronisiere dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichere das Mitglied erneut, um den Kontakt anzulegen." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "(set)" +msgstr "(gesetzt)" + +#: 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" + +#: 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." + +# 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." + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "App URL (contact view link)" +msgstr "App-URL (Link zur Kontaktansicht)" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "From VEREINFACHT_APP_URL" +msgstr "Aus VEREINFACHT_APP_URL" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Error loading receipts: %{reason}" +msgstr "Belege konnten nicht geladen werden: %{reason}" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No receipts" +msgstr "Keine Belege" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Show bookings/receipts from Vereinfacht" +msgstr "Buchungen/Belege aus Vereinfacht anzeigen" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht receipts" +msgstr "Vereinfacht-Belege" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Cancelled" +msgstr "Storniert" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit" +msgstr "Gutschrift" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit note" +msgstr "Gutschrift" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Draft" +msgstr "Entwurf" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Invoice" +msgstr "Rechnung" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Open" +msgstr "Offen" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Receipt" +msgstr "Beleg" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Apr." +msgstr "Apr." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Aug." +msgstr "Aug." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Completed" +msgstr "Abgeschlossen" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Dec." +msgstr "Dez." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Expense" +msgstr "Ausgabe" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Feb." +msgstr "Feb." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Income" +msgstr "Einnahme" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Incompleted" +msgstr "Unvollständig" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jan." +msgstr "Jan." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jul." +msgstr "Jul." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jun." +msgstr "Jun." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Mar." +msgstr "Mär." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "May" +msgstr "Mai" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Nov." +msgstr "Nov." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Oct." +msgstr "Okt." + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sep." +msgstr "Sep." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index c1aafd9..ff466ab 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -201,6 +201,7 @@ msgstr "" #: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "No" @@ -2623,3 +2624,290 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Could not load member list. Please try again." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API Key" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API URL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Club ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "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/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/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 "" + +#: 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 "" + +# 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 "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "App URL (contact view link)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "From VEREINFACHT_APP_URL" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Error loading receipts: %{reason}" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No receipts" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Show bookings/receipts from Vereinfacht" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Vereinfacht receipts" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Cancelled" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit note" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Draft" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Invoice" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Open" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Receipt" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Apr." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Aug." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Completed" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Dec." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Expense" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Feb." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Income" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Incompleted" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jan." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jul." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jun." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Mar." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "May" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Nov." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Oct." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sep." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 12c1666..e5e0181 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -201,6 +201,7 @@ msgstr "" #: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format msgid "No" @@ -2623,3 +2624,290 @@ msgstr "" #, elixir-autogen, elixir-format, fuzzy msgid "Could not load member list. Please try again." msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API Key" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "API URL" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "Club ID" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "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/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/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." + +#: 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 "" + +# 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 "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format +msgid "App URL (contact view link)" +msgstr "" + +#: lib/mv_web/live/global_settings_live.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "From VEREINFACHT_APP_URL" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Error loading receipts: %{reason}" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "No receipts" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Show bookings/receipts from Vereinfacht" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Vereinfacht receipts" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Cancelled" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Credit note" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Draft" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Invoice" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Open" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Receipt" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Apr." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Aug." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Completed" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Dec." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Expense" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Feb." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Income" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Incompleted" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jan." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jul." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Jun." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Mar." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "May" +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Nov." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Oct." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "Sep." +msgstr "" 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/repo/migrations/20260218190000_add_vereinfacht_app_url.exs b/priv/repo/migrations/20260218190000_add_vereinfacht_app_url.exs new file mode 100644 index 0000000..f10728c --- /dev/null +++ b/priv/repo/migrations/20260218190000_add_vereinfacht_app_url.exs @@ -0,0 +1,15 @@ +defmodule Mv.Repo.Migrations.AddVereinfachtAppUrl do + use Ecto.Migration + + def up do + alter table(:settings) do + add :vereinfacht_app_url, :text + end + end + + def down do + alter table(:settings) do + remove :vereinfacht_app_url + end + end +end diff --git a/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 diff --git a/test/mv/config_vereinfacht_test.exs b/test/mv/config_vereinfacht_test.exs new file mode 100644 index 0000000..d7a3360 --- /dev/null +++ b/test/mv/config_vereinfacht_test.exs @@ -0,0 +1,83 @@ +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 app contact view URL when API URL is set (derived app URL)" do + clear_vereinfacht_env() + clear_vereinfacht_app_url_from_settings() + set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com/api/v1") + + assert Mv.Config.vereinfacht_contact_view_url("42") == + "https://app.example.com/en/admin/finances/contacts/42" + after + clear_vereinfacht_env() + end + + test "returns app contact view URL when VEREINFACHT_APP_URL is set" do + set_vereinfacht_env("VEREINFACHT_APP_URL", "https://app.verein.visuel.dev") + + assert Mv.Config.vereinfacht_contact_view_url("abc") == + "https://app.verein.visuel.dev/en/admin/finances/contacts/abc" + after + 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") + System.delete_env("VEREINFACHT_APP_URL") + end + + defp clear_vereinfacht_app_url_from_settings do + case Mv.Membership.get_settings() do + {:ok, settings} -> + Mv.Membership.update_settings(settings, %{vereinfacht_app_url: nil}) + + _ -> + :ok + end + end +end 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. 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 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