mitgliederverwaltung/lib/mv/vereinfacht/vereinfacht.ex
Moritz 124857cc9c
Vereinfacht: update existing contact when found by email
Before saving contact_id to member, sync current data to the
existing contact so Vereinfacht stays up to date.
2026-02-23 19:54:42 +01:00

162 lines
5.2 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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