diff --git a/lib/membership/member.ex b/lib/membership/member.ex index ef3aca1..828e82e 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -124,8 +124,9 @@ defmodule Mv.Membership.Member do case result do {:ok, member} -> if member.membership_fee_type_id && member.join_date do - actor = Map.get(changeset.context, :actor) - handle_cycle_generation(member, []) + # Capture initiator for audit trail (if available) + initiator = Map.get(changeset.context, :actor) + handle_cycle_generation(member, initiator: initiator) end {:error, _} -> @@ -228,8 +229,9 @@ defmodule Mv.Membership.Member do exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date) if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do - actor = Map.get(changeset.context, :actor) - handle_cycle_generation(member, []) + # Capture initiator for audit trail (if available) + initiator = Map.get(changeset.context, :actor) + handle_cycle_generation(member, initiator: initiator) end {:error, _} -> @@ -859,7 +861,13 @@ defmodule Mv.Membership.Member do {:ok, all_unpaid_cycles} -> cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today) - delete_and_regenerate_cycles(cycles_to_delete, member.id, today, skip_lock?: skip_lock?) + delete_and_regenerate_cycles( + cycles_to_delete, + member.id, + today, + actor_opts, + skip_lock?: skip_lock? + ) {:error, reason} -> {:error, reason} @@ -886,15 +894,15 @@ defmodule Mv.Membership.Member do # Deletes future cycles and regenerates them with the new type/amount # Passes today to ensure consistent date across deletion and regeneration # Returns {:ok, notifications} or {:error, reason} - # Uses system actor for cycle generation - defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do + # Uses system actor for cycle generation and deletion + defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, actor_opts, opts) do skip_lock? = Keyword.get(opts, :skip_lock?, false) if Enum.empty?(cycles_to_delete) do # No cycles to delete, just regenerate regenerate_cycles(member_id, today, skip_lock?: skip_lock?) else - case delete_cycles(cycles_to_delete) do + case delete_cycles(cycles_to_delete, actor_opts) do :ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?) {:error, reason} -> {:error, reason} end @@ -902,10 +910,11 @@ defmodule Mv.Membership.Member do end # Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise - defp delete_cycles(cycles_to_delete) do + # Uses system actor for authorization to ensure deletion always works + defp delete_cycles(cycles_to_delete, actor_opts) do delete_results = Enum.map(cycles_to_delete, fn cycle -> - Ash.destroy(cycle) + Ash.destroy(cycle, actor_opts) end) if Enum.any?(delete_results, &match?({:error, _}, &1)) do @@ -940,43 +949,58 @@ defmodule Mv.Membership.Member do # This function encapsulates the common logic for cycle generation # to avoid code duplication across different hooks # Uses system actor for cycle generation (mandatory side effect) - defp handle_cycle_generation(member, _opts) do + # Captures initiator for audit trail (if available in opts) + defp handle_cycle_generation(member, opts) do + initiator = Keyword.get(opts, :initiator) + if Mv.Config.sql_sandbox?() do - handle_cycle_generation_sync(member) + handle_cycle_generation_sync(member, initiator) else - handle_cycle_generation_async(member) + handle_cycle_generation_async(member, initiator) end end # Runs cycle generation synchronously (for test environment) - defp handle_cycle_generation_sync(member) do + defp handle_cycle_generation_sync(member, initiator) do require Logger case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member( member.id, - today: Date.utc_today() + today: Date.utc_today(), + initiator: initiator ) do {:ok, cycles, notifications} -> send_notifications_if_any(notifications) - log_cycle_generation_success(member, cycles, notifications, sync: true) + + log_cycle_generation_success(member, cycles, notifications, + sync: true, + initiator: initiator + ) {:error, reason} -> - log_cycle_generation_error(member, reason, sync: true) + log_cycle_generation_error(member, reason, sync: true, initiator: initiator) end end # Runs cycle generation asynchronously (for production environment) - defp handle_cycle_generation_async(member) do + defp handle_cycle_generation_async(member, initiator) do Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn -> - case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do + case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, + initiator: initiator + ) do {:ok, cycles, notifications} -> send_notifications_if_any(notifications) - log_cycle_generation_success(member, cycles, notifications, sync: false) + + log_cycle_generation_success(member, cycles, notifications, + sync: false, + initiator: initiator + ) {:error, reason} -> - log_cycle_generation_error(member, reason, sync: false) + log_cycle_generation_error(member, reason, sync: false, initiator: initiator) end end) + |> Task.await(:infinity) end # Sends notifications if any are present @@ -987,13 +1011,17 @@ defmodule Mv.Membership.Member do end # Logs successful cycle generation - defp log_cycle_generation_success(member, cycles, notifications, sync: sync?) do + defp log_cycle_generation_success(member, cycles, notifications, + sync: sync?, + initiator: initiator + ) do require Logger sync_label = if sync?, do: "", else: " (async)" + initiator_info = get_initiator_info(initiator) Logger.debug( - "Successfully generated cycles for member#{sync_label}", + "Successfully generated cycles for member#{sync_label} (initiator: #{initiator_info})", member_id: member.id, cycles_count: length(cycles), notifications_count: length(notifications) @@ -1001,13 +1029,14 @@ defmodule Mv.Membership.Member do end # Logs cycle generation errors - defp log_cycle_generation_error(member, reason, sync: sync?) do + defp log_cycle_generation_error(member, reason, sync: sync?, initiator: initiator) do require Logger sync_label = if sync?, do: "", else: " (async)" + initiator_info = get_initiator_info(initiator) Logger.error( - "Failed to generate cycles for member#{sync_label}", + "Failed to generate cycles for member#{sync_label} (initiator: #{initiator_info})", member_id: member.id, member_email: member.email, error: inspect(reason), @@ -1015,6 +1044,11 @@ defmodule Mv.Membership.Member do ) end + # Extracts initiator information for audit trail + defp get_initiator_info(nil), do: "system" + defp get_initiator_info(%{email: email}), do: email + defp get_initiator_info(_), do: "unknown" + # Helper to extract error type for structured logging defp error_type(%{__struct__: struct_name}), do: struct_name defp error_type(error) when is_atom(error), do: error