Compare commits

...

22 commits

Author SHA1 Message Date
342ebb73b9
refactor: reduce nesting depth in process_batch function
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-15 21:26:35 +01:00
c3ccecd138
fix: address notification handling review feedback
1. Fix misleading comment in async create_member path
2. Use skip_lock?: true in test case for create_member
3. Fix generate_cycles_for_all_members/1
2025-12-15 16:23:47 +01:00
666c7b8802
refactor: implement proper notification handling via after_action hooks
All checks were successful
continuous-integration/drone/push Build is passing
Refactor notification handling according to Ash best practices
2025-12-15 16:01:31 +01:00
bb5851bc23
fix: resolve notification handling and maintain after_action for cycle regeneration
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-15 15:26:39 +01:00
65d1561803
fix: correct return_notifications? logic to prevent missed notifications
All checks were successful
continuous-integration/drone/push Build is passing
Fix the logic for return_notifications? in create_cycles
2025-12-15 13:35:59 +01:00
3fad912025
refactor: reduce nesting depth in regenerate_cycles_on_type_change
Split the function into smaller, focused functions to reduce nesting depth
2025-12-15 13:35:48 +01:00
2f83f35bcc
fix: address code review points for cycle regeneration
1. Fix critical notifications bug
2. Fix today inconsistency
3. Add advisory lock around deletion
4. Improve helper function documentation
5. Improve error message UX
2025-12-15 13:35:42 +01:00
15fc897f2a
refactor: reduce complexity of with_advisory_lock function
Split the complex with_advisory_lock function into smaller, focused
functions to improve readability and reduce cyclomatic complexity
2025-12-15 13:35:28 +01:00
b81ed99571
fix: prevent deadlocks by detecting existing transactions 2025-12-15 12:41:00 +01:00
ba6b81e155
test: update test to reflect nil assignment prevention 2025-12-15 12:40:40 +01:00
446f75bcc1
fix: remove unused variable warning in ValidateSameInterval 2025-12-15 12:40:31 +01:00
bbae62313d
docs: update architecture docs for atomic cycle regeneration 2025-12-15 12:40:22 +01:00
9f33db3fed
test: add monthly interval tests for cycle calculations 2025-12-15 12:40:06 +01:00
f08b520e66
test: remove Process.sleep from type change integration tests 2025-12-15 12:39:51 +01:00
28b2be1125
fix: prevent nil assignment for membership_fee_type_id
Reject attempts to set membership_fee_type_id to nil when a current type
exists.
2025-12-15 12:39:29 +01:00
032db2a4ba
fix: implement fail-closed behavior in ValidateSameInterval
Change validation to fail closed instead of fail open when types cannot
be loaded. This prevents inconsistent data states and provides clearer
error messages to users.
2025-12-15 12:39:08 +01:00
1e5f84fd88
fix: make cycle regeneration atomic on type change
Make cycle regeneration synchronous in the same transaction as the member
update to ensure atomicity.
2025-12-15 12:38:40 +01:00
e9c53cc520 refactor: reduce nesting depth and improve code readability
All checks were successful
continuous-integration/drone/push Build is passing
- Replace Enum.map |> Enum.join with Enum.map_join for efficiency
- Extract helper functions to reduce nesting depth from 4 to 2
- Rename is_current_cycle? to current_cycle? following Elixir conventions
2025-12-15 11:50:08 +01:00
06324d77c5
feat: regenerate cycles when membership fee type changes (same interval)
Some checks failed
continuous-integration/drone/push Build is failing
- Implemented regenerate_cycles_on_type_change helper in Member resource
- Cycles that haven't ended yet (cycle_end >= today) are deleted and regenerated
- Paid and suspended cycles remain unchanged (not deleted)
- CycleGenerator reloads member with new membership_fee_type_id
- Adjusted tests to work with current cycles only (no future cycles)
- All integration tests passing

Phase 4 completed: Cycle regeneration on type change
2025-12-15 11:39:26 +01:00
7994303166
feat: add validation for same-interval membership fee type changes 2025-12-15 11:35:48 +01:00
6763d4f2eb
feat: add cycle status calculations to Member resource 2025-12-15 11:35:47 +01:00
48d98b97b2
feat: add status management actions to MembershipFeeCycle 2025-12-15 11:35:33 +01:00
13 changed files with 1835 additions and 345 deletions

View file

@ -282,8 +282,14 @@ lib/
**Implementation Pattern:** **Implementation Pattern:**
- Use Ash change module to validate - Use Ash change module to validate
- Use after_action hook to trigger regeneration - Use after_action hook to trigger regeneration synchronously
- Use transaction to ensure atomicity - Regeneration runs in the same transaction as the member update to ensure atomicity
- CycleGenerator uses advisory locks and transactions internally to prevent race conditions
**Validation Behavior:**
- Fail-closed: If membership fee types cannot be loaded during validation, the change is rejected with a validation error
- Nil assignment prevention: Attempts to set membership_fee_type_id to nil are rejected when a current type exists
--- ---
@ -417,7 +423,7 @@ lib/
**AC-TC-3:** On allowed change: future unpaid cycles regenerated **AC-TC-3:** On allowed change: future unpaid cycles regenerated
**AC-TC-4:** On allowed change: paid/suspended cycles unchanged **AC-TC-4:** On allowed change: paid/suspended cycles unchanged
**AC-TC-5:** On allowed change: amount updated to new type's amount **AC-TC-5:** On allowed change: amount updated to new type's amount
**AC-TC-6:** Change is atomic (transaction) **AC-TC-6:** Change is atomic (transaction) - ✅ Implemented: Regeneration runs synchronously in the same transaction as the member update
### Settings ### Settings

View file

@ -112,10 +112,37 @@ defmodule Mv.Membership.Member do
# but in test environment it runs synchronously for DB sandbox compatibility # but in test environment it runs synchronously for DB sandbox compatibility
change after_action(fn _changeset, member, _context -> change after_action(fn _changeset, member, _context ->
if member.membership_fee_type_id && member.join_date do if member.membership_fee_type_id && member.join_date do
generate_fn = fn -> if Application.get_env(:mv, :sql_sandbox, false) do
# Run synchronously in test environment for DB sandbox compatibility
# Use skip_lock?: true to avoid nested transactions (after_action runs within action transaction)
# Return notifications to Ash so they are sent after commit
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member.id,
today: Date.utc_today(),
skip_lock?: true
) do
{:ok, _cycles, notifications} ->
{:ok, member, notifications}
{:error, reason} ->
require Logger
Logger.warning(
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
)
{:ok, member}
end
else
# Run asynchronously in other environments
# Send notifications explicitly since they cannot be returned via after_action
Task.start(fn ->
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _cycles} -> {:ok, _cycles, notifications} ->
:ok # Send notifications manually for async case
if Enum.any?(notifications) do
Ash.Notifier.notify(notifications)
end
{:error, reason} -> {:error, reason} ->
require Logger require Logger
@ -124,18 +151,13 @@ defmodule Mv.Membership.Member do
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}" "Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
) )
end end
end end)
if Application.get_env(:mv, :sql_sandbox, false) do
# Run synchronously in test environment for DB sandbox compatibility
generate_fn.()
else
# Run asynchronously in other environments
Task.start(generate_fn)
end
end
{:ok, member} {:ok, member}
end
else
{:ok, member}
end
end) end)
end end
@ -178,44 +200,44 @@ defmodule Mv.Membership.Member do
where [changing(:user)] where [changing(:user)]
end end
# Validate that membership fee type changes only allow same-interval types
change Mv.MembershipFees.Changes.ValidateSameInterval do
where [changing(:membership_fee_type_id)]
end
# Auto-calculate membership_fee_start_date when membership_fee_type_id is set # Auto-calculate membership_fee_start_date when membership_fee_type_id is set
# and membership_fee_start_date is not already set # and membership_fee_start_date is not already set
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate do change Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
where [changing(:membership_fee_type_id)] where [changing(:membership_fee_type_id)]
end end
# Trigger cycle generation when membership_fee_type_id changes # Trigger cycle regeneration when membership_fee_type_id changes
# Note: Cycle generation runs asynchronously to not block the action, # This deletes future unpaid cycles and regenerates them with the new type/amount
# but in test environment it runs synchronously for DB sandbox compatibility # Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
# CycleGenerator uses advisory locks and transactions internally to prevent race conditions
# Notifications are returned to Ash and sent automatically after commit
change after_action(fn changeset, member, _context -> change after_action(fn changeset, member, _context ->
fee_type_changed = fee_type_changed =
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id) Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
if fee_type_changed && member.membership_fee_type_id && member.join_date do if fee_type_changed && member.membership_fee_type_id && member.join_date do
generate_fn = fn -> case regenerate_cycles_on_type_change(member) do
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do {:ok, notifications} ->
{:ok, _cycles} -> # Return notifications to Ash - they will be sent automatically after commit
:ok {:ok, member, notifications}
{:error, reason} -> {:error, reason} ->
require Logger require Logger
Logger.warning( Logger.warning(
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}" "Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
) )
end
end
if Application.get_env(:mv, :sql_sandbox, false) do
# Run synchronously in test environment for DB sandbox compatibility
generate_fn.()
else
# Run asynchronously in other environments
Task.start(generate_fn)
end
end
{:ok, member} {:ok, member}
end
else
{:ok, member}
end
end) end)
end end
@ -501,6 +523,50 @@ defmodule Mv.Membership.Member do
has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
end end
calculations do
calculate :current_cycle_status, :atom do
description "Status of the current cycle (the one that is active today)"
# Automatically load cycles with all attributes and membership_fee_type
load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
calculation fn [member], _context ->
case get_current_cycle(member) do
nil -> [nil]
cycle -> [cycle.status]
end
end
constraints one_of: [:unpaid, :paid, :suspended]
end
calculate :last_cycle_status, :atom do
description "Status of the last completed cycle (the most recent cycle that has ended)"
# Automatically load cycles with all attributes and membership_fee_type
load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
calculation fn [member], _context ->
case get_last_completed_cycle(member) do
nil -> [nil]
cycle -> [cycle.status]
end
end
constraints one_of: [:unpaid, :paid, :suspended]
end
calculate :overdue_count, :integer do
description "Count of unpaid cycles that have already ended (cycle_end < today)"
# Automatically load cycles with all attributes and membership_fee_type
load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]
calculation fn [member], _context ->
overdue = get_overdue_cycles(member)
count = if is_list(overdue), do: length(overdue), else: 0
[count]
end
end
end
# Define identities for upsert operations # Define identities for upsert operations
identities do identities do
identity :unique_email, [:email] identity :unique_email, [:email]
@ -547,6 +613,261 @@ defmodule Mv.Membership.Member do
def show_in_overview?(_), do: true def show_in_overview?(_), do: true
# Helper functions for cycle status calculations
#
# These functions expect membership_fee_cycles to be loaded with membership_fee_type
# preloaded. The calculations explicitly load this relationship, but if called
# directly, ensure membership_fee_type is loaded or the functions will return
# nil/[] when membership_fee_type is missing.
@doc false
@spec get_current_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
def get_current_cycle(member) do
today = Date.utc_today()
# Check if cycles are already loaded
cycles = Map.get(member, :membership_fee_cycles)
if is_list(cycles) and cycles != [] do
Enum.find(cycles, &current_cycle?(&1, today))
else
nil
end
end
# Checks if a cycle is the current cycle (active today)
defp current_cycle?(cycle, today) do
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(cycle.cycle_start, today) in [:lt, :eq] and
Date.compare(today, cycle_end) in [:lt, :eq]
_ ->
false
end
end
@doc false
@spec get_last_completed_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
def get_last_completed_cycle(member) do
today = Date.utc_today()
# Check if cycles are already loaded
cycles = Map.get(member, :membership_fee_cycles)
if is_list(cycles) and cycles != [] do
cycles
|> filter_completed_cycles(today)
|> sort_cycles_by_end_date()
|> List.first()
else
nil
end
end
# Filters cycles that have ended (cycle_end < today)
defp filter_completed_cycles(cycles, today) do
Enum.filter(cycles, fn cycle ->
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(today, cycle_end) == :gt
_ ->
false
end
end)
end
# Sorts cycles by end date in descending order
defp sort_cycles_by_end_date(cycles) do
Enum.sort_by(
cycles,
fn cycle ->
interval = Map.get(cycle, :membership_fee_type).interval
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
end,
{:desc, Date}
)
end
@doc false
@spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()]
def get_overdue_cycles(member) do
today = Date.utc_today()
# Check if cycles are already loaded
cycles = Map.get(member, :membership_fee_cycles)
if is_list(cycles) and cycles != [] do
filter_overdue_cycles(cycles, today)
else
[]
end
end
# Filters cycles that are unpaid and have ended (cycle_end < today)
defp filter_overdue_cycles(cycles, today) do
Enum.filter(cycles, fn cycle ->
case Map.get(cycle, :membership_fee_type) do
%{interval: interval} ->
cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt
_ ->
false
end
end)
end
# Regenerates cycles when membership fee type changes
# Deletes future unpaid cycles and regenerates them with the new type/amount
# Uses advisory lock to prevent concurrent modifications
# Returns {:ok, notifications} or {:error, reason} where notifications are collected
# to be sent after transaction commits
@doc false
def regenerate_cycles_on_type_change(member) do
today = Date.utc_today()
lock_key = :erlang.phash2(member.id)
# Use advisory lock to prevent concurrent deletion and regeneration
# This ensures atomicity when multiple updates happen simultaneously
if Mv.Repo.in_transaction?() do
regenerate_cycles_in_transaction(member, today, lock_key)
else
regenerate_cycles_new_transaction(member, today, lock_key)
end
end
# Already in transaction: use advisory lock directly
# Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles_in_transaction(member, today, lock_key) do
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
end
# Not in transaction: start new transaction with advisory lock
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
defp regenerate_cycles_new_transaction(member, today, lock_key) do
Mv.Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
{:ok, notifications} ->
# Return notifications - they will be sent by the caller
notifications
{:error, reason} ->
Mv.Repo.rollback(reason)
end
end)
|> case do
{:ok, notifications} -> {:ok, notifications}
{:error, reason} -> {:error, reason}
end
end
# Performs the actual cycle deletion and regeneration
# Returns {:ok, notifications} or {:error, reason}
# notifications are collected to be sent after transaction commits
defp do_regenerate_cycles_on_type_change(member, today, opts) do
require Ash.Query
skip_lock? = Keyword.get(opts, :skip_lock?, false)
# Find all unpaid cycles for this member
# We need to check cycle_end for each cycle using its own interval
all_unpaid_cycles_query =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.Query.filter(status == :unpaid)
|> Ash.Query.load([:membership_fee_type])
case Ash.read(all_unpaid_cycles_query) 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?)
{:error, reason} ->
{:error, reason}
end
end
# Filters cycles that haven't ended yet (cycle_end >= today)
# These are the "future" cycles that should be regenerated
defp filter_future_cycles(all_unpaid_cycles, today) do
Enum.filter(all_unpaid_cycles, fn cycle ->
case cycle.membership_fee_type do
%{interval: interval} ->
cycle_end =
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
Date.compare(today, cycle_end) in [:lt, :eq]
_ ->
false
end
end)
end
# 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}
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, 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
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
{:error, reason} -> {:error, reason}
end
end
end
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
defp delete_cycles(cycles_to_delete) do
delete_results =
Enum.map(cycles_to_delete, fn cycle ->
Ash.destroy(cycle)
end)
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
{:error, :deletion_failed}
else
:ok
end
end
# Regenerates cycles with new type/amount
# Passes today to ensure consistent date across deletion and regeneration
# skip_lock?: true means advisory lock is already set by caller
# Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles(member_id, today, opts) do
skip_lock? = Keyword.get(opts, :skip_lock?, false)
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
member_id,
today: today,
skip_lock?: skip_lock?
) do
{:ok, _cycles, notifications} when is_list(notifications) ->
{:ok, notifications}
{:error, reason} ->
{:error, reason}
end
end
# Normalizes visibility config map keys from strings to atoms. # Normalizes visibility config map keys from strings to atoms.
# JSONB in PostgreSQL converts atom keys to string keys when storing. # JSONB in PostgreSQL converts atom keys to string keys when storing.
defp normalize_visibility_config(config) when is_map(config) do defp normalize_visibility_config(config) when is_map(config) do

View file

@ -0,0 +1,148 @@
defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
@moduledoc """
Validates that membership fee type changes only allow same-interval types.
Prevents changing from yearly to monthly, etc. (MVP constraint).
## Usage
In a Member action:
update :update_member do
# ...
change Mv.MembershipFees.Changes.ValidateSameInterval
end
The change module only executes when `membership_fee_type_id` is being changed.
If the new type has a different interval than the current type, a validation error is returned.
"""
use Ash.Resource.Change
@impl true
def change(changeset, _opts, _context) do
if changing_membership_fee_type?(changeset) do
validate_interval_match(changeset)
else
changeset
end
end
# Check if membership_fee_type_id is being changed
defp changing_membership_fee_type?(changeset) do
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
end
# Validate that the new type has the same interval as the current type
defp validate_interval_match(changeset) do
current_type_id = get_current_type_id(changeset)
new_type_id = get_new_type_id(changeset)
cond do
# If no current type, allow any change (first assignment)
is_nil(current_type_id) ->
changeset
# If new type is nil, reject the change (membership_fee_type_id is required)
is_nil(new_type_id) ->
add_nil_type_error(changeset)
# Both types exist - validate intervals match
true ->
validate_intervals_match(changeset, current_type_id, new_type_id)
end
end
# Validates that intervals match when both types exist
defp validate_intervals_match(changeset, current_type_id, new_type_id) do
case get_intervals(current_type_id, new_type_id) do
{:ok, current_interval, new_interval} ->
if current_interval == new_interval do
changeset
else
add_interval_mismatch_error(changeset, current_interval, new_interval)
end
{:error, reason} ->
# Fail closed: If we can't load the types, reject the change
# This prevents inconsistent data states
add_type_validation_error(changeset, reason)
end
end
# Get current type ID from changeset data
defp get_current_type_id(changeset) do
case changeset.data do
%{membership_fee_type_id: type_id} -> type_id
_ -> nil
end
end
# Get new type ID from changeset
defp get_new_type_id(changeset) do
case Ash.Changeset.fetch_change(changeset, :membership_fee_type_id) do
{:ok, type_id} -> type_id
:error -> nil
end
end
# Get intervals for both types
defp get_intervals(current_type_id, new_type_id) do
alias Mv.MembershipFees.MembershipFeeType
case {Ash.get(MembershipFeeType, current_type_id), Ash.get(MembershipFeeType, new_type_id)} do
{{:ok, current_type}, {:ok, new_type}} ->
{:ok, current_type.interval, new_type.interval}
_ ->
{:error, :type_not_found}
end
end
# Add validation error for interval mismatch
defp add_interval_mismatch_error(changeset, current_interval, new_interval) do
current_interval_name = format_interval(current_interval)
new_interval_name = format_interval(new_interval)
message =
"Cannot change membership fee type: current type uses #{current_interval_name} interval, " <>
"new type uses #{new_interval_name} interval. Only same-interval changes are allowed."
Ash.Changeset.add_error(
changeset,
field: :membership_fee_type_id,
message: message
)
end
# Add validation error when types cannot be loaded
defp add_type_validation_error(changeset, _reason) do
message =
"Could not validate membership fee type intervals. " <>
"The current or new membership fee type no longer exists. " <>
"This may indicate a data consistency issue."
Ash.Changeset.add_error(
changeset,
field: :membership_fee_type_id,
message: message
)
end
# Add validation error when trying to set membership_fee_type_id to nil
defp add_nil_type_error(changeset) do
message = "Cannot remove membership fee type. A membership fee type is required."
Ash.Changeset.add_error(
changeset,
field: :membership_fee_type_id,
message: message
)
end
# Format interval atom to human-readable string
defp format_interval(:monthly), do: "monthly"
defp format_interval(:quarterly), do: "quarterly"
defp format_interval(:half_yearly), do: "half-yearly"
defp format_interval(:yearly), do: "yearly"
defp format_interval(interval), do: to_string(interval)
end

View file

@ -51,6 +51,36 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
primary? true primary? true
accept [:status, :notes] accept [:status, :notes]
end end
update :mark_as_paid do
description "Mark cycle as paid"
require_atomic? false
accept [:notes]
change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :paid)
end
end
update :mark_as_suspended do
description "Mark cycle as suspended"
require_atomic? false
accept [:notes]
change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :suspended)
end
end
update :mark_as_unpaid do
description "Mark cycle as unpaid (for error correction)"
require_atomic? false
accept [:notes]
change fn changeset, _context ->
Ash.Changeset.force_change_attribute(changeset, :status, :unpaid)
end
end
end end
attributes do attributes do

View file

@ -38,16 +38,17 @@ defmodule Mv.MembershipFees.CycleGenerator do
""" """
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Repo alias Mv.Repo
require Ash.Query require Ash.Query
require Logger require Logger
@type generate_result :: {:ok, [MembershipFeeCycle.t()]} | {:error, term()} @type generate_result ::
{:ok, [MembershipFeeCycle.t()], [Ash.Notifier.Notification.t()]} | {:error, term()}
@doc """ @doc """
Generates membership fee cycles for a single member. Generates membership fee cycles for a single member.
@ -62,14 +63,14 @@ defmodule Mv.MembershipFees.CycleGenerator do
## Returns ## Returns
- `{:ok, cycles}` - List of newly created cycles - `{:ok, cycles, notifications}` - List of newly created cycles and notifications
- `{:error, reason}` - Error with reason - `{:error, reason}` - Error with reason
## Examples ## Examples
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member) {:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member)
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member_id) {:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member_id)
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member, today: ~D[2024-12-31]) {:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member, today: ~D[2024-12-31])
""" """
@spec generate_cycles_for_member(Member.t() | String.t(), keyword()) :: generate_result() @spec generate_cycles_for_member(Member.t() | String.t(), keyword()) :: generate_result()
@ -84,12 +85,40 @@ defmodule Mv.MembershipFees.CycleGenerator do
def generate_cycles_for_member(%Member{} = member, opts) do def generate_cycles_for_member(%Member{} = member, opts) do
today = Keyword.get(opts, :today, Date.utc_today()) today = Keyword.get(opts, :today, Date.utc_today())
skip_lock? = Keyword.get(opts, :skip_lock?, false)
# Use advisory lock to prevent concurrent generation do_generate_cycles_with_lock(member, today, skip_lock?)
# Notifications are handled inside with_advisory_lock after transaction commits end
with_advisory_lock(member.id, fn ->
# 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?) do
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
# Just generate cycles without additional locking
do_generate_cycles(member, today) do_generate_cycles(member, today)
end
defp do_generate_cycles_with_lock(member, today, false) do
lock_key = :erlang.phash2(member.id)
Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
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)
{cycles, notifications}
{:error, reason} ->
Repo.rollback(reason)
end
end) end)
|> case do
{:ok, {cycles, notifications}} -> {:ok, cycles, notifications}
{:error, reason} -> {:error, reason}
end
end end
@doc """ @doc """
@ -150,7 +179,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
defp process_batch(batch, today) do defp process_batch(batch, today) do
batch batch
|> Task.async_stream(fn member -> |> Task.async_stream(fn member ->
{member.id, generate_cycles_for_member(member, today: today)} process_member_cycle_generation(member, today)
end) end)
|> Enum.map(fn |> Enum.map(fn
{:ok, result} -> {:ok, result} ->
@ -163,8 +192,29 @@ defmodule Mv.MembershipFees.CycleGenerator do
end) end)
end end
# Process cycle generation for a single member in batch job
# Returns {member_id, result} tuple where result is {:ok, cycles, notifications} or {:error, reason}
defp process_member_cycle_generation(member, today) do
case generate_cycles_for_member(member, today: today) do
{:ok, _cycles, notifications} = ok ->
send_notifications_for_batch_job(notifications)
{member.id, ok}
{:error, _reason} = err ->
{member.id, err}
end
end
# Send notifications for batch job
# This is a top-level job, so we need to send notifications explicitly
defp send_notifications_for_batch_job(notifications) do
if Enum.any?(notifications) do
Ash.Notifier.notify(notifications)
end
end
defp build_results_summary(results) do defp build_results_summary(results) do
success_count = Enum.count(results, fn {_id, result} -> match?({:ok, _}, result) end) success_count = Enum.count(results, fn {_id, result} -> match?({:ok, _, _}, result) end)
failed_count = Enum.count(results, fn {_id, result} -> match?({:error, _}, result) end) failed_count = Enum.count(results, fn {_id, result} -> match?({:error, _}, result) end)
%{success: success_count, failed: failed_count, total: length(results)} %{success: success_count, failed: failed_count, total: length(results)}
@ -184,43 +234,6 @@ defmodule Mv.MembershipFees.CycleGenerator do
end end
end end
defp with_advisory_lock(member_id, fun) do
# Convert UUID to integer for advisory lock (use hash)
lock_key = :erlang.phash2(member_id)
result =
Repo.transaction(fn ->
# Acquire advisory lock for this transaction
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case fun.() do
{:ok, result, notifications} when is_list(notifications) ->
# Return result and notifications separately
{result, notifications}
{:ok, result} ->
# Handle case where no notifications were returned (backward compatibility)
{result, []}
{:error, reason} ->
Repo.rollback(reason)
end
end)
# Extract result and notifications, send notifications after transaction
case result do
{:ok, {cycles, notifications}} ->
if Enum.any?(notifications) do
Ash.Notifier.notify(notifications)
end
{:ok, cycles}
{:error, reason} ->
{:error, reason}
end
end
defp do_generate_cycles(member, today) do defp do_generate_cycles(member, today) do
# Reload member with relationships to ensure fresh data # Reload member with relationships to ensure fresh data
case load_member(member.id) do case load_member(member.id) do
@ -353,6 +366,9 @@ defmodule Mv.MembershipFees.CycleGenerator do
end end
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do
# 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})
results = results =
Enum.map(cycle_starts, fn cycle_start -> Enum.map(cycle_starts, fn cycle_start ->
attrs = %{ attrs = %{
@ -363,10 +379,15 @@ defmodule Mv.MembershipFees.CycleGenerator do
status: :unpaid status: :unpaid
} }
# Return notifications to avoid warnings when creating within a transaction
case Ash.create(MembershipFeeCycle, attrs, return_notifications?: true) do case Ash.create(MembershipFeeCycle, attrs, return_notifications?: true) do
{:ok, cycle, notifications} -> {:ok, cycle, notifications} {:ok, cycle, notifications} when is_list(notifications) ->
{:error, reason} -> {:error, {cycle_start, reason}} {:ok, cycle, notifications}
{:ok, cycle} ->
{:ok, cycle, []}
{:error, reason} ->
{:error, {cycle_start, reason}}
end end
end) end)
@ -377,7 +398,6 @@ defmodule Mv.MembershipFees.CycleGenerator do
if Enum.empty?(errors) do if Enum.empty?(errors) do
successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end) successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end)
# 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)}")

View file

@ -26,7 +26,7 @@
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"}, "ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
@ -39,7 +39,7 @@
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
"live_debugger": {:hex, :live_debugger, "0.5.0", "95e0f7727d61010f7e9053923fb2a9416904a7533c2dfb36120e7684cba4c0af", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "73ebe95118d22aa402675f677abd731cb16b136d1b6ae5f4010441fb50753b14"}, "live_debugger": {:hex, :live_debugger, "0.5.0", "95e0f7727d61010f7e9053923fb2a9416904a7533c2dfb36120e7684cba4c0af", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "73ebe95118d22aa402675f677abd731cb16b136d1b6ae5f4010441fb50753b14"},
@ -80,7 +80,7 @@
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"}, "thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
"tidewave": {:hex, :tidewave, "0.5.2", "f549acffe9daeed8b6b547c232c60de987770da7f827f9b3300140dfc465b102", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "34ab3ffee7e402f05cd1eae68d0e77ed0e0d1925677971ef83634247553e8afd"}, "tidewave": {:hex, :tidewave, "0.5.2", "f549acffe9daeed8b6b547c232c60de987770da7f827f9b3300140dfc465b102", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "34ab3ffee7e402f05cd1eae68d0e77ed0e0d1925677971ef83634247553e8afd"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},

View file

@ -0,0 +1,360 @@
defmodule Mv.Membership.MemberCycleCalculationsTest do
@moduledoc """
Tests for Member cycle status calculations.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CalendarCycles
# 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
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{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 create a cycle
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "current_cycle_status" do
test "returns status of current cycle for member with active cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create a cycle that is active today (2024-01-01 to 2024-12-31)
# Assuming today is in 2024
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :paid
})
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == :paid
end
test "returns nil for member without current cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create a cycle in the past (not current)
create_cycle(member, fee_type, %{
cycle_start: ~D[2020-01-01],
status: :paid
})
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == nil
end
test "returns nil for member without cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == nil
end
test "returns status of current cycle for monthly interval" do
fee_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create a cycle that is active today (current month)
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :unpaid
})
member = Ash.load!(member, :current_cycle_status)
assert member.current_cycle_status == :unpaid
end
end
describe "last_cycle_status" do
test "returns status of last completed cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create cycles: 2022 (completed), 2023 (completed), 2024 (current)
today = Date.utc_today()
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :paid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :unpaid
})
# Current cycle
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :paid
})
member = Ash.load!(member, :last_cycle_status)
# Should return status of 2023 (last completed)
assert member.last_cycle_status == :unpaid
end
test "returns nil for member without completed cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Only create current cycle (not completed yet)
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :paid
})
member = Ash.load!(member, :last_cycle_status)
assert member.last_cycle_status == nil
end
test "returns nil for member without cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
member = Ash.load!(member, :last_cycle_status)
assert member.last_cycle_status == nil
end
test "returns status of last completed cycle for monthly interval" do
fee_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
# Create cycles: last month (completed), current month (not completed)
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
create_cycle(member, fee_type, %{
cycle_start: last_month_start,
status: :paid
})
create_cycle(member, fee_type, %{
cycle_start: current_month_start,
status: :unpaid
})
member = Ash.load!(member, :last_cycle_status)
# Should return status of last month (last completed)
assert member.last_cycle_status == :paid
end
end
describe "overdue_count" do
test "counts only unpaid cycles that have ended" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
# Create cycles:
# 2022: unpaid, ended (overdue)
# 2023: paid, ended (not overdue)
# 2024: unpaid, current (not overdue)
# 2025: unpaid, future (not overdue)
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid
})
# Current cycle
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :unpaid
})
# Future cycle (if we're not at the end of the year)
next_year = today.year + 1
if today.month < 12 or today.day < 31 do
next_year_start = Date.new!(next_year, 1, 1)
create_cycle(member, fee_type, %{
cycle_start: next_year_start,
status: :unpaid
})
end
member = Ash.load!(member, :overdue_count)
# Should only count 2022 (unpaid and ended)
assert member.overdue_count == 1
end
test "returns 0 when no overdue cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create only paid cycles
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :paid
})
member = Ash.load!(member, :overdue_count)
assert member.overdue_count == 0
end
test "returns 0 for member without cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
member = Ash.load!(member, :overdue_count)
assert member.overdue_count == 0
end
test "counts overdue cycles for monthly interval" do
fee_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
# Create cycles: two months ago (unpaid, ended), last month (paid, ended), current month (unpaid, not ended)
two_months_ago_start =
Date.add(today, -65) |> CalendarCycles.calculate_cycle_start(:monthly)
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
create_cycle(member, fee_type, %{
cycle_start: two_months_ago_start,
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: last_month_start,
status: :paid
})
create_cycle(member, fee_type, %{
cycle_start: current_month_start,
status: :unpaid
})
member = Ash.load!(member, :overdue_count)
# Should only count two_months_ago (unpaid and ended)
assert member.overdue_count == 1
end
test "counts multiple overdue cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# Create multiple unpaid, ended cycles
create_cycle(member, fee_type, %{
cycle_start: ~D[2020-01-01],
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2021-01-01],
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :unpaid
})
member = Ash.load!(member, :overdue_count)
assert member.overdue_count == 3
end
end
describe "calculations with multiple cycles" do
test "all calculations work correctly with multiple cycles" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
today = Date.utc_today()
# Create cycles: 2022 (unpaid, ended), 2023 (paid, ended), 2024 (unpaid, current)
create_cycle(member, fee_type, %{
cycle_start: ~D[2022-01-01],
status: :unpaid
})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid
})
cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
create_cycle(member, fee_type, %{
cycle_start: cycle_start,
status: :unpaid
})
member =
Ash.load!(member, [:current_cycle_status, :last_cycle_status, :overdue_count])
assert member.current_cycle_status == :unpaid
assert member.last_cycle_status == :paid
assert member.overdue_count == 1
end
end
end

View file

@ -0,0 +1,453 @@
defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
@moduledoc """
Integration tests for membership fee type changes and cycle regeneration.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CalendarCycles
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
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2023-01-15]
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "type change cycle regeneration" do
test "future unpaid cycles are regenerated with new amount" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Create cycles: one in the past (paid), one current (unpaid)
# Note: Future cycles are not automatically generated by CycleGenerator,
# so we only test with current cycle
past_cycle_start = CalendarCycles.calculate_cycle_start(~D[2023-01-01], :yearly)
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Past cycle (paid) - should remain unchanged
# Check if it already exists (from auto-generation), if not create it
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
# Update to paid
existing_cycle
|> Ash.Changeset.for_update(:update, %{status: :paid})
|> Ash.update!()
_ ->
create_cycle(member, yearly_type1, %{
cycle_start: past_cycle_start,
status: :paid,
amount: Decimal.new("100.00")
})
end
# Current cycle (unpaid) - should be regenerated
# Delete if exists (from auto-generation), then create with old amount
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
Ash.destroy!(existing_cycle)
_ ->
:ok
end
_current_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
# Change membership fee type (same interval, different amount)
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify past cycle is unchanged
past_cycle_after =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one!()
assert past_cycle_after.status == :paid
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
# Verify current cycle was deleted and regenerated
# Check that cycle with new type exists (regenerated)
new_current_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
# Verify it has the new type and amount
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00"))
assert new_current_cycle.status == :unpaid
# Verify old cycle with old type doesn't exist anymore
old_current_cycles =
MembershipFeeCycle
|> Ash.Query.filter(
member_id == ^member.id and cycle_start == ^current_cycle_start and
membership_fee_type_id == ^yearly_type1.id
)
|> Ash.read!()
assert Enum.empty?(old_current_cycles)
end
test "paid cycles remain unchanged" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Get the current cycle and mark it as paid
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Find current cycle and mark as paid
paid_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
|> Ash.Changeset.for_update(:mark_as_paid)
|> Ash.update!()
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify paid cycle is unchanged (not deleted and regenerated)
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, paid_cycle.id)
assert cycle_after.status == :paid
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
assert cycle_after.membership_fee_type_id == yearly_type1.id
end
test "suspended cycles remain unchanged" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Get the current cycle and mark it as suspended
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Find current cycle and mark as suspended
suspended_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
|> Ash.Changeset.for_update(:mark_as_suspended)
|> Ash.update!()
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify suspended cycle is unchanged (not deleted and regenerated)
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, suspended_cycle.id)
assert cycle_after.status == :suspended
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
assert cycle_after.membership_fee_type_id == yearly_type1.id
end
test "only cycles that haven't ended yet are deleted" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member without fee type first to avoid auto-generation
member = create_member(%{})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Create cycles: one in the past (unpaid, ended), one current (unpaid, not ended)
past_cycle_start =
CalendarCycles.calculate_cycle_start(
Date.add(today, -365),
:yearly
)
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Past cycle (unpaid) - should remain unchanged (cycle_start < today)
# Delete existing cycle if it exists (from auto-generation)
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
Ash.destroy!(existing_cycle)
_ ->
:ok
end
past_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: past_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
# Current cycle (unpaid) - should be regenerated (cycle_start >= today)
# Delete existing cycle if it exists (from auto-generation)
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
Ash.destroy!(existing_cycle)
_ ->
:ok
end
_current_cycle =
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
# Change membership fee type
assert {:ok, _updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Verify past cycle is unchanged
{:ok, past_cycle_after} = Ash.get(MembershipFeeCycle, past_cycle.id)
assert past_cycle_after.status == :unpaid
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
# Verify current cycle was regenerated
# Check that cycle with new type exists
new_current_cycle =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one!()
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00"))
# Verify old cycle with old type doesn't exist anymore
old_current_cycles =
MembershipFeeCycle
|> Ash.Query.filter(
member_id == ^member.id and cycle_start == ^current_cycle_start and
membership_fee_type_id == ^yearly_type1.id
)
|> Ash.read!()
assert Enum.empty?(old_current_cycles)
end
test "member calculations update after type change" do
today = Date.utc_today()
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
# Create member with join_date = today to avoid past cycles
# This ensures no overdue cycles exist
member = create_member(%{join_date: today})
# Manually assign fee type (this will trigger cycle generation)
member =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type1.id
})
|> Ash.update!()
# Cycle generation runs synchronously in the same transaction
# No need to wait for async completion
# Get current cycle start
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
# Ensure only one cycle exists (the current one)
# Delete all cycles except the current one
existing_cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(existing_cycles, fn cycle ->
if cycle.cycle_start != current_cycle_start do
Ash.destroy!(cycle)
end
end)
# Ensure current cycle exists and is unpaid
case MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|> Ash.read_one() do
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
# Update to unpaid if it's not
if existing_cycle.status != :unpaid do
existing_cycle
|> Ash.Changeset.for_update(:mark_as_unpaid)
|> Ash.update!()
end
_ ->
# Create if it doesn't exist
create_cycle(member, yearly_type1, %{
cycle_start: current_cycle_start,
status: :unpaid,
amount: Decimal.new("100.00")
})
end
# Load calculations before change
member = Ash.load!(member, [:current_cycle_status, :overdue_count])
assert member.current_cycle_status == :unpaid
assert member.overdue_count == 0
# Change membership fee type
assert {:ok, updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
# Cycle regeneration runs synchronously in the same transaction
# No need to wait for async completion
# Reload member with calculations
updated_member = Ash.load!(updated_member, [:current_cycle_status, :overdue_count])
# Calculations should still work (cycle was regenerated)
assert updated_member.current_cycle_status == :unpaid
assert updated_member.overdue_count == 0
end
end
end

View file

@ -0,0 +1,227 @@
defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
@moduledoc """
Tests for ValidateSameInterval change module.
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.Changes.ValidateSameInterval
# 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
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
describe "validate_interval_match/1" do
test "allows change to type with same interval" do
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"})
yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"})
member = create_member(%{membership_fee_type_id: yearly_type1.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> ValidateSameInterval.change(%{}, %{})
assert changeset.valid?
end
test "prevents change to type with different interval" do
yearly_type = create_fee_type(%{interval: :yearly})
monthly_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: monthly_type.id
})
|> ValidateSameInterval.change(%{}, %{})
refute changeset.valid?
assert %{errors: errors} = changeset
assert Enum.any?(errors, fn error ->
error.field == :membership_fee_type_id and
error.message =~ "yearly" and
error.message =~ "monthly"
end)
end
test "allows first assignment of membership fee type" do
yearly_type = create_fee_type(%{interval: :yearly})
# No fee type assigned
member = create_member(%{})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type.id
})
|> ValidateSameInterval.change(%{}, %{})
assert changeset.valid?
end
test "prevents removal of membership fee type" do
yearly_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: nil
})
|> ValidateSameInterval.change(%{}, %{})
refute changeset.valid?
assert %{errors: errors} = changeset
assert Enum.any?(errors, fn error ->
error.field == :membership_fee_type_id and
error.message =~ "Cannot remove membership fee type"
end)
end
test "does nothing when membership_fee_type_id is not changed" do
yearly_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
first_name: "New Name"
})
|> ValidateSameInterval.change(%{}, %{})
assert changeset.valid?
end
test "error message is clear and helpful" do
yearly_type = create_fee_type(%{interval: :yearly})
quarterly_type = create_fee_type(%{interval: :quarterly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: quarterly_type.id
})
|> ValidateSameInterval.change(%{}, %{})
error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id))
assert error.message =~ "yearly"
assert error.message =~ "quarterly"
assert error.message =~ "same-interval"
end
test "handles all interval types correctly" do
intervals = [:monthly, :quarterly, :half_yearly, :yearly]
for interval1 <- intervals,
interval2 <- intervals,
interval1 != interval2 do
type1 =
create_fee_type(%{
interval: interval1,
name: "Type #{interval1} #{System.unique_integer([:positive])}"
})
type2 =
create_fee_type(%{
interval: interval2,
name: "Type #{interval2} #{System.unique_integer([:positive])}"
})
member = create_member(%{membership_fee_type_id: type1.id})
changeset =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: type2.id
})
|> ValidateSameInterval.change(%{}, %{})
refute changeset.valid?,
"Should prevent change from #{interval1} to #{interval2}"
end
end
end
describe "integration with update_member action" do
test "validation works when updating member via update_member action" do
yearly_type = create_fee_type(%{interval: :yearly})
monthly_type = create_fee_type(%{interval: :monthly})
member = create_member(%{membership_fee_type_id: yearly_type.id})
# Try to update member with different interval type
assert {:error, %Ash.Error.Invalid{} = error} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: monthly_type.id
})
|> Ash.update()
# Check that error is about interval mismatch
error_message = extract_error_message(error)
assert error_message =~ "yearly"
assert error_message =~ "monthly"
assert error_message =~ "same-interval"
end
test "allows update when interval matches" do
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"})
yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"})
member = create_member(%{membership_fee_type_id: yearly_type1.id})
# Update member with same-interval type
assert {:ok, updated_member} =
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: yearly_type2.id
})
|> Ash.update()
assert updated_member.membership_fee_type_id == yearly_type2.id
end
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) do
errors
|> Enum.filter(&(&1.field == :membership_fee_type_id))
|> Enum.map_join(" ", & &1.message)
end
end
end

View file

@ -198,7 +198,7 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
today = ~D[2025-12-31] today = ~D[2025-12-31]
# Manually trigger generation again with fixed "today" date # Manually trigger generation again with fixed "today" date
{:ok, _} = {:ok, _, _} =
Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, today: today) Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, today: today)
final_cycles = get_member_cycles(member.id) final_cycles = get_member_cycles(member.id)

View file

@ -1,6 +1,6 @@
defmodule Mv.MembershipFees.MembershipFeeCycleTest do defmodule Mv.MembershipFees.MembershipFeeCycleTest do
@moduledoc """ @moduledoc """
Tests for MembershipFeeCycle resource. Tests for MembershipFeeCycle resource, focusing on status management actions.
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
@ -8,275 +8,200 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member alias Mv.Membership.Member
setup do # Helper to create a membership fee type
# Create a member for testing defp create_fee_type(attrs) do
{:ok, member} = default_attrs = %{
Ash.create(Member, %{ 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
defp create_member(attrs) do
default_attrs = %{
first_name: "Test", first_name: "Test",
last_name: "Member", last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com" email: "test.member.#{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 create a cycle
defp create_cycle(member, fee_type, attrs) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
describe "status defaults" do
test "status defaults to :unpaid when creating a cycle" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle =
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}) })
|> Ash.create!()
# Create a fee type for testing
{:ok, fee_type} =
Ash.create(MembershipFeeType, %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("100.00"),
interval: :monthly
})
%{member: member, fee_type: fee_type}
end
describe "create MembershipFeeCycle" do
test "can create cycle with valid attributes", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
assert {:ok, %MembershipFeeCycle{} = cycle} = Ash.create(MembershipFeeCycle, attrs)
assert cycle.cycle_start == ~D[2025-01-01]
assert Decimal.equal?(cycle.amount, Decimal.new("100.00"))
assert cycle.member_id == member.id
assert cycle.membership_fee_type_id == fee_type.id
end
test "can create cycle with notes", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
notes: "First payment cycle"
}
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
assert cycle.notes == "First payment cycle"
end
test "requires cycle_start", %{member: member, fee_type: fee_type} do
attrs = %{
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
assert error_on_field?(error, :cycle_start)
end
test "requires amount", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-01-01],
member_id: member.id,
membership_fee_type_id: fee_type.id
}
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
assert error_on_field?(error, :amount)
end
test "requires member_id", %{fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
membership_fee_type_id: fee_type.id
}
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
assert error_on_field?(error, :member_id) or error_on_field?(error, :member)
end
test "requires membership_fee_type_id", %{member: member} do
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id
}
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
assert error_on_field?(error, :membership_fee_type_id) or
error_on_field?(error, :membership_fee_type)
end
test "status defaults to :unpaid", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
assert cycle.status == :unpaid assert cycle.status == :unpaid
end end
test "validates status enum values - unpaid", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
assert cycle.status == :unpaid
end end
test "validates status enum values - paid", %{member: member, fee_type: fee_type} do describe "mark_as_paid" do
attrs = %{ test "sets status to :paid" do
cycle_start: ~D[2025-02-01], fee_type = create_fee_type(%{interval: :yearly})
amount: Decimal.new("100.00"), member = create_member(%{membership_fee_type_id: fee_type.id})
member_id: member.id, cycle = create_cycle(member, fee_type, %{status: :unpaid})
membership_fee_type_id: fee_type.id,
status: :paid
}
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs) assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_paid)
assert cycle.status == :paid assert updated.status == :paid
end end
test "validates status enum values - suspended", %{member: member, fee_type: fee_type} do test "can set notes when marking as paid" do
attrs = %{ fee_type = create_fee_type(%{interval: :yearly})
cycle_start: ~D[2025-03-01], member = create_member(%{membership_fee_type_id: fee_type.id})
amount: Decimal.new("100.00"), cycle = create_cycle(member, fee_type, %{status: :unpaid})
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :suspended
}
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs) assert {:ok, updated} =
assert cycle.status == :suspended Ash.update(cycle, %{notes: "Payment received via bank transfer"},
action: :mark_as_paid
)
assert updated.status == :paid
assert updated.notes == "Payment received via bank transfer"
end end
test "rejects invalid status values", %{member: member, fee_type: fee_type} do test "can change from suspended to paid" do
attrs = %{ fee_type = create_fee_type(%{interval: :yearly})
cycle_start: ~D[2025-01-01], member = create_member(%{membership_fee_type_id: fee_type.id})
amount: Decimal.new("100.00"), cycle = create_cycle(member, fee_type, %{status: :suspended})
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :cancelled
}
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs) assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_paid)
assert error_on_field?(error, :status) assert updated.status == :paid
end
test "rejects negative amount", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-04-01],
amount: Decimal.new("-50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
assert error_on_field?(error, :amount)
end
test "accepts zero amount", %{member: member, fee_type: fee_type} do
attrs = %{
cycle_start: ~D[2025-05-01],
amount: Decimal.new("0.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
assert Decimal.equal?(cycle.amount, Decimal.new("0.00"))
end end
end end
describe "uniqueness constraint" do describe "mark_as_suspended" do
test "cannot create duplicate cycle for same member and cycle_start", %{ test "sets status to :suspended" do
member: member, fee_type = create_fee_type(%{interval: :yearly})
fee_type: fee_type member = create_member(%{membership_fee_type_id: fee_type.id})
} do cycle = create_cycle(member, fee_type, %{status: :unpaid})
attrs = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs) assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_suspended)
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs) assert updated.status == :suspended
# Should fail due to uniqueness constraint
assert is_struct(error, Ash.Error.Invalid)
end end
test "can create cycles for same member with different cycle_start", %{ test "can set notes when marking as suspended" do
member: member, fee_type = create_fee_type(%{interval: :yearly})
fee_type: fee_type member = create_member(%{membership_fee_type_id: fee_type.id})
} do cycle = create_cycle(member, fee_type, %{status: :unpaid})
attrs1 = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
attrs2 = %{ assert {:ok, updated} =
cycle_start: ~D[2025-02-01], Ash.update(cycle, %{notes: "Waived due to special circumstances"},
amount: Decimal.new("100.00"), action: :mark_as_suspended
member_id: member.id, )
membership_fee_type_id: fee_type.id
}
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs1) assert updated.status == :suspended
assert {:ok, _cycle2} = Ash.create(MembershipFeeCycle, attrs2) assert updated.notes == "Waived due to special circumstances"
end end
test "can create cycles for different members with same cycle_start", %{fee_type: fee_type} do test "can change from paid to suspended" do
{:ok, member1} = fee_type = create_fee_type(%{interval: :yearly})
Ash.create(Member, %{ member = create_member(%{membership_fee_type_id: fee_type.id})
first_name: "Member", cycle = create_cycle(member, fee_type, %{status: :paid})
last_name: "One",
email: "member.one.#{System.unique_integer([:positive])}@example.com"
})
{:ok, member2} = assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_suspended)
Ash.create(Member, %{ assert updated.status == :suspended
first_name: "Member",
last_name: "Two",
email: "member.two.#{System.unique_integer([:positive])}@example.com"
})
attrs1 = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member1.id,
membership_fee_type_id: fee_type.id
}
attrs2 = %{
cycle_start: ~D[2025-01-01],
amount: Decimal.new("100.00"),
member_id: member2.id,
membership_fee_type_id: fee_type.id
}
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs1)
assert {:ok, _cycle2} = Ash.create(MembershipFeeCycle, attrs2)
end end
end end
# Helper to check if an error occurred on a specific field describe "mark_as_unpaid" do
defp error_on_field?(%Ash.Error.Invalid{} = error, field) do test "sets status to :unpaid" do
Enum.any?(error.errors, fn e -> fee_type = create_fee_type(%{interval: :yearly})
case e do member = create_member(%{membership_fee_type_id: fee_type.id})
%{field: ^field} -> true cycle = create_cycle(member, fee_type, %{status: :paid})
%{fields: fields} when is_list(fields) -> field in fields
_ -> false assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
end assert updated.status == :unpaid
end)
end end
defp error_on_field?(_, _), do: false test "can set notes when marking as unpaid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :paid})
assert {:ok, updated} =
Ash.update(cycle, %{notes: "Payment was reversed"}, action: :mark_as_unpaid)
assert updated.status == :unpaid
assert updated.notes == "Payment was reversed"
end
test "can change from suspended to unpaid" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
cycle = create_cycle(member, fee_type, %{status: :suspended})
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
assert updated.status == :unpaid
end
end
describe "status transitions" do
test "all status transitions are allowed" do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# unpaid -> paid
cycle1 = create_cycle(member, fee_type, %{status: :unpaid})
assert {:ok, c1} = Ash.update(cycle1, %{}, action: :mark_as_paid)
assert c1.status == :paid
# paid -> suspended
assert {:ok, c2} = Ash.update(c1, %{}, action: :mark_as_suspended)
assert c2.status == :suspended
# suspended -> unpaid
assert {:ok, c3} = Ash.update(c2, %{}, action: :mark_as_unpaid)
assert c3.status == :unpaid
# unpaid -> suspended
assert {:ok, c4} = Ash.update(c3, %{}, action: :mark_as_suspended)
assert c4.status == :suspended
# suspended -> paid
assert {:ok, c5} = Ash.update(c4, %{}, action: :mark_as_paid)
assert c5.status == :paid
# paid -> unpaid
assert {:ok, c6} = Ash.update(c5, %{}, action: :mark_as_unpaid)
assert c6.status == :unpaid
end
end
end end

View file

@ -84,7 +84,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
Enum.each(existing_cycles, &Ash.destroy!(&1)) Enum.each(existing_cycles, &Ash.destroy!(&1))
# Generate cycles with fixed "today" date # Generate cycles with fixed "today" date
{:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) {:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
end end
member member
@ -128,7 +128,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|> Ash.update!() |> Ash.update!()
# Explicitly generate cycles with fixed "today" date # Explicitly generate cycles with fixed "today" date
{:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) {:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Check all cycles # Check all cycles
cycles = get_member_cycles(member.id) cycles = get_member_cycles(member.id)
@ -158,7 +158,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|> Ash.update!() |> Ash.update!()
# Explicitly generate cycles with fixed "today" date # Explicitly generate cycles with fixed "today" date
{:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) {:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Check all cycles # Check all cycles
cycles = get_member_cycles(member.id) cycles = get_member_cycles(member.id)
@ -333,7 +333,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
# Explicitly generate cycles with fixed "today" date # Explicitly generate cycles with fixed "today" date
today = ~D[2024-06-15] today = ~D[2024-06-15]
{:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) {:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Check all cycles # Check all cycles
all_cycles = get_member_cycles(member.id) all_cycles = get_member_cycles(member.id)

View file

@ -78,7 +78,7 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
# Explicitly generate cycles with fixed "today" date to avoid date dependency # Explicitly generate cycles with fixed "today" date to avoid date dependency
today = ~D[2024-06-15] today = ~D[2024-06-15]
{:ok, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today) {:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Verify cycles were generated # Verify cycles were generated
all_cycles = get_member_cycles(member.id) all_cycles = get_member_cycles(member.id)
@ -122,7 +122,7 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
# Generate cycles with specific "today" date # Generate cycles with specific "today" date
today = ~D[2024-06-15] today = ~D[2024-06-15]
{:ok, new_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today) {:ok, new_cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Should generate only 2023 and 2024 (2022 already exists) # Should generate only 2023 and 2024 (2022 already exists)
new_cycle_years = Enum.map(new_cycles, & &1.cycle_start.year) |> Enum.sort() new_cycle_years = Enum.map(new_cycles, & &1.cycle_start.year) |> Enum.sort()
@ -144,7 +144,7 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
# Generate cycles with specific "today" date far in the future # Generate cycles with specific "today" date far in the future
today = ~D[2025-06-15] today = ~D[2025-06-15]
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today) {:ok, cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# With exit_date in 2023, should only generate 2022 and 2023 cycles # With exit_date in 2023, should only generate 2022 and 2023 cycles
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
@ -168,10 +168,10 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
today = ~D[2024-06-15] today = ~D[2024-06-15]
# First generation # First generation
{:ok, _first_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today) {:ok, _first_cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Second generation (should be idempotent) # Second generation (should be idempotent)
{:ok, second_cycles} = CycleGenerator.generate_cycles_for_member(member.id, today: today) {:ok, second_cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
# Second call should return empty list (no new cycles) # Second call should return empty list (no new cycles)
assert second_cycles == [] assert second_cycles == []
@ -411,12 +411,12 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
result2 = Task.await(task2) result2 = Task.await(task2)
# Both should succeed # Both should succeed
assert match?({:ok, _}, result1) assert match?({:ok, _, _}, result1)
assert match?({:ok, _}, result2) assert match?({:ok, _, _}, result2)
# One should have created cycles, the other should have empty list (idempotent) # One should have created cycles, the other should have empty list (idempotent)
{:ok, cycles1} = result1 {:ok, cycles1, _} = result1
{:ok, cycles2} = result2 {:ok, cycles2, _} = result2
# Combined should not have duplicates # Combined should not have duplicates
all_cycles = cycles1 ++ cycles2 all_cycles = cycles1 ++ cycles2