Use system actor for cycle generation

Update cycle generator, member hooks, and job to use system actor.
Remove actor parameters as cycle generation is a mandatory side effect.
This commit is contained in:
Moritz 2026-01-20 22:09:20 +01:00
parent f0169c95b7
commit c64b74588f
3 changed files with 65 additions and 87 deletions

View file

@ -30,12 +30,11 @@ defmodule Mv.MembershipFees.CycleGenerator do
## Authorization
This module runs systemically and accepts optional actor parameters.
When called from hooks/changes, actor is extracted from changeset context.
When called directly, actor should be provided for proper authorization.
This module runs systemically and uses the system actor for all operations.
This ensures that cycle generation always works, regardless of user permissions.
All functions accept an optional `actor` parameter in the `opts` keyword list
that is passed to Ash operations to ensure proper authorization checks are performed.
All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass
user permission checks, as cycle generation is a mandatory side effect.
## Examples
@ -47,6 +46,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
"""
alias Mv.Helpers
alias Mv.Helpers.SystemActor
alias Mv.Membership.Member
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
@ -86,9 +87,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
def generate_cycles_for_member(member_or_id, opts \\ [])
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
actor = Keyword.get(opts, :actor)
case load_member(member_id, actor: actor) do
case load_member(member_id) do
{:ok, member} -> generate_cycles_for_member(member, opts)
{:error, reason} -> {:error, reason}
end
@ -98,27 +97,25 @@ defmodule Mv.MembershipFees.CycleGenerator do
today = Keyword.get(opts, :today, Date.utc_today())
skip_lock? = Keyword.get(opts, :skip_lock?, false)
do_generate_cycles_with_lock(member, today, skip_lock?, opts)
do_generate_cycles_with_lock(member, today, skip_lock?)
end
# Generate cycles with lock handling
# Returns {:ok, cycles, notifications} - notifications are never sent here,
# they should be returned to the caller (e.g., via after_action hook)
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
# Just generate cycles without additional locking
actor = Keyword.get(opts, :actor)
do_generate_cycles(member, today, actor: actor)
do_generate_cycles(member, today)
end
defp do_generate_cycles_with_lock(member, today, false, opts) do
defp do_generate_cycles_with_lock(member, today, false) do
lock_key = :erlang.phash2(member.id)
actor = Keyword.get(opts, :actor)
Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_generate_cycles(member, today, actor: actor) do
case do_generate_cycles(member, today) do
{:ok, cycles, notifications} ->
# Return cycles and notifications - do NOT send notifications here
# They will be sent by the caller (e.g., via after_action hook)
@ -168,12 +165,15 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Query ALL members with fee type assigned (including inactive/left members)
# The exit_date boundary is applied during cycle generation, not here.
# This allows catch-up generation for members who left but are missing cycles.
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
query =
Member
|> Ash.Query.filter(not is_nil(membership_fee_type_id))
|> Ash.Query.filter(not is_nil(join_date))
case Ash.read(query) do
case Ash.read(query, opts) do
{:ok, members} ->
results = process_members_in_batches(members, batch_size, today)
{:ok, build_results_summary(results)}
@ -235,33 +235,25 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Private functions
defp load_member(member_id, opts) do
actor = Keyword.get(opts, :actor)
defp load_member(member_id) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
query =
Member
|> Ash.Query.filter(id == ^member_id)
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
result =
if actor do
Ash.read_one(query, actor: actor)
else
Ash.read_one(query)
end
case result do
case Ash.read_one(query, opts) do
{:ok, nil} -> {:error, :member_not_found}
{:ok, member} -> {:ok, member}
{:error, reason} -> {:error, reason}
end
end
defp do_generate_cycles(member, today, opts) do
actor = Keyword.get(opts, :actor)
defp do_generate_cycles(member, today) do
# Reload member with relationships to ensure fresh data
case load_member(member.id, actor: actor) do
case load_member(member.id) do
{:ok, member} ->
cond do
is_nil(member.membership_fee_type_id) ->
@ -271,7 +263,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
{:error, :no_join_date}
true ->
generate_missing_cycles(member, today, actor: actor)
generate_missing_cycles(member, today)
end
{:error, reason} ->
@ -279,8 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp generate_missing_cycles(member, today, opts) do
actor = Keyword.get(opts, :actor)
defp generate_missing_cycles(member, today) do
fee_type = member.membership_fee_type
interval = fee_type.interval
amount = fee_type.amount
@ -296,7 +287,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Only generate if start_date <= end_date
if start_date && Date.compare(start_date, end_date) != :gt do
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
create_cycles(cycle_starts, member.id, fee_type.id, amount, actor: actor)
create_cycles(cycle_starts, member.id, fee_type.id, amount)
else
{:ok, [], []}
end
@ -391,8 +382,10 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
actor = Keyword.get(opts, :actor)
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
# Always use return_notifications?: true to collect notifications
# Notifications will be returned to the caller, who is responsible for
# sending them (e.g., via after_action hook returning {:ok, result, notifications})
@ -407,7 +400,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
}
handle_cycle_creation_result(
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true, actor: actor),
Ash.create(MembershipFeeCycle, attrs, [return_notifications?: true] ++ opts),
cycle_start
)
end)