Membership Fee 6 - UI Components & LiveViews closes #280 #304
2 changed files with 171 additions and 35 deletions
|
|
@ -6,6 +6,7 @@
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.MembershipFees.CycleGenerator
|
||||||
|
|
||||||
# Create example membership fee types
|
# Create example membership fee types
|
||||||
for fee_type_attrs <- [
|
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()
|
all_fee_types = MembershipFeeType |> Ash.read!() |> Enum.to_list()
|
||||||
|
|
||||||
# Create sample members for testing - use upsert to prevent duplicates
|
# 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 = [
|
member_attrs_list = [
|
||||||
%{
|
%{
|
||||||
first_name: "Hans",
|
first_name: "Hans",
|
||||||
|
|
@ -142,7 +146,9 @@ member_attrs_list = [
|
||||||
city: "München",
|
city: "München",
|
||||||
street: "Hauptstraße",
|
street: "Hauptstraße",
|
||||||
house_number: "42",
|
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",
|
first_name: "Greta",
|
||||||
|
|
@ -154,7 +160,9 @@ member_attrs_list = [
|
||||||
street: "Lindenstraße",
|
street: "Lindenstraße",
|
||||||
house_number: "17",
|
house_number: "17",
|
||||||
postal_code: "20095",
|
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",
|
first_name: "Friedrich",
|
||||||
|
|
@ -164,7 +172,9 @@ member_attrs_list = [
|
||||||
phone_number: "+49301122334",
|
phone_number: "+49301122334",
|
||||||
city: "Berlin",
|
city: "Berlin",
|
||||||
street: "Kastanienallee",
|
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",
|
first_name: "Marianne",
|
||||||
|
|
@ -175,21 +185,74 @@ member_attrs_list = [
|
||||||
city: "Berlin",
|
city: "Berlin",
|
||||||
street: "Kastanienallee",
|
street: "Kastanienallee",
|
||||||
house_number: "8"
|
house_number: "8"
|
||||||
|
# No membership_fee_type_id - member without fee type
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
# Assign fee types to members using round-robin
|
# Create members and generate cycles
|
||||||
Enum.with_index(member_attrs_list)
|
Enum.each(member_attrs_list, fn member_attrs ->
|
||||||
|> Enum.each(fn {member_attrs, index} ->
|
cycle_status = Map.get(member_attrs, :cycle_status)
|
||||||
# Round-robin assignment: cycle through fee types
|
member_attrs_without_status = Map.delete(member_attrs, :cycle_status)
|
||||||
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)
|
|
||||||
|
|
||||||
# Use upsert to prevent duplicates based on email
|
# 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?: true,
|
||||||
upsert_identity: :unique_email
|
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)
|
end)
|
||||||
|
|
||||||
# Create additional users for user-member linking examples
|
# 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
|
# Round-robin assignment: continue cycling through fee types
|
||||||
# Start from where previous members ended
|
# 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)
|
fee_type = Enum.at(all_fee_types, fee_type_index)
|
||||||
|
|
||||||
member_attrs_with_fee_type =
|
member_attrs_with_fee_type =
|
||||||
Map.put(member_attrs_without_user, :membership_fee_type_id, fee_type.id)
|
Map.put(member_attrs_without_user, :membership_fee_type_id, fee_type.id)
|
||||||
|
|
||||||
# Check if user already has a member
|
# Check if user already has a member
|
||||||
|
member =
|
||||||
if user.member_id == nil do
|
if user.member_id == nil do
|
||||||
# User is free, create member and link - use upsert to prevent duplicates
|
# User is free, create member and link - use upsert to prevent duplicates
|
||||||
Membership.create_member!(
|
Membership.create_member!(
|
||||||
|
|
@ -271,6 +335,43 @@ Enum.with_index(linked_members)
|
||||||
upsert_identity: :unique_email
|
upsert_identity: :unique_email
|
||||||
)
|
)
|
||||||
end
|
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)
|
end)
|
||||||
|
|
||||||
# Create sample custom field values for some members
|
# Create sample custom field values for some members
|
||||||
|
|
|
||||||
|
|
@ -43,18 +43,19 @@ defmodule Mv.SeedsTest do
|
||||||
"CustomFields count should remain same after re-running seeds"
|
"CustomFields count should remain same after re-running seeds"
|
||||||
end
|
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
|
# Run the seeds script
|
||||||
assert Code.eval_file("priv/repo/seeds.exs")
|
assert Code.eval_file("priv/repo/seeds.exs")
|
||||||
|
|
||||||
# Get all members
|
# Get all members
|
||||||
{:ok, members} = Ash.read(Mv.Membership.Member)
|
{:ok, members} = Ash.read(Mv.Membership.Member)
|
||||||
|
|
||||||
# All members should have a membership_fee_type_id
|
# At least one member should have no membership_fee_type_id
|
||||||
Enum.each(members, fn member ->
|
members_without_fee_type =
|
||||||
assert member.membership_fee_type_id != nil,
|
Enum.filter(members, fn member -> member.membership_fee_type_id == nil end)
|
||||||
"Member #{member.first_name} #{member.last_name} should have a membership fee type assigned"
|
|
||||||
end)
|
assert length(members_without_fee_type) > 0,
|
||||||
|
"At least one member should have no membership fee type assigned"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "each membership fee type has at least one member" do
|
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, fee_types} = Ash.read(Mv.MembershipFees.MembershipFeeType)
|
||||||
{:ok, members} = Ash.read(Mv.Membership.Member)
|
{:ok, members} = Ash.read(Mv.Membership.Member)
|
||||||
|
|
||||||
# Group members by fee type
|
# Group members by fee type (excluding nil)
|
||||||
members_by_fee_type =
|
members_by_fee_type =
|
||||||
members
|
members
|
||||||
|
|> Enum.filter(&(&1.membership_fee_type_id != nil))
|
||||||
|> Enum.group_by(& &1.membership_fee_type_id)
|
|> Enum.group_by(& &1.membership_fee_type_id)
|
||||||
|
|
||||||
# Each fee type should have at least one member
|
# 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"
|
"Membership fee type #{fee_type.name} should have at least one member assigned"
|
||||||
end)
|
end)
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue