All checks were successful
continuous-integration/drone/push Build is passing
- Remove Process.sleep calls from integration tests (tests run synchronously in SQL sandbox) - Improve error handling: membership_fee_type_not_found now returns changeset error instead of just logging - Clarify partial_failure documentation: successful_cycles are not persisted on rollback - Update documentation: joined_at → join_date, left_at → exit_date - Document PostgreSQL advisory locks per member (not whole table lock) - Document gap handling: explicitly deleted cycles are not recreated
174 lines
5.7 KiB
Elixir
174 lines
5.7 KiB
Elixir
defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
|
@moduledoc """
|
|
Ash change module that automatically calculates and sets the membership_fee_start_date.
|
|
|
|
## Logic
|
|
|
|
1. Only executes if `membership_fee_start_date` is not manually set
|
|
2. Requires both `join_date` and `membership_fee_type_id` to be present
|
|
3. Reads `include_joining_cycle` setting from global Settings
|
|
4. Reads `interval` from the assigned `membership_fee_type`
|
|
5. Calculates the start date:
|
|
- If `include_joining_cycle = true`: First day of the joining cycle
|
|
- If `include_joining_cycle = false`: First day of the next cycle after joining
|
|
|
|
## Usage
|
|
|
|
In a Member action:
|
|
|
|
create :create_member do
|
|
# ...
|
|
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
|
end
|
|
|
|
The change module handles all prerequisite checks internally (join_date, membership_fee_type_id).
|
|
If any required data is missing, the changeset is returned unchanged with a warning logged.
|
|
"""
|
|
use Ash.Resource.Change
|
|
|
|
require Logger
|
|
|
|
alias Mv.MembershipFees.CalendarCycles
|
|
|
|
@impl true
|
|
def change(changeset, _opts, _context) do
|
|
# Only calculate if membership_fee_start_date is not already set
|
|
if has_start_date?(changeset) do
|
|
changeset
|
|
else
|
|
calculate_and_set_start_date(changeset)
|
|
end
|
|
end
|
|
|
|
# Check if membership_fee_start_date is already set (either in changeset or data)
|
|
defp has_start_date?(changeset) do
|
|
# Check if it's being set in this changeset
|
|
case Ash.Changeset.fetch_change(changeset, :membership_fee_start_date) do
|
|
{:ok, date} when not is_nil(date) ->
|
|
true
|
|
|
|
_ ->
|
|
# Check if it already exists in the data (for updates)
|
|
case changeset.data do
|
|
%{membership_fee_start_date: date} when not is_nil(date) -> true
|
|
_ -> false
|
|
end
|
|
end
|
|
end
|
|
|
|
defp calculate_and_set_start_date(changeset) do
|
|
with {:ok, join_date} <- get_join_date(changeset),
|
|
{:ok, membership_fee_type_id} <- get_membership_fee_type_id(changeset),
|
|
{:ok, interval} <- get_interval(membership_fee_type_id),
|
|
{:ok, include_joining_cycle} <- get_include_joining_cycle() do
|
|
start_date = calculate_start_date(join_date, interval, include_joining_cycle)
|
|
Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date)
|
|
else
|
|
{:error, :join_date_not_set} ->
|
|
# Missing join_date is expected for partial creates
|
|
changeset
|
|
|
|
{:error, :membership_fee_type_not_set} ->
|
|
# Missing membership_fee_type_id is expected for partial creates
|
|
changeset
|
|
|
|
{:error, :membership_fee_type_not_found} ->
|
|
# This is a data integrity error - membership_fee_type_id references non-existent type
|
|
# Return changeset error to fail the action
|
|
Ash.Changeset.add_error(
|
|
changeset,
|
|
field: :membership_fee_type_id,
|
|
message: "not found"
|
|
)
|
|
|
|
{:error, reason} ->
|
|
# Log warning for other unexpected errors
|
|
Logger.warning("Could not auto-set membership_fee_start_date: #{inspect(reason)}")
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp get_join_date(changeset) do
|
|
# First check the changeset for changes
|
|
case Ash.Changeset.fetch_change(changeset, :join_date) do
|
|
{:ok, date} when not is_nil(date) ->
|
|
{:ok, date}
|
|
|
|
_ ->
|
|
# Then check existing data
|
|
case changeset.data do
|
|
%{join_date: date} when not is_nil(date) -> {:ok, date}
|
|
_ -> {:error, :join_date_not_set}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp get_membership_fee_type_id(changeset) do
|
|
# First check the changeset for changes
|
|
case Ash.Changeset.fetch_change(changeset, :membership_fee_type_id) do
|
|
{:ok, id} when not is_nil(id) ->
|
|
{:ok, id}
|
|
|
|
_ ->
|
|
# Then check existing data
|
|
case changeset.data do
|
|
%{membership_fee_type_id: id} when not is_nil(id) -> {:ok, id}
|
|
_ -> {:error, :membership_fee_type_not_set}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp get_interval(membership_fee_type_id) do
|
|
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id) do
|
|
{:ok, %{interval: interval}} -> {:ok, interval}
|
|
{:error, _} -> {:error, :membership_fee_type_not_found}
|
|
end
|
|
end
|
|
|
|
defp get_include_joining_cycle do
|
|
case Mv.Membership.get_settings() do
|
|
{:ok, %{include_joining_cycle: include}} -> {:ok, include}
|
|
{:error, _} -> {:ok, true}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Calculates the membership fee start date based on join date, interval, and settings.
|
|
|
|
## Parameters
|
|
|
|
- `join_date` - The date the member joined
|
|
- `interval` - The billing interval (:monthly, :quarterly, :half_yearly, :yearly)
|
|
- `include_joining_cycle` - Whether to include the joining cycle
|
|
|
|
## Returns
|
|
|
|
The calculated start date (first day of the appropriate cycle).
|
|
|
|
## Examples
|
|
|
|
iex> calculate_start_date(~D[2024-03-15], :yearly, true)
|
|
~D[2024-01-01]
|
|
|
|
iex> calculate_start_date(~D[2024-03-15], :yearly, false)
|
|
~D[2025-01-01]
|
|
|
|
iex> calculate_start_date(~D[2024-03-15], :quarterly, true)
|
|
~D[2024-01-01]
|
|
|
|
iex> calculate_start_date(~D[2024-03-15], :quarterly, false)
|
|
~D[2024-04-01]
|
|
|
|
"""
|
|
@spec calculate_start_date(Date.t(), CalendarCycles.interval(), boolean()) :: Date.t()
|
|
def calculate_start_date(join_date, interval, include_joining_cycle) do
|
|
if include_joining_cycle do
|
|
# Start date is the first day of the joining cycle
|
|
CalendarCycles.calculate_cycle_start(join_date, interval)
|
|
else
|
|
# Start date is the first day of the next cycle after joining
|
|
join_cycle_start = CalendarCycles.calculate_cycle_start(join_date, interval)
|
|
CalendarCycles.next_cycle_start(join_cycle_start, interval)
|
|
end
|
|
end
|
|
end
|