Membership Fee 6 - UI Components & LiveViews closes #280 #304

Open
moritz wants to merge 65 commits from feature/280_membership_fee_ui into main
2 changed files with 171 additions and 35 deletions
Showing only changes of commit 239d784f3c - Show all commits

View file

@ -6,6 +6,7 @@
alias Mv.Membership
alias Mv.Accounts
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.CycleGenerator
# Create example membership fee types
for fee_type_attrs <- [
@ -131,7 +132,10 @@ Accounts.create_user!(%{email: "admin@mv.local"}, upsert?: true, upsert_identity
all_fee_types = MembershipFeeType |> Ash.read!() |> Enum.to_list()
# Create sample members for testing - use upsert to prevent duplicates
# Assign each member to a fee type using round-robin distribution
# Member 1: Hans - All cycles paid
# Member 2: Greta - All cycles unpaid
# Member 3: Friedrich - Mixed cycles (paid, unpaid, suspended)
# Member 4: Marianne - No membership fee type
member_attrs_list = [
%{
first_name: "Hans",
@ -142,7 +146,9 @@ member_attrs_list = [
city: "München",
street: "Hauptstraße",
house_number: "42",
postal_code: "80331"
postal_code: "80331",
membership_fee_type_id: Enum.at(all_fee_types, 0).id,
cycle_status: :all_paid
},
%{
first_name: "Greta",
@ -154,7 +160,9 @@ member_attrs_list = [
street: "Lindenstraße",
house_number: "17",
postal_code: "20095",
notes: "Interessiert an Fortgeschrittenen-Kursen"
notes: "Interessiert an Fortgeschrittenen-Kursen",
membership_fee_type_id: Enum.at(all_fee_types, 1).id,
cycle_status: :all_unpaid
},
%{
first_name: "Friedrich",
@ -164,7 +172,9 @@ member_attrs_list = [
phone_number: "+49301122334",
city: "Berlin",
street: "Kastanienallee",
house_number: "8"
house_number: "8",
membership_fee_type_id: Enum.at(all_fee_types, 2).id,
cycle_status: :mixed
},
%{
first_name: "Marianne",
@ -175,21 +185,74 @@ member_attrs_list = [
city: "Berlin",
street: "Kastanienallee",
house_number: "8"
# No membership_fee_type_id - member without fee type
}
]
# Assign fee types to members using round-robin
Enum.with_index(member_attrs_list)
|> Enum.each(fn {member_attrs, index} ->
# Round-robin assignment: cycle through fee types
fee_type = Enum.at(all_fee_types, rem(index, length(all_fee_types)))
member_attrs_with_fee_type = Map.put(member_attrs, :membership_fee_type_id, fee_type.id)
# Create members and generate cycles
Enum.each(member_attrs_list, fn member_attrs ->
cycle_status = Map.get(member_attrs, :cycle_status)
member_attrs_without_status = Map.delete(member_attrs, :cycle_status)
# Use upsert to prevent duplicates based on email
Membership.create_member!(member_attrs_with_fee_type,
member =
Membership.create_member!(member_attrs_without_status,
upsert?: true,
upsert_identity: :unique_email
)
# Generate cycles if member has a fee type
if member.membership_fee_type_id do
# Load member with cycles to check if they already exist
member_with_cycles =
member
|> Ash.load!(:membership_fee_cycles)
# Only generate if no cycles exist yet (to avoid duplicates on re-run)
cycles =
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
# Generate cycles
{:ok, new_cycles, _notifications} =
CycleGenerator.generate_cycles_for_member(member.id, skip_lock?: true)
new_cycles
else
# Use existing cycles
member_with_cycles.membership_fee_cycles
end
# Set cycle statuses based on member type
if cycle_status do
cycles
|> Enum.sort_by(& &1.cycle_start, Date)
|> Enum.with_index()
|> Enum.each(fn {cycle, index} ->
status =
case cycle_status do
:all_paid ->
:paid
:all_unpaid ->
:unpaid
:mixed ->
# Mix: first paid, second unpaid, third suspended, then repeat
case rem(index, 3) do
0 -> :paid
1 -> :unpaid
2 -> :suspended
end
end
# Only update if status is different
if cycle.status != status do
cycle
|> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!()
end
end)
end
end
end)
# Create additional users for user-member linking examples
@ -250,13 +313,14 @@ Enum.with_index(linked_members)
# Round-robin assignment: continue cycling through fee types
# Start from where previous members ended
fee_type_index = rem(length(member_attrs_list) + index, length(all_fee_types))
fee_type_index = rem(3 + index, length(all_fee_types))
fee_type = Enum.at(all_fee_types, fee_type_index)
member_attrs_with_fee_type =
Map.put(member_attrs_without_user, :membership_fee_type_id, fee_type.id)
# Check if user already has a member
member =
if user.member_id == nil do
# User is free, create member and link - use upsert to prevent duplicates
Membership.create_member!(
@ -271,6 +335,43 @@ Enum.with_index(linked_members)
upsert_identity: :unique_email
)
end
# Generate cycles for linked members
if member.membership_fee_type_id do
# Load member with cycles to check if they already exist
member_with_cycles =
member
|> Ash.load!(:membership_fee_cycles)
# Only generate if no cycles exist yet (to avoid duplicates on re-run)
cycles =
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
# Generate cycles
{:ok, new_cycles, _notifications} =
CycleGenerator.generate_cycles_for_member(member.id, skip_lock?: true)
new_cycles
else
# Use existing cycles
member_with_cycles.membership_fee_cycles
end
# Set some cycles to paid for linked members (mixed status)
cycles
|> Enum.sort_by(& &1.cycle_start, Date)
|> Enum.with_index()
|> Enum.each(fn {cycle, index} ->
# Every other cycle is paid, rest unpaid
status = if rem(index, 2) == 0, do: :paid, else: :unpaid
# Only update if status is different
if cycle.status != status do
cycle
|> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!()
end
end)
end
end)
# Create sample custom field values for some members

View file

@ -43,18 +43,19 @@ defmodule Mv.SeedsTest do
"CustomFields count should remain same after re-running seeds"
end
test "all members have membership fee type assigned" do
test "at least one member has no membership fee type assigned" do
# Run the seeds script
assert Code.eval_file("priv/repo/seeds.exs")
# Get all members
{:ok, members} = Ash.read(Mv.Membership.Member)
# All members should have a membership_fee_type_id
Enum.each(members, fn member ->
assert member.membership_fee_type_id != nil,
"Member #{member.first_name} #{member.last_name} should have a membership fee type assigned"
end)
# At least one member should have no membership_fee_type_id
members_without_fee_type =
Enum.filter(members, fn member -> member.membership_fee_type_id == nil end)
assert length(members_without_fee_type) > 0,
"At least one member should have no membership fee type assigned"
end
test "each membership fee type has at least one member" do
@ -65,9 +66,10 @@ defmodule Mv.SeedsTest do
{:ok, fee_types} = Ash.read(Mv.MembershipFees.MembershipFeeType)
{:ok, members} = Ash.read(Mv.Membership.Member)
# Group members by fee type
# Group members by fee type (excluding nil)
members_by_fee_type =
members
|> Enum.filter(&(&1.membership_fee_type_id != nil))
|> Enum.group_by(& &1.membership_fee_type_id)
# Each fee type should have at least one member
@ -78,5 +80,38 @@ defmodule Mv.SeedsTest do
"Membership fee type #{fee_type.name} should have at least one member assigned"
end)
end
test "members with fee types have cycles with various statuses" do
# Run the seeds script
assert Code.eval_file("priv/repo/seeds.exs")
# Get all members with fee types
{:ok, members} = Ash.read(Mv.Membership.Member)
members_with_fee_types =
members
|> Enum.filter(&(&1.membership_fee_type_id != nil))
# At least one member should have cycles
assert length(members_with_fee_types) > 0,
"At least one member should have a membership fee type"
# Check that cycles exist and have various statuses
all_cycle_statuses =
members_with_fee_types
|> Enum.flat_map(fn member ->
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
end)
|> Enum.map(& &1.status)
# At least one cycle should be paid
assert :paid in all_cycle_statuses, "At least one cycle should be paid"
# At least one cycle should be unpaid
assert :unpaid in all_cycle_statuses, "At least one cycle should be unpaid"
# At least one cycle should be suspended
assert :suspended in all_cycle_statuses, "At least one cycle should be suspended"
end
end
end