Membership Fee Type Resource & Settings closes #278 #291

Open
moritz wants to merge 27 commits from feature/278_membership_fee_settings into main
5 changed files with 53 additions and 51 deletions
Showing only changes of commit 82897d5cd3 - Show all commits

View file

@ -153,8 +153,8 @@ lib/
**Existing Fields Used:** **Existing Fields Used:**
- `joined_at` - For calculating membership fee start - `join_date` - For calculating membership fee start
- `left_at` - For limiting cycle generation - `exit_date` - For limiting cycle generation
- These fields must remain member fields and should not be replaced by custom fields in the future - These fields must remain member fields and should not be replaced by custom fields in the future
### Settings Integration ### Settings Integration
@ -186,8 +186,9 @@ lib/
- Calculate which cycles should exist for a member - Calculate which cycles should exist for a member
- Generate missing cycles - Generate missing cycles
- Respect membership_fee_start_date and left_at boundaries - Respect membership_fee_start_date and exit_date boundaries
- Skip existing cycles (idempotent) - Skip existing cycles (idempotent)
- Use PostgreSQL advisory locks per member to prevent race conditions
**Triggers:** **Triggers:**
@ -199,17 +200,20 @@ lib/
**Algorithm Steps:** **Algorithm Steps:**
1. Retrieve member with membership fee type and dates 1. Retrieve member with membership fee type and dates
2. Determine first cycle start (based on membership_fee_start_date) 2. Determine generation start point:
3. Calculate all cycle starts from first to today (or left_at) - If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`)
4. Query existing cycles for member - If cycles exist: Start from the cycle AFTER the last existing one
5. Generate missing cycles with current membership fee type's amount 3. Generate all cycle starts from the determined start point to today (or `exit_date`)
6. Insert new cycles (batch operation) 4. Create new cycles with current membership fee type's amount
5. Use PostgreSQL advisory locks per member to prevent race conditions
**Edge Case Handling:** **Edge Case Handling:**
- If membership_fee_start_date is NULL: Calculate from joined_at + global setting - If membership_fee_start_date is NULL: Calculate from join_date + global setting
- If left_at is set: Stop generation at left_at - If exit_date is set: Stop generation at exit_date
- If membership fee type changes: Handled separately by regeneration logic - If membership fee type changes: Handled separately by regeneration logic
- **Gap Handling:** If cycles were explicitly deleted (gaps exist), they are NOT recreated.
The generator always continues from the cycle AFTER the last existing cycle, regardless of gaps.
### Calendar Cycle Calculations ### Calendar Cycle Calculations
@ -381,7 +385,7 @@ lib/
**AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default) **AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default)
**AC-M-2:** Member has membership_fee_start_date field (nullable) **AC-M-2:** Member has membership_fee_start_date field (nullable)
**AC-M-3:** New members get default membership fee type from global setting **AC-M-3:** New members get default membership fee type from global setting
**AC-M-4:** membership_fee_start_date auto-set based on joined_at and global setting **AC-M-4:** membership_fee_start_date auto-set based on join_date and global setting
**AC-M-5:** Admin can manually override membership_fee_start_date **AC-M-5:** Admin can manually override membership_fee_start_date
**AC-M-6:** Cannot change to membership fee type with different interval (MVP) **AC-M-6:** Cannot change to membership fee type with different interval (MVP)
@ -391,7 +395,7 @@ lib/
**AC-CG-2:** Cycles generated when member created (via change hook) **AC-CG-2:** Cycles generated when member created (via change hook)
**AC-CG-3:** Scheduled job generates missing cycles daily **AC-CG-3:** Scheduled job generates missing cycles daily
**AC-CG-4:** Generation respects membership_fee_start_date **AC-CG-4:** Generation respects membership_fee_start_date
**AC-CG-5:** Generation stops at left_at if member exited **AC-CG-5:** Generation stops at exit_date if member exited
**AC-CG-6:** Generation is idempotent (skips existing cycles) **AC-CG-6:** Generation is idempotent (skips existing cycles)
**AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year) **AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year)
**AC-CG-8:** Amount comes from membership_fee_type at generation time **AC-CG-8:** Amount comes from membership_fee_type at generation time
@ -472,8 +476,9 @@ lib/
- Correct cycle_start calculation for all interval types - Correct cycle_start calculation for all interval types
- Correct cycle count from start to end date - Correct cycle count from start to end date
- Respects membership_fee_start_date boundary - Respects membership_fee_start_date boundary
- Respects left_at boundary - Respects exit_date boundary
- Skips existing cycles (idempotent) - Skips existing cycles (idempotent)
- Does not fill gaps when cycles were deleted
- Handles edge dates (year boundaries, leap years) - Handles edge dates (year boundaries, leap years)
**Calendar Cycles Tests:** **Calendar Cycles Tests:**

View file

@ -120,7 +120,7 @@ This document provides a comprehensive overview of the Membership Fees system. I
``` ```
- membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings) - membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings)
- membership_fee_start_date (Date, nullable) - When to start generating membership fees - membership_fee_start_date (Date, nullable) - When to start generating membership fees
- left_at (Date, nullable) - Exit date (existing) - exit_date (Date, nullable) - Exit date (existing)
``` ```
**Logic for membership_fee_start_date:** **Logic for membership_fee_start_date:**
@ -167,16 +167,17 @@ value: UUID (Required) - Default membership fee type for new members
**Algorithm:** **Algorithm:**
Lock the whole cycle table for the duration of the algorithm Use PostgreSQL advisory locks per member to prevent race conditions
1. Get `member.membership_fee_start_date` and member's membership fee type 1. Get `member.membership_fee_start_date` and member's membership fee type
2. Generate cycles until today (or `left_at` if present): 2. Determine generation start point:
- If no cycle exists: - If NO cycles exist: Start from `membership_fee_start_date`
- Generate all cycles from `membership_fee_start_date` - If cycles exist: Start from the cycle AFTER the last existing one
- else: 3. Generate cycles until today (or `exit_date` if present):
- Generate all cycles from last existing cycle - Use the interval to generate the cycles
- use the interval to generate the cycles - **Note:** If cycles were explicitly deleted (gaps exist), they are NOT recreated.
3. Set `amount` to current membership fee type's amount The generator always continues from the cycle AFTER the last existing cycle.
4. Set `amount` to current membership fee type's amount
**Example (Yearly):** **Example (Yearly):**
@ -246,7 +247,7 @@ suspended → unpaid
**Logic:** **Logic:**
- Cycles only generated until `member.left_at` - Cycles only generated until `member.exit_date`
- Existing cycles remain visible - Existing cycles remain visible
- Unpaid exit cycle can be marked as "suspended" - Unpaid exit cycle can be marked as "suspended"

View file

@ -64,13 +64,26 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
start_date = calculate_start_date(join_date, interval, include_joining_cycle) start_date = calculate_start_date(join_date, interval, include_joining_cycle)
Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date) Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date)
else else
{:error, reason} -> {:error, :join_date_not_set} ->
# Log warning for debugging purposes, but don't fail the action # Missing join_date is expected for partial creates
# Missing join_date or membership_fee_type_id is expected for partial creates changeset
unless reason in [:join_date_not_set, :membership_fee_type_not_set] do
Logger.warning("Could not auto-set membership_fee_start_date: #{inspect(reason)}")
end
{:error, :membership_fee_type_not_set} ->
# Missing membership_fee_type_id is expected for partial creates
changeset
{:error, :membership_fee_type_not_found} ->
# This is a data integrity error - membership_fee_type_id references non-existent type
# Return changeset error to fail the action
Ash.Changeset.add_error(
changeset,
field: :membership_fee_type_id,
message: "not found"
)
{:error, reason} ->
# Log warning for other unexpected errors
Logger.warning("Could not auto-set membership_fee_start_date: #{inspect(reason)}")
changeset changeset
end end
end end

View file

@ -371,19 +371,20 @@ defmodule Mv.MembershipFees.CycleGenerator do
end) end)
{successes, errors} = Enum.split_with(results, &match?({:ok, _, _}, &1)) {successes, errors} = Enum.split_with(results, &match?({:ok, _, _}, &1))
successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end)
all_notifications = all_notifications =
Enum.flat_map(successes, fn {:ok, _cycle, notifications} -> notifications end) Enum.flat_map(successes, fn {:ok, _cycle, notifications} -> notifications end)
if Enum.empty?(errors) do if Enum.empty?(errors) do
successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end)
# Return cycles and notifications to be sent after transaction commits # Return cycles and notifications to be sent after transaction commits
{:ok, successful_cycles, all_notifications} {:ok, successful_cycles, all_notifications}
else else
Logger.warning("Some cycles failed to create: #{inspect(errors)}") Logger.warning("Some cycles failed to create: #{inspect(errors)}")
# Return partial failure with both successful and failed cycles # Return partial failure with errors
# This allows callers to decide how to handle partial failures # Note: When this error occurs, the transaction will be rolled back,
{:error, {:partial_failure, successful_cycles, errors}} # so no cycles were actually persisted in the database
{:error, {:partial_failure, errors}}
end end
end end
end end

View file

@ -58,9 +58,6 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
}) })
|> Ash.create!() |> Ash.create!()
# Wait for async cycle generation
Process.sleep(300)
cycles = get_member_cycles(member.id) cycles = get_member_cycles(member.id)
# Should have cycles for 2023 and 2024 (and possibly current year) # Should have cycles for 2023 and 2024 (and possibly current year)
@ -89,9 +86,6 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
}) })
|> Ash.create!() |> Ash.create!()
# Wait for potential async cycle generation
Process.sleep(200)
cycles = get_member_cycles(member.id) cycles = get_member_cycles(member.id)
assert cycles == [] assert cycles == []
@ -112,9 +106,6 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
}) })
|> Ash.create!() |> Ash.create!()
# Wait for potential async cycle generation
Process.sleep(200)
cycles = get_member_cycles(member.id) cycles = get_member_cycles(member.id)
assert cycles == [] assert cycles == []
@ -145,9 +136,6 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id}) |> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!() |> Ash.update!()
# Wait for async cycle generation
Process.sleep(300)
cycles = get_member_cycles(member.id) cycles = get_member_cycles(member.id)
# Should have generated cycles # Should have generated cycles
@ -178,9 +166,6 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
members = Enum.map(tasks, &Task.await/1) members = Enum.map(tasks, &Task.await/1)
# Wait for all async cycle generations
Process.sleep(500)
# Each member should have cycles # Each member should have cycles
Enum.each(members, fn member -> Enum.each(members, fn member ->
cycles = get_member_cycles(member.id) cycles = get_member_cycles(member.id)
@ -205,9 +190,6 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
}) })
|> Ash.create!() |> Ash.create!()
# Wait for async cycle generation
Process.sleep(300)
initial_cycles = get_member_cycles(member.id) initial_cycles = get_member_cycles(member.id)
initial_count = length(initial_cycles) initial_count = length(initial_cycles)