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