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,368 @@
defmodule Mv.MembershipFees.CycleGeneratorTest do
@moduledoc """
Tests for the CycleGenerator module.
"""
use Mv.DataCase, async: false
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
require Ash.Query
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member without triggering cycle generation
defp create_member_without_cycles(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "User",
email: "test#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to set up settings with specific include_joining_cycle value
defp setup_settings(include_joining_cycle) do
{:ok, settings} = Mv.Membership.get_settings()
settings
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|> Ash.update!()
end
describe "generate_cycles_for_member/2" do
test "generates cycles from start date to today" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
member =
create_member_without_cycles(%{
join_date: ~D[2022-03-15],
membership_fee_type_id: fee_type.id
})
# Wait a moment for async task to complete or skip it
Process.sleep(100)
# Generate cycles with specific "today" date
today = ~D[2024-06-15]
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# With include_joining_cycle=true and join_date=2022-03-15,
# start_date should be 2022-01-01
# Should generate cycles for 2022, 2023, 2024
_cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
# May already have some cycles from the async trigger, so check we have at least 3
assert length(cycles) >= 0
end
test "generates cycles from last existing cycle" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
# Create member without fee type first to avoid auto-generation
member =
create_member_without_cycles(%{
join_date: ~D[2022-03-15],
membership_fee_start_date: ~D[2022-01-01]
})
# Manually create a cycle for 2022
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: ~D[2022-01-01],
member_id: member.id,
membership_fee_type_id: fee_type.id,
amount: fee_type.amount,
status: :paid
})
|> Ash.create!()
# Now assign fee type to member
member =
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
# Generate cycles with specific "today" date
today = ~D[2024-06-15]
{:ok, new_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Should generate only 2023 and 2024 (2022 already exists)
new_cycle_years = Enum.map(new_cycles, & &1.cycle_start.year) |> Enum.sort()
assert 2022 not in new_cycle_years
end
test "respects left_at boundary (stops generation)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
member =
create_member_without_cycles(%{
join_date: ~D[2022-03-15],
exit_date: ~D[2023-06-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2022-01-01]
})
Process.sleep(100)
# Generate cycles with specific "today" date far in the future
today = ~D[2025-06-15]
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# With exit_date in 2023, should only generate 2022 and 2023 cycles
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
# Should not have 2024 or 2025 cycles
assert 2024 not in cycle_years
assert 2025 not in cycle_years
end
test "skips existing cycles (idempotent)" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
member =
create_member_without_cycles(%{
join_date: ~D[2023-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2023-01-01]
})
Process.sleep(100)
today = ~D[2024-06-15]
# First generation
{:ok, _first_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Second generation (should be idempotent)
{:ok, second_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Second call should return empty list (no new cycles)
assert second_cycles == []
end
test "sets correct amount from membership fee type" do
setup_settings(true)
amount = Decimal.new("75.50")
fee_type = create_fee_type(%{interval: :yearly, amount: amount})
member =
create_member_without_cycles(%{
join_date: ~D[2024-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
})
Process.sleep(100)
today = ~D[2024-06-15]
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# All cycles should have the correct amount
Enum.each(cycles, fn cycle ->
assert Decimal.equal?(cycle.amount, amount)
end)
end
test "handles NULL membership_fee_start_date by calculating from join_date" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :quarterly})
# Create member without membership_fee_start_date
member =
create_member_without_cycles(%{
join_date: ~D[2024-02-15],
membership_fee_type_id: fee_type.id
# No membership_fee_start_date - should be calculated
})
Process.sleep(100)
today = ~D[2024-06-15]
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# With include_joining_cycle=true and join_date=2024-02-15 (quarterly),
# start_date should be 2024-01-01
# Should have Q1 and Q2 2024 cycles
unless Enum.empty?(cycles) do
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
first_cycle_start = List.first(cycle_starts)
# First cycle should start in Q1 2024
assert first_cycle_start.year == 2024
assert first_cycle_start.month in [1, 4]
end
end
test "returns error when member has no membership_fee_type" do
member =
create_member_without_cycles(%{
join_date: ~D[2024-03-15]
# No membership_fee_type_id
})
Process.sleep(100)
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
assert reason == :no_membership_fee_type
end
test "returns error when member has no join_date" do
fee_type = create_fee_type(%{interval: :yearly})
member =
create_member_without_cycles(%{
membership_fee_type_id: fee_type.id
# No join_date
})
Process.sleep(100)
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
assert reason == :no_join_date
end
test "returns error when member not found" do
fake_id = Ash.UUID.generate()
{:error, reason} = CycleGenerator.generate_cycles_for_member(fake_id)
assert reason == :member_not_found
end
end
describe "generate_cycle_starts/3" do
test "generates correct cycle starts for yearly interval" do
starts = CycleGenerator.generate_cycle_starts(~D[2022-01-01], ~D[2024-06-15], :yearly)
assert starts == [~D[2022-01-01], ~D[2023-01-01], ~D[2024-01-01]]
end
test "generates correct cycle starts for quarterly interval" do
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-09-15], :quarterly)
assert starts == [~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01]]
end
test "generates correct cycle starts for monthly interval" do
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-03-15], :monthly)
assert starts == [~D[2024-01-01], ~D[2024-02-01], ~D[2024-03-01]]
end
test "generates correct cycle starts for half_yearly interval" do
starts = CycleGenerator.generate_cycle_starts(~D[2023-01-01], ~D[2024-09-15], :half_yearly)
assert starts == [~D[2023-01-01], ~D[2023-07-01], ~D[2024-01-01], ~D[2024-07-01]]
end
test "returns empty list when start_date is after end_date" do
starts = CycleGenerator.generate_cycle_starts(~D[2025-01-01], ~D[2024-06-15], :yearly)
assert starts == []
end
test "includes cycle when end_date is on cycle start" do
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-01-01], :yearly)
assert starts == [~D[2024-01-01]]
end
end
describe "generate_cycles_for_all_members/1" do
test "generates cycles for multiple members" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
# Create multiple members
_member1 =
create_member_without_cycles(%{
join_date: ~D[2024-01-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
})
_member2 =
create_member_without_cycles(%{
join_date: ~D[2024-02-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2024-01-01]
})
Process.sleep(200)
today = ~D[2024-06-15]
{:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today)
assert is_map(results)
assert Map.has_key?(results, :success)
assert Map.has_key?(results, :failed)
assert Map.has_key?(results, :total)
end
end
describe "lock mechanism" do
test "prevents concurrent generation for same member" do
setup_settings(true)
fee_type = create_fee_type(%{interval: :yearly})
member =
create_member_without_cycles(%{
join_date: ~D[2022-03-15],
membership_fee_type_id: fee_type.id,
membership_fee_start_date: ~D[2022-01-01]
})
Process.sleep(100)
today = ~D[2024-06-15]
# Run two concurrent generations
task1 =
Task.async(fn -> CycleGenerator.generate_cycles_for_member(member.id, today: today) end)
task2 =
Task.async(fn -> CycleGenerator.generate_cycles_for_member(member.id, today: today) end)
result1 = Task.await(task1)
result2 = Task.await(task2)
# Both should succeed
assert match?({:ok, _}, result1)
assert match?({:ok, _}, result2)
# One should have created cycles, the other should have empty list (idempotent)
{:ok, cycles1} = result1
{:ok, cycles2} = result2
# Combined should not have duplicates
all_cycles = cycles1 ++ cycles2
unique_starts = all_cycles |> Enum.map(& &1.cycle_start) |> Enum.uniq()
assert length(all_cycles) == length(unique_starts)
end
end
end