feat: add cycle status calculations to Member resource

This commit is contained in:
Moritz 2025-12-12 19:58:52 +01:00
parent 48d98b97b2
commit 6763d4f2eb
Signed by: moritz
GPG key ID: 1020A035E5DD0824
2 changed files with 411 additions and 0 deletions

View file

@ -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

View 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