feat: implement automatic cycle generation for members
All checks were successful
continuous-integration/drone/push Build is passing

- Add CycleGenerator module with advisory lock mechanism
- Add SetMembershipFeeStartDate change for auto-calculation
- Extend Settings with include_joining_cycle and default_membership_fee_type_id
- Add scheduled job skeleton for future Oban integration
This commit is contained in:
Moritz 2025-12-11 21:16:47 +01:00
parent ecddf55331
commit 162d06da21
Signed by: moritz
GPG key ID: 1020A035E5DD0824
15 changed files with 2698 additions and 6 deletions

View file

@ -0,0 +1,174 @@
defmodule Mv.MembershipFees.CycleGenerationJob do
@moduledoc """
Scheduled job for generating membership fee cycles.
This module provides a skeleton for scheduled cycle generation.
In the future, this can be integrated with Oban or similar job processing libraries.
## Current Implementation
Currently provides manual execution functions that can be called:
- From IEx console for administrative tasks
- From a cron job via a Mix task
- From the admin UI (future)
## Future Oban Integration
When Oban is added to the project, this module can be converted to an Oban worker:
defmodule Mv.MembershipFees.CycleGenerationJob do
use Oban.Worker,
queue: :membership_fees,
max_attempts: 3
@impl Oban.Worker
def perform(%Oban.Job{}) do
Mv.MembershipFees.CycleGenerator.generate_cycles_for_all_members()
end
end
## Usage
# Manual execution from IEx
Mv.MembershipFees.CycleGenerationJob.run()
# Check if cycles need to be generated
Mv.MembershipFees.CycleGenerationJob.pending_members_count()
"""
alias Mv.MembershipFees.CycleGenerator
require Ash.Query
require Logger
@doc """
Runs the cycle generation job for all active members.
This is the main entry point for scheduled execution.
## Returns
- `{:ok, results}` - Map with success/failed counts
- `{:error, reason}` - Error with reason
## Examples
iex> Mv.MembershipFees.CycleGenerationJob.run()
{:ok, %{success: 45, failed: 0, total: 45}}
"""
@spec run() :: {:ok, map()} | {:error, term()}
def run do
Logger.info("Starting membership fee cycle generation job")
start_time = System.monotonic_time(:millisecond)
result = CycleGenerator.generate_cycles_for_all_members()
elapsed = System.monotonic_time(:millisecond) - start_time
case result do
{:ok, stats} ->
Logger.info(
"Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total"
)
result
{:error, reason} ->
Logger.error("Cycle generation failed: #{inspect(reason)}")
result
end
end
@doc """
Runs cycle generation with custom options.
## Options
- `:today` - Override today's date (useful for testing or catch-up)
- `:batch_size` - Number of members to process in parallel
## Examples
# Generate cycles as if today was a specific date
Mv.MembershipFees.CycleGenerationJob.run(today: ~D[2024-12-31])
# Process with smaller batch size
Mv.MembershipFees.CycleGenerationJob.run(batch_size: 5)
"""
@spec run(keyword()) :: {:ok, map()} | {:error, term()}
def run(opts) when is_list(opts) do
Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}")
start_time = System.monotonic_time(:millisecond)
result = CycleGenerator.generate_cycles_for_all_members(opts)
elapsed = System.monotonic_time(:millisecond) - start_time
case result do
{:ok, stats} ->
Logger.info(
"Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total"
)
result
{:error, reason} ->
Logger.error("Cycle generation failed: #{inspect(reason)}")
result
end
end
@doc """
Returns the count of members that need cycle generation.
A member needs cycle generation if:
- Has a membership_fee_type assigned
- Has a join_date set
- Is active (no exit_date or exit_date >= today)
## Returns
- `{:ok, count}` - Number of members needing generation
- `{:error, reason}` - Error with reason
"""
@spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, term()}
def pending_members_count do
today = Date.utc_today()
query =
Mv.Membership.Member
|> Ash.Query.filter(not is_nil(membership_fee_type_id))
|> Ash.Query.filter(not is_nil(join_date))
|> Ash.Query.filter(is_nil(exit_date) or exit_date >= ^today)
case Ash.count(query) do
{:ok, count} -> {:ok, count}
{:error, reason} -> {:error, reason}
end
end
@doc """
Generates cycles for a specific member by ID.
Useful for administrative tasks or manual corrections.
## Parameters
- `member_id` - The UUID of the member
## Returns
- `{:ok, cycles}` - List of newly created cycles
- `{:error, reason}` - Error with reason
"""
@spec run_for_member(String.t()) :: {:ok, [map()]} | {:error, term()}
def run_for_member(member_id) when is_binary(member_id) do
Logger.info("Generating cycles for member #{member_id}")
CycleGenerator.generate_cycles_for_member(member_id)
end
end