Before saving contact_id to member, sync current data to the existing contact so Vereinfacht stays up to date.
162 lines
5.2 KiB
Elixir
162 lines
5.2 KiB
Elixir
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
|
||
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(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
|