diff --git a/lib/membership/member.ex b/lib/membership/member.ex index ae32abd..b76fb64 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -501,6 +501,50 @@ defmodule Mv.Membership.Member do has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle 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 identities do identity :unique_email, [:email] @@ -547,6 +591,91 @@ defmodule Mv.Membership.Member do def show_in_overview?(_), do: true + # Helper functions for cycle status calculations + + @doc false + 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, 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(cycle.cycle_start, today) in [:lt, :eq] and + Date.compare(today, cycle_end) in [:lt, :eq] + + _ -> + false + end + end) + else + nil + end + end + + @doc false + 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 + |> Enum.filter(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 must have ended (cycle_end < today) + Date.compare(today, cycle_end) == :gt + + _ -> + false + end + end) + |> Enum.sort_by(fn cycle -> + interval = Map.get(cycle, :membership_fee_type).interval + Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval) + end, {:desc, Date}) + |> List.first() + else + nil + end + end + + @doc false + 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 + 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) + else + [] + end + end + # Normalizes visibility config map keys from strings to atoms. # JSONB in PostgreSQL converts atom keys to string keys when storing. defp normalize_visibility_config(config) when is_map(config) do diff --git a/test/membership/member_cycle_calculations_test.exs b/test/membership/member_cycle_calculations_test.exs new file mode 100644 index 0000000..8dcaeed --- /dev/null +++ b/test/membership/member_cycle_calculations_test.exs @@ -0,0 +1,282 @@ +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 + 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 + 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 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 +