feat: add cycle status calculations to Member resource
This commit is contained in:
parent
48d98b97b2
commit
6763d4f2eb
2 changed files with 411 additions and 0 deletions
|
|
@ -501,6 +501,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 +591,91 @@ defmodule Mv.Membership.Member do
|
||||||
|
|
||||||
def show_in_overview?(_), do: true
|
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.
|
# 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
|
||||||
|
|
|
||||||
282
test/membership/member_cycle_calculations_test.exs
Normal file
282
test/membership/member_cycle_calculations_test.exs
Normal file
|
|
@ -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
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue