defmodule Mv.Vereinfacht do @moduledoc """ Business logic for Vereinfacht accounting software integration. - `sync_member/1` – Sync a single member to the API (create or update contact). Used by Member create/update (SyncContact) and by User actions that update the linked member's email via Ecto (e.g. user email change). - `sync_members_without_contact/0` – Bulk sync of members without a contact ID. """ require Ash.Query alias Mv.Vereinfacht.Client alias Mv.Membership.Member alias Mv.Helpers.SystemActor alias Mv.Helpers @doc """ Syncs a single member to Vereinfacht (create or update finance contact). If the member has no `vereinfacht_contact_id`, creates a contact and updates the member with the new ID. If they already have an ID, updates the contact. Uses system actor for any Ash update. Does nothing if Vereinfacht is not configured. Returns: - `:ok` – Contact was updated. - `{:ok, member}` – Contact was created and member was updated with the new ID. - `{:error, reason}` – API or update failed. """ @spec sync_member(struct()) :: :ok | {:ok, struct()} | {:error, term()} def sync_member(member) do if Mv.Config.vereinfacht_configured?() do do_sync_member(member) else :ok end end defp do_sync_member(member) do if present_contact_id?(member.vereinfacht_contact_id) do case Client.update_contact(member.vereinfacht_contact_id, member) do {:ok, _} -> :ok {:error, reason} -> {:error, reason} end else case Client.create_contact(member) do {:ok, contact_id} -> save_contact_id(member, contact_id) {:error, reason} -> {:error, reason} end end end defp save_contact_id(member, contact_id) do system_actor = SystemActor.get_system_actor() opts = Helpers.ash_actor_opts(system_actor) case Ash.update(member, %{vereinfacht_contact_id: contact_id}, [ {:action, :set_vereinfacht_contact_id} | opts ]) do {:ok, updated} -> {:ok, updated} {:error, reason} -> {:error, reason} end end defp present_contact_id?(nil), do: false defp present_contact_id?(""), do: false defp present_contact_id?(s) when is_binary(s), do: String.trim(s) != "" defp present_contact_id?(_), do: false @doc """ Formats an API/request error reason into a short user-facing message. Used by SyncContact (flash) and GlobalSettingsLive (sync result list). """ @spec format_error(term()) :: String.t() def format_error({:http, _status, detail}) when is_binary(detail), do: "Vereinfacht: " <> detail def format_error({:http, status, _}), do: "Vereinfacht: API error (HTTP #{status})." def format_error({:request_failed, _}), do: "Vereinfacht: Request failed (e.g. connection error)." def format_error({:invalid_response, _}), do: "Vereinfacht: Invalid API response." def format_error(other), do: "Vereinfacht: " <> inspect(other) @doc """ Creates Vereinfacht contacts for all members that do not yet have a `vereinfacht_contact_id`. Uses system actor for reads and updates. Returns `{:ok, %{synced: count, errors: list}}` where errors is a list of `{member_id, reason}`. Does nothing if Vereinfacht is not configured. """ @spec sync_members_without_contact() :: {:ok, %{synced: non_neg_integer(), errors: [{String.t(), term()}]}} | {:error, :not_configured} def sync_members_without_contact do if Mv.Config.vereinfacht_configured?() do system_actor = SystemActor.get_system_actor() opts = Helpers.ash_actor_opts(system_actor) query = Member |> Ash.Query.filter(is_nil(vereinfacht_contact_id)) case Ash.read(query, opts) do {:ok, members} -> do_sync_members(members, opts) {:error, _} = err -> err end else {:error, :not_configured} end end defp do_sync_members(members, opts) do {synced, errors} = Enum.reduce(members, {0, []}, fn member, {acc_synced, acc_errors} -> {inc, new_errors} = sync_one_member(member, opts) {acc_synced + inc, acc_errors ++ new_errors} end) {:ok, %{synced: synced, errors: errors}} end defp sync_one_member(member, _opts) do case sync_member(member) do :ok -> {1, []} {:ok, _} -> {1, []} {:error, reason} -> {0, [{member.id, reason}]} end end end