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 """ Tests the connection to the Vereinfacht API using the current configuration. Delegates to `Mv.Vereinfacht.Client.test_connection/3` with the values from `Mv.Config` (ENV variables take priority over database settings). ## Returns - `{:ok, :connected}` – credentials are valid and API is reachable - `{:error, :not_configured}` – URL, API key or club ID is missing - `{:error, {:http, status, message}}` – API returned an error (e.g. 401, 403) - `{:error, {:request_failed, reason}}` – network/transport error """ @spec test_connection() :: {:ok, :connected} | {:error, term()} def test_connection do Client.test_connection( Mv.Config.vereinfacht_api_url(), Mv.Config.vereinfacht_api_key(), Mv.Config.vereinfacht_club_id() ) end @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