Compare commits
4 commits
feature/27
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 894b9b9d5c | |||
| a7285915e6 | |||
| da6c495d04 | |||
| 3fc4440bce |
37 changed files with 897 additions and 6076 deletions
|
|
@ -47,5 +47,4 @@ config :mv, :session_identifier, :unsafe
|
||||||
config :mv, :require_token_presence_for_authentication, false
|
config :mv, :require_token_presence_for_authentication, false
|
||||||
|
|
||||||
# Enable SQL Sandbox for async LiveView tests
|
# Enable SQL Sandbox for async LiveView tests
|
||||||
# This flag controls sync vs async behavior in CycleGenerator after_action hooks
|
|
||||||
config :mv, :sql_sandbox, true
|
config :mv, :sql_sandbox, true
|
||||||
|
|
|
||||||
|
|
@ -153,8 +153,8 @@ lib/
|
||||||
|
|
||||||
**Existing Fields Used:**
|
**Existing Fields Used:**
|
||||||
|
|
||||||
- `join_date` - For calculating membership fee start
|
- `joined_at` - For calculating membership fee start
|
||||||
- `exit_date` - For limiting cycle generation
|
- `left_at` - For limiting cycle generation
|
||||||
- These fields must remain member fields and should not be replaced by custom fields in the future
|
- These fields must remain member fields and should not be replaced by custom fields in the future
|
||||||
|
|
||||||
### Settings Integration
|
### Settings Integration
|
||||||
|
|
@ -186,9 +186,8 @@ lib/
|
||||||
|
|
||||||
- Calculate which cycles should exist for a member
|
- Calculate which cycles should exist for a member
|
||||||
- Generate missing cycles
|
- Generate missing cycles
|
||||||
- Respect membership_fee_start_date and exit_date boundaries
|
- Respect membership_fee_start_date and left_at boundaries
|
||||||
- Skip existing cycles (idempotent)
|
- Skip existing cycles (idempotent)
|
||||||
- Use PostgreSQL advisory locks per member to prevent race conditions
|
|
||||||
|
|
||||||
**Triggers:**
|
**Triggers:**
|
||||||
|
|
||||||
|
|
@ -200,20 +199,17 @@ lib/
|
||||||
**Algorithm Steps:**
|
**Algorithm Steps:**
|
||||||
|
|
||||||
1. Retrieve member with membership fee type and dates
|
1. Retrieve member with membership fee type and dates
|
||||||
2. Determine generation start point:
|
2. Determine first cycle start (based on membership_fee_start_date)
|
||||||
- If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`)
|
3. Calculate all cycle starts from first to today (or left_at)
|
||||||
- If cycles exist: Start from the cycle AFTER the last existing one
|
4. Query existing cycles for member
|
||||||
3. Generate all cycle starts from the determined start point to today (or `exit_date`)
|
5. Generate missing cycles with current membership fee type's amount
|
||||||
4. Create new cycles with current membership fee type's amount
|
6. Insert new cycles (batch operation)
|
||||||
5. Use PostgreSQL advisory locks per member to prevent race conditions
|
|
||||||
|
|
||||||
**Edge Case Handling:**
|
**Edge Case Handling:**
|
||||||
|
|
||||||
- If membership_fee_start_date is NULL: Calculate from join_date + global setting
|
- If membership_fee_start_date is NULL: Calculate from joined_at + global setting
|
||||||
- If exit_date is set: Stop generation at exit_date
|
- If left_at is set: Stop generation at left_at
|
||||||
- If membership fee type changes: Handled separately by regeneration logic
|
- If membership fee type changes: Handled separately by regeneration logic
|
||||||
- **Gap Handling:** If cycles were explicitly deleted (gaps exist), they are NOT recreated.
|
|
||||||
The generator always continues from the cycle AFTER the last existing cycle, regardless of gaps.
|
|
||||||
|
|
||||||
### Calendar Cycle Calculations
|
### Calendar Cycle Calculations
|
||||||
|
|
||||||
|
|
@ -282,14 +278,8 @@ lib/
|
||||||
**Implementation Pattern:**
|
**Implementation Pattern:**
|
||||||
|
|
||||||
- Use Ash change module to validate
|
- Use Ash change module to validate
|
||||||
- Use after_action hook to trigger regeneration synchronously
|
- Use after_action hook to trigger regeneration
|
||||||
- Regeneration runs in the same transaction as the member update to ensure atomicity
|
- Use transaction to ensure atomicity
|
||||||
- CycleGenerator uses advisory locks and transactions internally to prevent race conditions
|
|
||||||
|
|
||||||
**Validation Behavior:**
|
|
||||||
|
|
||||||
- Fail-closed: If membership fee types cannot be loaded during validation, the change is rejected with a validation error
|
|
||||||
- Nil assignment prevention: Attempts to set membership_fee_type_id to nil are rejected when a current type exists
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -391,7 +381,7 @@ lib/
|
||||||
**AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default)
|
**AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default)
|
||||||
**AC-M-2:** Member has membership_fee_start_date field (nullable)
|
**AC-M-2:** Member has membership_fee_start_date field (nullable)
|
||||||
**AC-M-3:** New members get default membership fee type from global setting
|
**AC-M-3:** New members get default membership fee type from global setting
|
||||||
**AC-M-4:** membership_fee_start_date auto-set based on join_date and global setting
|
**AC-M-4:** membership_fee_start_date auto-set based on joined_at and global setting
|
||||||
**AC-M-5:** Admin can manually override membership_fee_start_date
|
**AC-M-5:** Admin can manually override membership_fee_start_date
|
||||||
**AC-M-6:** Cannot change to membership fee type with different interval (MVP)
|
**AC-M-6:** Cannot change to membership fee type with different interval (MVP)
|
||||||
|
|
||||||
|
|
@ -401,7 +391,7 @@ lib/
|
||||||
**AC-CG-2:** Cycles generated when member created (via change hook)
|
**AC-CG-2:** Cycles generated when member created (via change hook)
|
||||||
**AC-CG-3:** Scheduled job generates missing cycles daily
|
**AC-CG-3:** Scheduled job generates missing cycles daily
|
||||||
**AC-CG-4:** Generation respects membership_fee_start_date
|
**AC-CG-4:** Generation respects membership_fee_start_date
|
||||||
**AC-CG-5:** Generation stops at exit_date if member exited
|
**AC-CG-5:** Generation stops at left_at if member exited
|
||||||
**AC-CG-6:** Generation is idempotent (skips existing cycles)
|
**AC-CG-6:** Generation is idempotent (skips existing cycles)
|
||||||
**AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year)
|
**AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year)
|
||||||
**AC-CG-8:** Amount comes from membership_fee_type at generation time
|
**AC-CG-8:** Amount comes from membership_fee_type at generation time
|
||||||
|
|
@ -423,7 +413,7 @@ lib/
|
||||||
**AC-TC-3:** On allowed change: future unpaid cycles regenerated
|
**AC-TC-3:** On allowed change: future unpaid cycles regenerated
|
||||||
**AC-TC-4:** On allowed change: paid/suspended cycles unchanged
|
**AC-TC-4:** On allowed change: paid/suspended cycles unchanged
|
||||||
**AC-TC-5:** On allowed change: amount updated to new type's amount
|
**AC-TC-5:** On allowed change: amount updated to new type's amount
|
||||||
**AC-TC-6:** Change is atomic (transaction) - ✅ Implemented: Regeneration runs synchronously in the same transaction as the member update
|
**AC-TC-6:** Change is atomic (transaction)
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
|
|
||||||
|
|
@ -482,9 +472,8 @@ lib/
|
||||||
- Correct cycle_start calculation for all interval types
|
- Correct cycle_start calculation for all interval types
|
||||||
- Correct cycle count from start to end date
|
- Correct cycle count from start to end date
|
||||||
- Respects membership_fee_start_date boundary
|
- Respects membership_fee_start_date boundary
|
||||||
- Respects exit_date boundary
|
- Respects left_at boundary
|
||||||
- Skips existing cycles (idempotent)
|
- Skips existing cycles (idempotent)
|
||||||
- Does not fill gaps when cycles were deleted
|
|
||||||
- Handles edge dates (year boundaries, leap years)
|
- Handles edge dates (year boundaries, leap years)
|
||||||
|
|
||||||
**Calendar Cycles Tests:**
|
**Calendar Cycles Tests:**
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ This document provides a comprehensive overview of the Membership Fees system. I
|
||||||
```
|
```
|
||||||
- membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings)
|
- membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings)
|
||||||
- membership_fee_start_date (Date, nullable) - When to start generating membership fees
|
- membership_fee_start_date (Date, nullable) - When to start generating membership fees
|
||||||
- exit_date (Date, nullable) - Exit date (existing)
|
- left_at (Date, nullable) - Exit date (existing)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Logic for membership_fee_start_date:**
|
**Logic for membership_fee_start_date:**
|
||||||
|
|
@ -167,17 +167,16 @@ value: UUID (Required) - Default membership fee type for new members
|
||||||
|
|
||||||
**Algorithm:**
|
**Algorithm:**
|
||||||
|
|
||||||
Use PostgreSQL advisory locks per member to prevent race conditions
|
Lock the whole cycle table for the duration of the algorithm
|
||||||
|
|
||||||
1. Get `member.membership_fee_start_date` and member's membership fee type
|
1. Get `member.membership_fee_start_date` and member's membership fee type
|
||||||
2. Determine generation start point:
|
2. Generate cycles until today (or `left_at` if present):
|
||||||
- If NO cycles exist: Start from `membership_fee_start_date`
|
- If no cycle exists:
|
||||||
- If cycles exist: Start from the cycle AFTER the last existing one
|
- Generate all cycles from `membership_fee_start_date`
|
||||||
3. Generate cycles until today (or `exit_date` if present):
|
- else:
|
||||||
- Use the interval to generate the cycles
|
- Generate all cycles from last existing cycle
|
||||||
- **Note:** If cycles were explicitly deleted (gaps exist), they are NOT recreated.
|
- use the interval to generate the cycles
|
||||||
The generator always continues from the cycle AFTER the last existing cycle.
|
3. Set `amount` to current membership fee type's amount
|
||||||
4. Set `amount` to current membership fee type's amount
|
|
||||||
|
|
||||||
**Example (Yearly):**
|
**Example (Yearly):**
|
||||||
|
|
||||||
|
|
@ -247,7 +246,7 @@ suspended → unpaid
|
||||||
|
|
||||||
**Logic:**
|
**Logic:**
|
||||||
|
|
||||||
- Cycles only generated until `member.exit_date`
|
- Cycles only generated until `member.left_at`
|
||||||
- Existing cycles remain visible
|
- Existing cycles remain visible
|
||||||
- Unpaid exit cycle can be marked as "suspended"
|
- Unpaid exit cycle can be marked as "suspended"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ defmodule Mv.Membership.Member do
|
||||||
argument :user, :map, allow_nil?: true
|
argument :user, :map, allow_nil?: true
|
||||||
|
|
||||||
# Accept member fields plus membership_fee_type_id (belongs_to FK)
|
# Accept member fields plus membership_fee_type_id (belongs_to FK)
|
||||||
accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
|
accept @member_fields ++ [:membership_fee_type_id]
|
||||||
|
|
||||||
change manage_relationship(:custom_field_values, type: :create)
|
change manage_relationship(:custom_field_values, type: :create)
|
||||||
|
|
||||||
|
|
@ -101,64 +101,6 @@ defmodule Mv.Membership.Member do
|
||||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
where [changing(:user)]
|
where [changing(:user)]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Auto-calculate membership_fee_start_date if not manually set
|
|
||||||
# Requires both join_date and membership_fee_type_id to be present
|
|
||||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
|
||||||
|
|
||||||
# Trigger cycle generation after member creation
|
|
||||||
# Only runs if membership_fee_type_id is set
|
|
||||||
# Note: Cycle generation runs asynchronously to not block the action,
|
|
||||||
# but in test environment it runs synchronously for DB sandbox compatibility
|
|
||||||
change after_action(fn _changeset, member, _context ->
|
|
||||||
if member.membership_fee_type_id && member.join_date do
|
|
||||||
if Application.get_env(:mv, :sql_sandbox, false) do
|
|
||||||
# Run synchronously in test environment for DB sandbox compatibility
|
|
||||||
# Use skip_lock?: true to avoid nested transactions (after_action runs within action transaction)
|
|
||||||
# Return notifications to Ash so they are sent after commit
|
|
||||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
|
||||||
member.id,
|
|
||||||
today: Date.utc_today(),
|
|
||||||
skip_lock?: true
|
|
||||||
) do
|
|
||||||
{:ok, _cycles, notifications} ->
|
|
||||||
{:ok, member, notifications}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
Logger.warning(
|
|
||||||
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, member}
|
|
||||||
end
|
|
||||||
else
|
|
||||||
# Run asynchronously in other environments
|
|
||||||
# Send notifications explicitly since they cannot be returned via after_action
|
|
||||||
Task.start(fn ->
|
|
||||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
|
|
||||||
{:ok, _cycles, notifications} ->
|
|
||||||
# Send notifications manually for async case
|
|
||||||
if Enum.any?(notifications) do
|
|
||||||
Ash.Notifier.notify(notifications)
|
|
||||||
end
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
Logger.warning(
|
|
||||||
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
{:ok, member}
|
|
||||||
end
|
|
||||||
else
|
|
||||||
{:ok, member}
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
update :update_member do
|
update :update_member do
|
||||||
|
|
@ -172,7 +114,7 @@ defmodule Mv.Membership.Member do
|
||||||
argument :user, :map, allow_nil?: true
|
argument :user, :map, allow_nil?: true
|
||||||
|
|
||||||
# Accept member fields plus membership_fee_type_id (belongs_to FK)
|
# Accept member fields plus membership_fee_type_id (belongs_to FK)
|
||||||
accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]
|
accept @member_fields ++ [:membership_fee_type_id]
|
||||||
|
|
||||||
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
||||||
|
|
||||||
|
|
@ -199,46 +141,6 @@ defmodule Mv.Membership.Member do
|
||||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
where [changing(:user)]
|
where [changing(:user)]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate that membership fee type changes only allow same-interval types
|
|
||||||
change Mv.MembershipFees.Changes.ValidateSameInterval do
|
|
||||||
where [changing(:membership_fee_type_id)]
|
|
||||||
end
|
|
||||||
|
|
||||||
# Auto-calculate membership_fee_start_date when membership_fee_type_id is set
|
|
||||||
# and membership_fee_start_date is not already set
|
|
||||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
|
||||||
where [changing(:membership_fee_type_id)]
|
|
||||||
end
|
|
||||||
|
|
||||||
# Trigger cycle regeneration when membership_fee_type_id changes
|
|
||||||
# This deletes future unpaid cycles and regenerates them with the new type/amount
|
|
||||||
# Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
|
|
||||||
# CycleGenerator uses advisory locks and transactions internally to prevent race conditions
|
|
||||||
# Notifications are returned to Ash and sent automatically after commit
|
|
||||||
change after_action(fn changeset, member, _context ->
|
|
||||||
fee_type_changed =
|
|
||||||
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
|
|
||||||
|
|
||||||
if fee_type_changed && member.membership_fee_type_id && member.join_date do
|
|
||||||
case regenerate_cycles_on_type_change(member) do
|
|
||||||
{:ok, notifications} ->
|
|
||||||
# Return notifications to Ash - they will be sent automatically after commit
|
|
||||||
{:ok, member, notifications}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
Logger.warning(
|
|
||||||
"Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, member}
|
|
||||||
end
|
|
||||||
else
|
|
||||||
{:ok, member}
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Action to handle fuzzy search on specific fields
|
# Action to handle fuzzy search on specific fields
|
||||||
|
|
@ -523,50 +425,6 @@ 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]
|
||||||
|
|
@ -613,261 +471,6 @@ defmodule Mv.Membership.Member do
|
||||||
|
|
||||||
def show_in_overview?(_), do: true
|
def show_in_overview?(_), do: true
|
||||||
|
|
||||||
# Helper functions for cycle status calculations
|
|
||||||
#
|
|
||||||
# These functions expect membership_fee_cycles to be loaded with membership_fee_type
|
|
||||||
# preloaded. The calculations explicitly load this relationship, but if called
|
|
||||||
# directly, ensure membership_fee_type is loaded or the functions will return
|
|
||||||
# nil/[] when membership_fee_type is missing.
|
|
||||||
|
|
||||||
@doc false
|
|
||||||
@spec get_current_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
|
|
||||||
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, ¤t_cycle?(&1, today))
|
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Checks if a cycle is the current cycle (active today)
|
|
||||||
defp current_cycle?(cycle, today) do
|
|
||||||
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
|
|
||||||
|
|
||||||
@doc false
|
|
||||||
@spec get_last_completed_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
|
|
||||||
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
|
|
||||||
|> filter_completed_cycles(today)
|
|
||||||
|> sort_cycles_by_end_date()
|
|
||||||
|> List.first()
|
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Filters cycles that have ended (cycle_end < today)
|
|
||||||
defp filter_completed_cycles(cycles, today) 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)
|
|
||||||
|
|
||||||
Date.compare(today, cycle_end) == :gt
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Sorts cycles by end date in descending order
|
|
||||||
defp sort_cycles_by_end_date(cycles) do
|
|
||||||
Enum.sort_by(
|
|
||||||
cycles,
|
|
||||||
fn cycle ->
|
|
||||||
interval = Map.get(cycle, :membership_fee_type).interval
|
|
||||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
|
||||||
end,
|
|
||||||
{:desc, Date}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc false
|
|
||||||
@spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()]
|
|
||||||
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
|
|
||||||
filter_overdue_cycles(cycles, today)
|
|
||||||
else
|
|
||||||
[]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Filters cycles that are unpaid and have ended (cycle_end < today)
|
|
||||||
defp filter_overdue_cycles(cycles, today) 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)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Regenerates cycles when membership fee type changes
|
|
||||||
# Deletes future unpaid cycles and regenerates them with the new type/amount
|
|
||||||
# Uses advisory lock to prevent concurrent modifications
|
|
||||||
# Returns {:ok, notifications} or {:error, reason} where notifications are collected
|
|
||||||
# to be sent after transaction commits
|
|
||||||
@doc false
|
|
||||||
def regenerate_cycles_on_type_change(member) do
|
|
||||||
today = Date.utc_today()
|
|
||||||
lock_key = :erlang.phash2(member.id)
|
|
||||||
|
|
||||||
# Use advisory lock to prevent concurrent deletion and regeneration
|
|
||||||
# This ensures atomicity when multiple updates happen simultaneously
|
|
||||||
if Mv.Repo.in_transaction?() do
|
|
||||||
regenerate_cycles_in_transaction(member, today, lock_key)
|
|
||||||
else
|
|
||||||
regenerate_cycles_new_transaction(member, today, lock_key)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Already in transaction: use advisory lock directly
|
|
||||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
|
||||||
defp regenerate_cycles_in_transaction(member, today, lock_key) do
|
|
||||||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
|
||||||
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Not in transaction: start new transaction with advisory lock
|
|
||||||
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
|
||||||
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
|
||||||
Mv.Repo.transaction(fn ->
|
|
||||||
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
|
||||||
|
|
||||||
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
|
|
||||||
{:ok, notifications} ->
|
|
||||||
# Return notifications - they will be sent by the caller
|
|
||||||
notifications
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
Mv.Repo.rollback(reason)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|> case do
|
|
||||||
{:ok, notifications} -> {:ok, notifications}
|
|
||||||
{:error, reason} -> {:error, reason}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Performs the actual cycle deletion and regeneration
|
|
||||||
# Returns {:ok, notifications} or {:error, reason}
|
|
||||||
# notifications are collected to be sent after transaction commits
|
|
||||||
defp do_regenerate_cycles_on_type_change(member, today, opts) do
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
|
||||||
|
|
||||||
# Find all unpaid cycles for this member
|
|
||||||
# We need to check cycle_end for each cycle using its own interval
|
|
||||||
all_unpaid_cycles_query =
|
|
||||||
Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member.id)
|
|
||||||
|> Ash.Query.filter(status == :unpaid)
|
|
||||||
|> Ash.Query.load([:membership_fee_type])
|
|
||||||
|
|
||||||
case Ash.read(all_unpaid_cycles_query) do
|
|
||||||
{:ok, all_unpaid_cycles} ->
|
|
||||||
cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)
|
|
||||||
delete_and_regenerate_cycles(cycles_to_delete, member.id, today, skip_lock?: skip_lock?)
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
{:error, reason}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Filters cycles that haven't ended yet (cycle_end >= today)
|
|
||||||
# These are the "future" cycles that should be regenerated
|
|
||||||
defp filter_future_cycles(all_unpaid_cycles, today) do
|
|
||||||
Enum.filter(all_unpaid_cycles, fn cycle ->
|
|
||||||
case cycle.membership_fee_type do
|
|
||||||
%{interval: interval} ->
|
|
||||||
cycle_end =
|
|
||||||
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
|
||||||
|
|
||||||
Date.compare(today, cycle_end) in [:lt, :eq]
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Deletes future cycles and regenerates them with the new type/amount
|
|
||||||
# Passes today to ensure consistent date across deletion and regeneration
|
|
||||||
# Returns {:ok, notifications} or {:error, reason}
|
|
||||||
defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, opts) do
|
|
||||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
|
||||||
|
|
||||||
if Enum.empty?(cycles_to_delete) do
|
|
||||||
# No cycles to delete, just regenerate
|
|
||||||
regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
|
||||||
else
|
|
||||||
case delete_cycles(cycles_to_delete) do
|
|
||||||
:ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
|
|
||||||
{:error, reason} -> {:error, reason}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
|
|
||||||
defp delete_cycles(cycles_to_delete) do
|
|
||||||
delete_results =
|
|
||||||
Enum.map(cycles_to_delete, fn cycle ->
|
|
||||||
Ash.destroy(cycle)
|
|
||||||
end)
|
|
||||||
|
|
||||||
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
|
|
||||||
{:error, :deletion_failed}
|
|
||||||
else
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Regenerates cycles with new type/amount
|
|
||||||
# Passes today to ensure consistent date across deletion and regeneration
|
|
||||||
# skip_lock?: true means advisory lock is already set by caller
|
|
||||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
|
||||||
defp regenerate_cycles(member_id, today, opts) do
|
|
||||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
|
||||||
|
|
||||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
|
||||||
member_id,
|
|
||||||
today: today,
|
|
||||||
skip_lock?: skip_lock?
|
|
||||||
) do
|
|
||||||
{:ok, _cycles, notifications} when is_list(notifications) ->
|
|
||||||
{:ok, notifications}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
{:error, reason}
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,13 @@ defmodule Mv.Membership.Setting do
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
Settings is a singleton resource that stores global configuration for the association,
|
Settings is a singleton resource that stores global configuration for the association,
|
||||||
such as the club name, branding information, and membership fee settings. There should
|
such as the club name and branding information. There should only ever be one settings
|
||||||
only ever be one settings record in the database.
|
record in the database.
|
||||||
|
|
||||||
## Attributes
|
## Attributes
|
||||||
- `club_name` - The name of the association/club (required, cannot be empty)
|
- `club_name` - The name of the association/club (required, cannot be empty)
|
||||||
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
|
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
|
||||||
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
|
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
|
||||||
- `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true)
|
|
||||||
- `default_membership_fee_type_id` - Default membership fee type for new members (optional)
|
|
||||||
|
|
||||||
## Singleton Pattern
|
## Singleton Pattern
|
||||||
This resource uses a singleton pattern - there should only be one settings record.
|
This resource uses a singleton pattern - there should only be one settings record.
|
||||||
|
|
@ -24,12 +22,6 @@ defmodule Mv.Membership.Setting do
|
||||||
If set, the environment variable value is used as a fallback when no database
|
If set, the environment variable value is used as a fallback when no database
|
||||||
value exists. Database values always take precedence over environment variables.
|
value exists. Database values always take precedence over environment variables.
|
||||||
|
|
||||||
## Membership Fee Settings
|
|
||||||
- `include_joining_cycle`: When true, members pay from their joining cycle. When false,
|
|
||||||
they pay from the next full cycle after joining.
|
|
||||||
- `default_membership_fee_type_id`: The membership fee type automatically assigned to
|
|
||||||
new members. Can be nil if no default is set.
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
# Get current settings
|
# Get current settings
|
||||||
|
|
@ -41,9 +33,6 @@ defmodule Mv.Membership.Setting do
|
||||||
|
|
||||||
# Update member field visibility
|
# Update member field visibility
|
||||||
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
||||||
|
|
||||||
# Update membership fee settings
|
|
||||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
|
|
||||||
"""
|
"""
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
domain: Mv.Membership,
|
domain: Mv.Membership,
|
||||||
|
|
@ -65,24 +54,13 @@ defmodule Mv.Membership.Setting do
|
||||||
# Used only as fallback in get_settings/0 if settings don't exist
|
# Used only as fallback in get_settings/0 if settings don't exist
|
||||||
# Settings should normally be created via seed script
|
# Settings should normally be created via seed script
|
||||||
create :create do
|
create :create do
|
||||||
accept [
|
accept [:club_name, :member_field_visibility]
|
||||||
:club_name,
|
|
||||||
:member_field_visibility,
|
|
||||||
:include_joining_cycle,
|
|
||||||
:default_membership_fee_type_id
|
|
||||||
]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
update :update do
|
update :update do
|
||||||
primary? true
|
primary? true
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
|
accept [:club_name, :member_field_visibility]
|
||||||
accept [
|
|
||||||
:club_name,
|
|
||||||
:member_field_visibility,
|
|
||||||
:include_joining_cycle,
|
|
||||||
:default_membership_fee_type_id
|
|
||||||
]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
update :update_member_field_visibility do
|
update :update_member_field_visibility do
|
||||||
|
|
@ -90,14 +68,6 @@ defmodule Mv.Membership.Setting do
|
||||||
require_atomic? false
|
require_atomic? false
|
||||||
accept [:member_field_visibility]
|
accept [:member_field_visibility]
|
||||||
end
|
end
|
||||||
|
|
||||||
update :update_membership_fee_settings do
|
|
||||||
description "Updates the membership fee configuration"
|
|
||||||
require_atomic? false
|
|
||||||
accept [:include_joining_cycle, :default_membership_fee_type_id]
|
|
||||||
|
|
||||||
change Mv.Membership.Setting.Changes.NormalizeDefaultFeeTypeId
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
validations do
|
validations do
|
||||||
|
|
@ -143,41 +113,6 @@ defmodule Mv.Membership.Setting do
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
on: [:create, :update]
|
on: [:create, :update]
|
||||||
|
|
||||||
# Validate default_membership_fee_type_id exists if set
|
|
||||||
validate fn changeset, _context ->
|
|
||||||
fee_type_id =
|
|
||||||
Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
|
|
||||||
|
|
||||||
if fee_type_id do
|
|
||||||
case Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type_id) do
|
|
||||||
{:ok, _} ->
|
|
||||||
:ok
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
|
|
||||||
{:error,
|
|
||||||
field: :default_membership_fee_type_id,
|
|
||||||
message: "Membership fee type not found"}
|
|
||||||
|
|
||||||
{:error, err} ->
|
|
||||||
# Log unexpected errors (DB timeout, connection errors, etc.)
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
Logger.warning(
|
|
||||||
"Unexpected error when validating default_membership_fee_type_id: #{inspect(err)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return generic error to user
|
|
||||||
{:error,
|
|
||||||
field: :default_membership_fee_type_id,
|
|
||||||
message: "Could not validate membership fee type"}
|
|
||||||
end
|
|
||||||
else
|
|
||||||
# Optional, can be nil
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
on: [:create, :update]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
attributes do
|
attributes do
|
||||||
|
|
@ -198,26 +133,6 @@ defmodule Mv.Membership.Setting do
|
||||||
description:
|
description:
|
||||||
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
|
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
|
||||||
|
|
||||||
# Membership fee settings
|
|
||||||
attribute :include_joining_cycle, :boolean do
|
|
||||||
allow_nil? false
|
|
||||||
default true
|
|
||||||
public? true
|
|
||||||
description "Whether to include the joining cycle in membership fee generation"
|
|
||||||
end
|
|
||||||
|
|
||||||
attribute :default_membership_fee_type_id, :uuid do
|
|
||||||
allow_nil? true
|
|
||||||
public? true
|
|
||||||
description "Default membership fee type ID for new members"
|
|
||||||
end
|
|
||||||
|
|
||||||
timestamps()
|
timestamps()
|
||||||
end
|
end
|
||||||
|
|
||||||
relationships do
|
|
||||||
# Optional relationship to the default membership fee type
|
|
||||||
# Note: We use manual FK (default_membership_fee_type_id attribute) instead of belongs_to
|
|
||||||
# to avoid circular dependency between Membership and MembershipFees domains
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
defmodule Mv.Membership.Setting.Changes.NormalizeDefaultFeeTypeId do
|
|
||||||
@moduledoc """
|
|
||||||
Ash change that normalizes empty strings to nil for default_membership_fee_type_id.
|
|
||||||
|
|
||||||
HTML forms submit empty select values as empty strings (""), but the database
|
|
||||||
expects nil for optional UUID fields. This change converts "" to nil.
|
|
||||||
"""
|
|
||||||
use Ash.Resource.Change
|
|
||||||
|
|
||||||
def change(changeset, _opts, _context) do
|
|
||||||
default_fee_type_id = Ash.Changeset.get_attribute(changeset, :default_membership_fee_type_id)
|
|
||||||
|
|
||||||
if default_fee_type_id == "" do
|
|
||||||
Ash.Changeset.force_change_attribute(changeset, :default_membership_fee_type_id, nil)
|
|
||||||
else
|
|
||||||
changeset
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
|
|
||||||
@moduledoc """
|
|
||||||
Ash change module that automatically calculates and sets the membership_fee_start_date.
|
|
||||||
|
|
||||||
## Logic
|
|
||||||
|
|
||||||
1. Only executes if `membership_fee_start_date` is not manually set
|
|
||||||
2. Requires both `join_date` and `membership_fee_type_id` to be present
|
|
||||||
3. Reads `include_joining_cycle` setting from global Settings
|
|
||||||
4. Reads `interval` from the assigned `membership_fee_type`
|
|
||||||
5. Calculates the start date:
|
|
||||||
- If `include_joining_cycle = true`: First day of the joining cycle
|
|
||||||
- If `include_joining_cycle = false`: First day of the next cycle after joining
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
In a Member action:
|
|
||||||
|
|
||||||
create :create_member do
|
|
||||||
# ...
|
|
||||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
|
||||||
end
|
|
||||||
|
|
||||||
The change module handles all prerequisite checks internally (join_date, membership_fee_type_id).
|
|
||||||
If any required data is missing, the changeset is returned unchanged with a warning logged.
|
|
||||||
"""
|
|
||||||
use Ash.Resource.Change
|
|
||||||
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def change(changeset, _opts, _context) do
|
|
||||||
# Only calculate if membership_fee_start_date is not already set
|
|
||||||
if has_start_date?(changeset) do
|
|
||||||
changeset
|
|
||||||
else
|
|
||||||
calculate_and_set_start_date(changeset)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if membership_fee_start_date is already set (either in changeset or data)
|
|
||||||
defp has_start_date?(changeset) do
|
|
||||||
# Check if it's being set in this changeset
|
|
||||||
case Ash.Changeset.fetch_change(changeset, :membership_fee_start_date) do
|
|
||||||
{:ok, date} when not is_nil(date) ->
|
|
||||||
true
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
# Check if it already exists in the data (for updates)
|
|
||||||
case changeset.data do
|
|
||||||
%{membership_fee_start_date: date} when not is_nil(date) -> true
|
|
||||||
_ -> false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp calculate_and_set_start_date(changeset) do
|
|
||||||
with {:ok, join_date} <- get_join_date(changeset),
|
|
||||||
{:ok, membership_fee_type_id} <- get_membership_fee_type_id(changeset),
|
|
||||||
{:ok, interval} <- get_interval(membership_fee_type_id),
|
|
||||||
{:ok, include_joining_cycle} <- get_include_joining_cycle() do
|
|
||||||
start_date = calculate_start_date(join_date, interval, include_joining_cycle)
|
|
||||||
Ash.Changeset.force_change_attribute(changeset, :membership_fee_start_date, start_date)
|
|
||||||
else
|
|
||||||
{:error, :join_date_not_set} ->
|
|
||||||
# Missing join_date is expected for partial creates
|
|
||||||
changeset
|
|
||||||
|
|
||||||
{:error, :membership_fee_type_not_set} ->
|
|
||||||
# Missing membership_fee_type_id is expected for partial creates
|
|
||||||
changeset
|
|
||||||
|
|
||||||
{:error, :membership_fee_type_not_found} ->
|
|
||||||
# This is a data integrity error - membership_fee_type_id references non-existent type
|
|
||||||
# Return changeset error to fail the action
|
|
||||||
Ash.Changeset.add_error(
|
|
||||||
changeset,
|
|
||||||
field: :membership_fee_type_id,
|
|
||||||
message: "not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
# Log warning for other unexpected errors
|
|
||||||
Logger.warning("Could not auto-set membership_fee_start_date: #{inspect(reason)}")
|
|
||||||
changeset
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp get_join_date(changeset) do
|
|
||||||
# First check the changeset for changes
|
|
||||||
case Ash.Changeset.fetch_change(changeset, :join_date) do
|
|
||||||
{:ok, date} when not is_nil(date) ->
|
|
||||||
{:ok, date}
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
# Then check existing data
|
|
||||||
case changeset.data do
|
|
||||||
%{join_date: date} when not is_nil(date) -> {:ok, date}
|
|
||||||
_ -> {:error, :join_date_not_set}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp get_membership_fee_type_id(changeset) do
|
|
||||||
# First check the changeset for changes
|
|
||||||
case Ash.Changeset.fetch_change(changeset, :membership_fee_type_id) do
|
|
||||||
{:ok, id} when not is_nil(id) ->
|
|
||||||
{:ok, id}
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
# Then check existing data
|
|
||||||
case changeset.data do
|
|
||||||
%{membership_fee_type_id: id} when not is_nil(id) -> {:ok, id}
|
|
||||||
_ -> {:error, :membership_fee_type_not_set}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp get_interval(membership_fee_type_id) do
|
|
||||||
case Ash.get(Mv.MembershipFees.MembershipFeeType, membership_fee_type_id) do
|
|
||||||
{:ok, %{interval: interval}} -> {:ok, interval}
|
|
||||||
{:error, _} -> {:error, :membership_fee_type_not_found}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp get_include_joining_cycle do
|
|
||||||
case Mv.Membership.get_settings() do
|
|
||||||
{:ok, %{include_joining_cycle: include}} -> {:ok, include}
|
|
||||||
{:error, _} -> {:ok, true}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Calculates the membership fee start date based on join date, interval, and settings.
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `join_date` - The date the member joined
|
|
||||||
- `interval` - The billing interval (:monthly, :quarterly, :half_yearly, :yearly)
|
|
||||||
- `include_joining_cycle` - Whether to include the joining cycle
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
The calculated start date (first day of the appropriate cycle).
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> calculate_start_date(~D[2024-03-15], :yearly, true)
|
|
||||||
~D[2024-01-01]
|
|
||||||
|
|
||||||
iex> calculate_start_date(~D[2024-03-15], :yearly, false)
|
|
||||||
~D[2025-01-01]
|
|
||||||
|
|
||||||
iex> calculate_start_date(~D[2024-03-15], :quarterly, true)
|
|
||||||
~D[2024-01-01]
|
|
||||||
|
|
||||||
iex> calculate_start_date(~D[2024-03-15], :quarterly, false)
|
|
||||||
~D[2024-04-01]
|
|
||||||
|
|
||||||
"""
|
|
||||||
@spec calculate_start_date(Date.t(), CalendarCycles.interval(), boolean()) :: Date.t()
|
|
||||||
def calculate_start_date(join_date, interval, include_joining_cycle) do
|
|
||||||
if include_joining_cycle do
|
|
||||||
# Start date is the first day of the joining cycle
|
|
||||||
CalendarCycles.calculate_cycle_start(join_date, interval)
|
|
||||||
else
|
|
||||||
# Start date is the first day of the next cycle after joining
|
|
||||||
join_cycle_start = CalendarCycles.calculate_cycle_start(join_date, interval)
|
|
||||||
CalendarCycles.next_cycle_start(join_cycle_start, interval)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
defmodule Mv.MembershipFees.Changes.ValidateSameInterval do
|
|
||||||
@moduledoc """
|
|
||||||
Validates that membership fee type changes only allow same-interval types.
|
|
||||||
|
|
||||||
Prevents changing from yearly to monthly, etc. (MVP constraint).
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
In a Member action:
|
|
||||||
|
|
||||||
update :update_member do
|
|
||||||
# ...
|
|
||||||
change Mv.MembershipFees.Changes.ValidateSameInterval
|
|
||||||
end
|
|
||||||
|
|
||||||
The change module only executes when `membership_fee_type_id` is being changed.
|
|
||||||
If the new type has a different interval than the current type, a validation error is returned.
|
|
||||||
"""
|
|
||||||
use Ash.Resource.Change
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def change(changeset, _opts, _context) do
|
|
||||||
if changing_membership_fee_type?(changeset) do
|
|
||||||
validate_interval_match(changeset)
|
|
||||||
else
|
|
||||||
changeset
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if membership_fee_type_id is being changed
|
|
||||||
defp changing_membership_fee_type?(changeset) do
|
|
||||||
Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Validate that the new type has the same interval as the current type
|
|
||||||
defp validate_interval_match(changeset) do
|
|
||||||
current_type_id = get_current_type_id(changeset)
|
|
||||||
new_type_id = get_new_type_id(changeset)
|
|
||||||
|
|
||||||
cond do
|
|
||||||
# If no current type, allow any change (first assignment)
|
|
||||||
is_nil(current_type_id) ->
|
|
||||||
changeset
|
|
||||||
|
|
||||||
# If new type is nil, reject the change (membership_fee_type_id is required)
|
|
||||||
is_nil(new_type_id) ->
|
|
||||||
add_nil_type_error(changeset)
|
|
||||||
|
|
||||||
# Both types exist - validate intervals match
|
|
||||||
true ->
|
|
||||||
validate_intervals_match(changeset, current_type_id, new_type_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Validates that intervals match when both types exist
|
|
||||||
defp validate_intervals_match(changeset, current_type_id, new_type_id) do
|
|
||||||
case get_intervals(current_type_id, new_type_id) do
|
|
||||||
{:ok, current_interval, new_interval} ->
|
|
||||||
if current_interval == new_interval do
|
|
||||||
changeset
|
|
||||||
else
|
|
||||||
add_interval_mismatch_error(changeset, current_interval, new_interval)
|
|
||||||
end
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
# Fail closed: If we can't load the types, reject the change
|
|
||||||
# This prevents inconsistent data states
|
|
||||||
add_type_validation_error(changeset, reason)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Get current type ID from changeset data
|
|
||||||
defp get_current_type_id(changeset) do
|
|
||||||
case changeset.data do
|
|
||||||
%{membership_fee_type_id: type_id} -> type_id
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Get new type ID from changeset
|
|
||||||
defp get_new_type_id(changeset) do
|
|
||||||
case Ash.Changeset.fetch_change(changeset, :membership_fee_type_id) do
|
|
||||||
{:ok, type_id} -> type_id
|
|
||||||
:error -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Get intervals for both types
|
|
||||||
defp get_intervals(current_type_id, new_type_id) do
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
|
|
||||||
case {Ash.get(MembershipFeeType, current_type_id), Ash.get(MembershipFeeType, new_type_id)} do
|
|
||||||
{{:ok, current_type}, {:ok, new_type}} ->
|
|
||||||
{:ok, current_type.interval, new_type.interval}
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
{:error, :type_not_found}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Add validation error for interval mismatch
|
|
||||||
defp add_interval_mismatch_error(changeset, current_interval, new_interval) do
|
|
||||||
current_interval_name = format_interval(current_interval)
|
|
||||||
new_interval_name = format_interval(new_interval)
|
|
||||||
|
|
||||||
message =
|
|
||||||
"Cannot change membership fee type: current type uses #{current_interval_name} interval, " <>
|
|
||||||
"new type uses #{new_interval_name} interval. Only same-interval changes are allowed."
|
|
||||||
|
|
||||||
Ash.Changeset.add_error(
|
|
||||||
changeset,
|
|
||||||
field: :membership_fee_type_id,
|
|
||||||
message: message
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Add validation error when types cannot be loaded
|
|
||||||
defp add_type_validation_error(changeset, _reason) do
|
|
||||||
message =
|
|
||||||
"Could not validate membership fee type intervals. " <>
|
|
||||||
"The current or new membership fee type no longer exists. " <>
|
|
||||||
"This may indicate a data consistency issue."
|
|
||||||
|
|
||||||
Ash.Changeset.add_error(
|
|
||||||
changeset,
|
|
||||||
field: :membership_fee_type_id,
|
|
||||||
message: message
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Add validation error when trying to set membership_fee_type_id to nil
|
|
||||||
defp add_nil_type_error(changeset) do
|
|
||||||
message = "Cannot remove membership fee type. A membership fee type is required."
|
|
||||||
|
|
||||||
Ash.Changeset.add_error(
|
|
||||||
changeset,
|
|
||||||
field: :membership_fee_type_id,
|
|
||||||
message: message
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Format interval atom to human-readable string
|
|
||||||
defp format_interval(:monthly), do: "monthly"
|
|
||||||
defp format_interval(:quarterly), do: "quarterly"
|
|
||||||
defp format_interval(:half_yearly), do: "half-yearly"
|
|
||||||
defp format_interval(:yearly), do: "yearly"
|
|
||||||
defp format_interval(interval), do: to_string(interval)
|
|
||||||
end
|
|
||||||
|
|
@ -51,36 +51,6 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
primary? true
|
primary? true
|
||||||
accept [:status, :notes]
|
accept [:status, :notes]
|
||||||
end
|
end
|
||||||
|
|
||||||
update :mark_as_paid do
|
|
||||||
description "Mark cycle as paid"
|
|
||||||
require_atomic? false
|
|
||||||
accept [:notes]
|
|
||||||
|
|
||||||
change fn changeset, _context ->
|
|
||||||
Ash.Changeset.force_change_attribute(changeset, :status, :paid)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
update :mark_as_suspended do
|
|
||||||
description "Mark cycle as suspended"
|
|
||||||
require_atomic? false
|
|
||||||
accept [:notes]
|
|
||||||
|
|
||||||
change fn changeset, _context ->
|
|
||||||
Ash.Changeset.force_change_attribute(changeset, :status, :suspended)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
update :mark_as_unpaid do
|
|
||||||
description "Mark cycle as unpaid (for error correction)"
|
|
||||||
require_atomic? false
|
|
||||||
accept [:notes]
|
|
||||||
|
|
||||||
change fn changeset, _context ->
|
|
||||||
Ash.Changeset.force_change_attribute(changeset, :status, :unpaid)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
attributes do
|
attributes do
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ defmodule Mv.MembershipFees.MembershipFeeType do
|
||||||
end
|
end
|
||||||
|
|
||||||
actions do
|
actions do
|
||||||
defaults [:read]
|
defaults [:read, :destroy]
|
||||||
|
|
||||||
create :create do
|
create :create do
|
||||||
primary? true
|
primary? true
|
||||||
|
|
@ -45,108 +45,10 @@ defmodule Mv.MembershipFees.MembershipFeeType do
|
||||||
|
|
||||||
update :update do
|
update :update do
|
||||||
primary? true
|
primary? true
|
||||||
# require_atomic? false because validation queries (member/cycle counts) are not atomic
|
|
||||||
# DB constraints serve as the final safeguard if data changes between validation and update
|
|
||||||
require_atomic? false
|
|
||||||
# Note: interval is NOT in accept list - it's immutable after creation
|
# Note: interval is NOT in accept list - it's immutable after creation
|
||||||
|
# Immutability validation will be added in a future issue
|
||||||
accept [:name, :amount, :description]
|
accept [:name, :amount, :description]
|
||||||
end
|
end
|
||||||
|
|
||||||
destroy :destroy do
|
|
||||||
primary? true
|
|
||||||
|
|
||||||
# require_atomic? false because validation queries (member/cycle/settings counts) are not atomic
|
|
||||||
# DB constraints serve as the final safeguard if data changes between validation and delete
|
|
||||||
require_atomic? false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
validations do
|
|
||||||
# Prevent interval changes after creation
|
|
||||||
validate fn changeset, _context ->
|
|
||||||
if Ash.Changeset.changing_attribute?(changeset, :interval) do
|
|
||||||
case changeset.data do
|
|
||||||
# Creating new resource, interval can be set
|
|
||||||
nil ->
|
|
||||||
:ok
|
|
||||||
|
|
||||||
_existing ->
|
|
||||||
{:error,
|
|
||||||
field: :interval, message: "Interval cannot be changed after creation"}
|
|
||||||
end
|
|
||||||
else
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
on: [:update]
|
|
||||||
|
|
||||||
# Prevent deletion if assigned to members
|
|
||||||
validate fn changeset, _context ->
|
|
||||||
if changeset.action_type == :destroy do
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
member_count =
|
|
||||||
Mv.Membership.Member
|
|
||||||
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|
|
||||||
|> Ash.count!()
|
|
||||||
|
|
||||||
if member_count > 0 do
|
|
||||||
{:error,
|
|
||||||
message:
|
|
||||||
"Cannot delete membership fee type: #{member_count} member(s) are assigned to it"}
|
|
||||||
else
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
else
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
on: [:destroy]
|
|
||||||
|
|
||||||
# Prevent deletion if cycles exist
|
|
||||||
validate fn changeset, _context ->
|
|
||||||
if changeset.action_type == :destroy do
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
cycle_count =
|
|
||||||
Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(membership_fee_type_id == ^changeset.data.id)
|
|
||||||
|> Ash.count!()
|
|
||||||
|
|
||||||
if cycle_count > 0 do
|
|
||||||
{:error,
|
|
||||||
message:
|
|
||||||
"Cannot delete membership fee type: #{cycle_count} cycle(s) reference it"}
|
|
||||||
else
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
else
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
on: [:destroy]
|
|
||||||
|
|
||||||
# Prevent deletion if used as default in settings
|
|
||||||
validate fn changeset, _context ->
|
|
||||||
if changeset.action_type == :destroy do
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
setting_count =
|
|
||||||
Mv.Membership.Setting
|
|
||||||
|> Ash.Query.filter(default_membership_fee_type_id == ^changeset.data.id)
|
|
||||||
|> Ash.count!()
|
|
||||||
|
|
||||||
if setting_count > 0 do
|
|
||||||
{:error,
|
|
||||||
message: "Cannot delete membership fee type: it's used as default in settings"}
|
|
||||||
else
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
else
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
on: [:destroy]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
attributes do
|
attributes do
|
||||||
|
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
defmodule Mv.MembershipFees.CycleGenerationJob do
|
|
||||||
@moduledoc """
|
|
||||||
Scheduled job for generating membership fee cycles.
|
|
||||||
|
|
||||||
This module provides a skeleton for scheduled cycle generation.
|
|
||||||
In the future, this can be integrated with Oban or similar job processing libraries.
|
|
||||||
|
|
||||||
## Current Implementation
|
|
||||||
|
|
||||||
Currently provides manual execution functions that can be called:
|
|
||||||
- From IEx console for administrative tasks
|
|
||||||
- From a cron job via a Mix task
|
|
||||||
- From the admin UI (future)
|
|
||||||
|
|
||||||
## Future Oban Integration
|
|
||||||
|
|
||||||
When Oban is added to the project, this module can be converted to an Oban worker:
|
|
||||||
|
|
||||||
defmodule Mv.MembershipFees.CycleGenerationJob do
|
|
||||||
use Oban.Worker,
|
|
||||||
queue: :membership_fees,
|
|
||||||
max_attempts: 3
|
|
||||||
|
|
||||||
@impl Oban.Worker
|
|
||||||
def perform(%Oban.Job{}) do
|
|
||||||
Mv.MembershipFees.CycleGenerator.generate_cycles_for_all_members()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
# Manual execution from IEx
|
|
||||||
Mv.MembershipFees.CycleGenerationJob.run()
|
|
||||||
|
|
||||||
# Check if cycles need to be generated
|
|
||||||
Mv.MembershipFees.CycleGenerationJob.pending_members_count()
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
alias Mv.MembershipFees.CycleGenerator
|
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Runs the cycle generation job for all active members.
|
|
||||||
|
|
||||||
This is the main entry point for scheduled execution.
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
- `{:ok, results}` - Map with success/failed counts
|
|
||||||
- `{:error, reason}` - Error with reason
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> Mv.MembershipFees.CycleGenerationJob.run()
|
|
||||||
{:ok, %{success: 45, failed: 0, total: 45}}
|
|
||||||
|
|
||||||
"""
|
|
||||||
@spec run() :: {:ok, map()} | {:error, term()}
|
|
||||||
def run do
|
|
||||||
Logger.info("Starting membership fee cycle generation job")
|
|
||||||
start_time = System.monotonic_time(:millisecond)
|
|
||||||
|
|
||||||
result = CycleGenerator.generate_cycles_for_all_members()
|
|
||||||
|
|
||||||
elapsed = System.monotonic_time(:millisecond) - start_time
|
|
||||||
|
|
||||||
case result do
|
|
||||||
{:ok, stats} ->
|
|
||||||
Logger.info(
|
|
||||||
"Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total"
|
|
||||||
)
|
|
||||||
|
|
||||||
result
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
Logger.error("Cycle generation failed: #{inspect(reason)}")
|
|
||||||
result
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Runs cycle generation with custom options.
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
- `:today` - Override today's date (useful for testing or catch-up)
|
|
||||||
- `:batch_size` - Number of members to process in parallel
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
# Generate cycles as if today was a specific date
|
|
||||||
Mv.MembershipFees.CycleGenerationJob.run(today: ~D[2024-12-31])
|
|
||||||
|
|
||||||
# Process with smaller batch size
|
|
||||||
Mv.MembershipFees.CycleGenerationJob.run(batch_size: 5)
|
|
||||||
|
|
||||||
"""
|
|
||||||
@spec run(keyword()) :: {:ok, map()} | {:error, term()}
|
|
||||||
def run(opts) when is_list(opts) do
|
|
||||||
Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}")
|
|
||||||
start_time = System.monotonic_time(:millisecond)
|
|
||||||
|
|
||||||
result = CycleGenerator.generate_cycles_for_all_members(opts)
|
|
||||||
|
|
||||||
elapsed = System.monotonic_time(:millisecond) - start_time
|
|
||||||
|
|
||||||
case result do
|
|
||||||
{:ok, stats} ->
|
|
||||||
Logger.info(
|
|
||||||
"Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total"
|
|
||||||
)
|
|
||||||
|
|
||||||
result
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
Logger.error("Cycle generation failed: #{inspect(reason)}")
|
|
||||||
result
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns the count of members that need cycle generation.
|
|
||||||
|
|
||||||
A member needs cycle generation if:
|
|
||||||
- Has a membership_fee_type assigned
|
|
||||||
- Has a join_date set
|
|
||||||
- Is active (no exit_date or exit_date >= today)
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
- `{:ok, count}` - Number of members needing generation
|
|
||||||
- `{:error, reason}` - Error with reason
|
|
||||||
|
|
||||||
"""
|
|
||||||
@spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, term()}
|
|
||||||
def pending_members_count do
|
|
||||||
today = Date.utc_today()
|
|
||||||
|
|
||||||
query =
|
|
||||||
Mv.Membership.Member
|
|
||||||
|> Ash.Query.filter(not is_nil(membership_fee_type_id))
|
|
||||||
|> Ash.Query.filter(not is_nil(join_date))
|
|
||||||
|> Ash.Query.filter(is_nil(exit_date) or exit_date >= ^today)
|
|
||||||
|
|
||||||
case Ash.count(query) do
|
|
||||||
{:ok, count} -> {:ok, count}
|
|
||||||
{:error, reason} -> {:error, reason}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generates cycles for a specific member by ID.
|
|
||||||
|
|
||||||
Useful for administrative tasks or manual corrections.
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `member_id` - The UUID of the member
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
- `{:ok, cycles}` - List of newly created cycles
|
|
||||||
- `{:error, reason}` - Error with reason
|
|
||||||
|
|
||||||
"""
|
|
||||||
@spec run_for_member(String.t()) :: {:ok, [map()]} | {:error, term()}
|
|
||||||
def run_for_member(member_id) when is_binary(member_id) do
|
|
||||||
Logger.info("Generating cycles for member #{member_id}")
|
|
||||||
CycleGenerator.generate_cycles_for_member(member_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,410 +0,0 @@
|
||||||
defmodule Mv.MembershipFees.CycleGenerator do
|
|
||||||
@moduledoc """
|
|
||||||
Module for generating membership fee cycles for members.
|
|
||||||
|
|
||||||
This module provides functions to automatically generate membership fee cycles
|
|
||||||
based on a member's fee type, start date, and exit date.
|
|
||||||
|
|
||||||
## Algorithm
|
|
||||||
|
|
||||||
1. Load member with relationships (membership_fee_type, membership_fee_cycles)
|
|
||||||
2. Determine the generation start point:
|
|
||||||
- If NO cycles exist: Start from `membership_fee_start_date` (or calculated from `join_date`)
|
|
||||||
- If cycles exist: Start from the cycle AFTER the last existing one
|
|
||||||
3. Generate all cycle starts from the determined start point to today (or `exit_date`)
|
|
||||||
4. Create new cycles with the current amount from `membership_fee_type`
|
|
||||||
|
|
||||||
## Important: Gap Handling
|
|
||||||
|
|
||||||
**Gaps are NOT filled.** If a cycle was explicitly deleted (e.g., 2023 was deleted
|
|
||||||
but 2022 and 2024 exist), the generator will NOT recreate the deleted cycle.
|
|
||||||
It always continues from the LAST existing cycle, regardless of any gaps.
|
|
||||||
|
|
||||||
This behavior ensures that manually deleted cycles remain deleted and prevents
|
|
||||||
unwanted automatic recreation of intentionally removed cycles.
|
|
||||||
|
|
||||||
## Concurrency
|
|
||||||
|
|
||||||
Uses PostgreSQL advisory locks to prevent race conditions when generating
|
|
||||||
cycles for the same member concurrently.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
# Generate cycles for a single member
|
|
||||||
{:ok, cycles} = CycleGenerator.generate_cycles_for_member(member)
|
|
||||||
|
|
||||||
# Generate cycles for all active members
|
|
||||||
{:ok, results} = CycleGenerator.generate_cycles_for_all_members()
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
|
||||||
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.Repo
|
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
@type generate_result ::
|
|
||||||
{:ok, [MembershipFeeCycle.t()], [Ash.Notifier.Notification.t()]} | {:error, term()}
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generates membership fee cycles for a single member.
|
|
||||||
|
|
||||||
Uses an advisory lock to prevent concurrent generation for the same member.
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `member` - The member struct or member ID
|
|
||||||
- `opts` - Options:
|
|
||||||
- `:today` - Override today's date (useful for testing)
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
- `{:ok, cycles, notifications}` - List of newly created cycles and notifications
|
|
||||||
- `{:error, reason}` - Error with reason
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
{:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member)
|
|
||||||
{:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member_id)
|
|
||||||
{:ok, cycles, notifications} = CycleGenerator.generate_cycles_for_member(member, today: ~D[2024-12-31])
|
|
||||||
|
|
||||||
"""
|
|
||||||
@spec generate_cycles_for_member(Member.t() | String.t(), keyword()) :: generate_result()
|
|
||||||
def generate_cycles_for_member(member_or_id, opts \\ [])
|
|
||||||
|
|
||||||
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
|
|
||||||
case load_member(member_id) do
|
|
||||||
{:ok, member} -> generate_cycles_for_member(member, opts)
|
|
||||||
{:error, reason} -> {:error, reason}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_cycles_for_member(%Member{} = member, opts) do
|
|
||||||
today = Keyword.get(opts, :today, Date.utc_today())
|
|
||||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
|
||||||
|
|
||||||
do_generate_cycles_with_lock(member, today, skip_lock?)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Generate cycles with lock handling
|
|
||||||
# Returns {:ok, cycles, notifications} - notifications are never sent here,
|
|
||||||
# they should be returned to the caller (e.g., via after_action hook)
|
|
||||||
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do
|
|
||||||
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
|
|
||||||
# Just generate cycles without additional locking
|
|
||||||
do_generate_cycles(member, today)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp do_generate_cycles_with_lock(member, today, false) do
|
|
||||||
lock_key = :erlang.phash2(member.id)
|
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
|
||||||
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
|
||||||
|
|
||||||
case do_generate_cycles(member, today) do
|
|
||||||
{:ok, cycles, notifications} ->
|
|
||||||
# Return cycles and notifications - do NOT send notifications here
|
|
||||||
# They will be sent by the caller (e.g., via after_action hook)
|
|
||||||
{cycles, notifications}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
Repo.rollback(reason)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|> case do
|
|
||||||
{:ok, {cycles, notifications}} -> {:ok, cycles, notifications}
|
|
||||||
{:error, reason} -> {:error, reason}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generates membership fee cycles for all members with a fee type assigned.
|
|
||||||
|
|
||||||
This includes both active and inactive (left) members. Inactive members
|
|
||||||
will have cycles generated up to their exit_date if they don't have cycles
|
|
||||||
for that period yet. This allows for catch-up generation of missing cycles.
|
|
||||||
|
|
||||||
Members processed are those who:
|
|
||||||
- Have a membership_fee_type assigned
|
|
||||||
- Have a join_date set
|
|
||||||
|
|
||||||
The exit_date boundary is respected during generation (not in the query),
|
|
||||||
so inactive members will get cycles up to their exit date.
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `opts` - Options:
|
|
||||||
- `:today` - Override today's date (useful for testing)
|
|
||||||
- `:batch_size` - Number of members to process in parallel (default: 10)
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
- `{:ok, results}` - Map with :success and :failed counts
|
|
||||||
- `{:error, reason}` - Error with reason
|
|
||||||
|
|
||||||
"""
|
|
||||||
@spec generate_cycles_for_all_members(keyword()) :: {:ok, map()} | {:error, term()}
|
|
||||||
def generate_cycles_for_all_members(opts \\ []) do
|
|
||||||
today = Keyword.get(opts, :today, Date.utc_today())
|
|
||||||
batch_size = Keyword.get(opts, :batch_size, 10)
|
|
||||||
|
|
||||||
# Query ALL members with fee type assigned (including inactive/left members)
|
|
||||||
# The exit_date boundary is applied during cycle generation, not here.
|
|
||||||
# This allows catch-up generation for members who left but are missing cycles.
|
|
||||||
query =
|
|
||||||
Member
|
|
||||||
|> Ash.Query.filter(not is_nil(membership_fee_type_id))
|
|
||||||
|> Ash.Query.filter(not is_nil(join_date))
|
|
||||||
|
|
||||||
case Ash.read(query) do
|
|
||||||
{:ok, members} ->
|
|
||||||
results = process_members_in_batches(members, batch_size, today)
|
|
||||||
{:ok, build_results_summary(results)}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
{:error, reason}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp process_members_in_batches(members, batch_size, today) do
|
|
||||||
members
|
|
||||||
|> Enum.chunk_every(batch_size)
|
|
||||||
|> Enum.flat_map(&process_batch(&1, today))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp process_batch(batch, today) do
|
|
||||||
batch
|
|
||||||
|> Task.async_stream(fn member ->
|
|
||||||
process_member_cycle_generation(member, today)
|
|
||||||
end)
|
|
||||||
|> Enum.map(fn
|
|
||||||
{:ok, result} ->
|
|
||||||
result
|
|
||||||
|
|
||||||
{:exit, reason} ->
|
|
||||||
# Task crashed - log and return error tuple
|
|
||||||
Logger.error("Task crashed during cycle generation: #{inspect(reason)}")
|
|
||||||
{nil, {:error, {:task_exit, reason}}}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Process cycle generation for a single member in batch job
|
|
||||||
# Returns {member_id, result} tuple where result is {:ok, cycles, notifications} or {:error, reason}
|
|
||||||
defp process_member_cycle_generation(member, today) do
|
|
||||||
case generate_cycles_for_member(member, today: today) do
|
|
||||||
{:ok, _cycles, notifications} = ok ->
|
|
||||||
send_notifications_for_batch_job(notifications)
|
|
||||||
{member.id, ok}
|
|
||||||
|
|
||||||
{:error, _reason} = err ->
|
|
||||||
{member.id, err}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Send notifications for batch job
|
|
||||||
# This is a top-level job, so we need to send notifications explicitly
|
|
||||||
defp send_notifications_for_batch_job(notifications) do
|
|
||||||
if Enum.any?(notifications) do
|
|
||||||
Ash.Notifier.notify(notifications)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_results_summary(results) do
|
|
||||||
success_count = Enum.count(results, fn {_id, result} -> match?({:ok, _, _}, result) end)
|
|
||||||
failed_count = Enum.count(results, fn {_id, result} -> match?({:error, _}, result) end)
|
|
||||||
|
|
||||||
%{success: success_count, failed: failed_count, total: length(results)}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Private functions
|
|
||||||
|
|
||||||
defp load_member(member_id) do
|
|
||||||
Member
|
|
||||||
|> Ash.Query.filter(id == ^member_id)
|
|
||||||
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
|
|
||||||
|> Ash.read_one()
|
|
||||||
|> case do
|
|
||||||
{:ok, nil} -> {:error, :member_not_found}
|
|
||||||
{:ok, member} -> {:ok, member}
|
|
||||||
{:error, reason} -> {:error, reason}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp do_generate_cycles(member, today) do
|
|
||||||
# Reload member with relationships to ensure fresh data
|
|
||||||
case load_member(member.id) do
|
|
||||||
{:ok, member} ->
|
|
||||||
cond do
|
|
||||||
is_nil(member.membership_fee_type_id) ->
|
|
||||||
{:error, :no_membership_fee_type}
|
|
||||||
|
|
||||||
is_nil(member.join_date) ->
|
|
||||||
{:error, :no_join_date}
|
|
||||||
|
|
||||||
true ->
|
|
||||||
generate_missing_cycles(member, today)
|
|
||||||
end
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
{:error, reason}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp generate_missing_cycles(member, today) do
|
|
||||||
fee_type = member.membership_fee_type
|
|
||||||
interval = fee_type.interval
|
|
||||||
amount = fee_type.amount
|
|
||||||
existing_cycles = member.membership_fee_cycles || []
|
|
||||||
|
|
||||||
# Determine start point based on existing cycles
|
|
||||||
# Note: We do NOT fill gaps - only generate from the last existing cycle onwards
|
|
||||||
start_date = determine_generation_start(member, existing_cycles, interval)
|
|
||||||
|
|
||||||
# Determine end date (today or exit_date, whichever is earlier)
|
|
||||||
end_date = determine_end_date(member, today)
|
|
||||||
|
|
||||||
# Only generate if start_date <= end_date
|
|
||||||
if start_date && Date.compare(start_date, end_date) != :gt do
|
|
||||||
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
|
|
||||||
create_cycles(cycle_starts, member.id, fee_type.id, amount)
|
|
||||||
else
|
|
||||||
{:ok, [], []}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# No existing cycles: start from membership_fee_start_date
|
|
||||||
defp determine_generation_start(member, [], interval) do
|
|
||||||
determine_start_date(member, interval)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Has existing cycles: start from the cycle AFTER the last one
|
|
||||||
# This ensures gaps (deleted cycles) are NOT filled
|
|
||||||
defp determine_generation_start(_member, existing_cycles, interval) do
|
|
||||||
last_cycle_start =
|
|
||||||
existing_cycles
|
|
||||||
|> Enum.map(& &1.cycle_start)
|
|
||||||
|> Enum.max(Date)
|
|
||||||
|
|
||||||
CalendarCycles.next_cycle_start(last_cycle_start, interval)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp determine_start_date(member, interval) do
|
|
||||||
if member.membership_fee_start_date do
|
|
||||||
member.membership_fee_start_date
|
|
||||||
else
|
|
||||||
# Calculate from join_date using global settings
|
|
||||||
include_joining_cycle = get_include_joining_cycle()
|
|
||||||
|
|
||||||
SetMembershipFeeStartDate.calculate_start_date(
|
|
||||||
member.join_date,
|
|
||||||
interval,
|
|
||||||
include_joining_cycle
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp determine_end_date(member, today) do
|
|
||||||
if member.exit_date && Date.compare(member.exit_date, today) == :lt do
|
|
||||||
# Member has left - use the exit date as boundary
|
|
||||||
# Note: If exit_date == cycle_start, the cycle IS still generated.
|
|
||||||
# This means the member is considered a member on the first day of that cycle.
|
|
||||||
# Example: exit_date = 2025-01-01, yearly interval
|
|
||||||
# -> The 2025 cycle (starting 2025-01-01) WILL be generated
|
|
||||||
member.exit_date
|
|
||||||
else
|
|
||||||
today
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp get_include_joining_cycle do
|
|
||||||
case Mv.Membership.get_settings() do
|
|
||||||
{:ok, %{include_joining_cycle: include}} -> include
|
|
||||||
{:error, _} -> true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Generates all cycle start dates from a start date to an end date.
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
|
|
||||||
- `start_date` - The first cycle start date
|
|
||||||
- `end_date` - The date up to which cycles should be generated
|
|
||||||
- `interval` - The billing interval
|
|
||||||
|
|
||||||
## Returns
|
|
||||||
|
|
||||||
List of cycle start dates.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
iex> generate_cycle_starts(~D[2024-01-01], ~D[2024-12-31], :quarterly)
|
|
||||||
[~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01], ~D[2024-10-01]]
|
|
||||||
|
|
||||||
"""
|
|
||||||
@spec generate_cycle_starts(Date.t(), Date.t(), atom()) :: [Date.t()]
|
|
||||||
def generate_cycle_starts(start_date, end_date, interval) do
|
|
||||||
# Ensure start_date is aligned to cycle boundary
|
|
||||||
aligned_start = CalendarCycles.calculate_cycle_start(start_date, interval)
|
|
||||||
|
|
||||||
generate_cycle_starts_acc(aligned_start, end_date, interval, [])
|
|
||||||
end
|
|
||||||
|
|
||||||
defp generate_cycle_starts_acc(current_start, end_date, interval, acc) do
|
|
||||||
if Date.compare(current_start, end_date) == :gt do
|
|
||||||
# Current cycle start is after end date - stop
|
|
||||||
Enum.reverse(acc)
|
|
||||||
else
|
|
||||||
# Include this cycle and continue to next
|
|
||||||
next_start = CalendarCycles.next_cycle_start(current_start, interval)
|
|
||||||
generate_cycle_starts_acc(next_start, end_date, interval, [current_start | acc])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do
|
|
||||||
# Always use return_notifications?: true to collect notifications
|
|
||||||
# Notifications will be returned to the caller, who is responsible for
|
|
||||||
# sending them (e.g., via after_action hook returning {:ok, result, notifications})
|
|
||||||
results =
|
|
||||||
Enum.map(cycle_starts, fn cycle_start ->
|
|
||||||
attrs = %{
|
|
||||||
cycle_start: cycle_start,
|
|
||||||
member_id: member_id,
|
|
||||||
membership_fee_type_id: fee_type_id,
|
|
||||||
amount: amount,
|
|
||||||
status: :unpaid
|
|
||||||
}
|
|
||||||
|
|
||||||
case Ash.create(MembershipFeeCycle, attrs, return_notifications?: true) do
|
|
||||||
{:ok, cycle, notifications} when is_list(notifications) ->
|
|
||||||
{:ok, cycle, notifications}
|
|
||||||
|
|
||||||
{:ok, cycle} ->
|
|
||||||
{:ok, cycle, []}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
{:error, {cycle_start, reason}}
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
{successes, errors} = Enum.split_with(results, &match?({:ok, _, _}, &1))
|
|
||||||
|
|
||||||
all_notifications =
|
|
||||||
Enum.flat_map(successes, fn {:ok, _cycle, notifications} -> notifications end)
|
|
||||||
|
|
||||||
if Enum.empty?(errors) do
|
|
||||||
successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end)
|
|
||||||
{:ok, successful_cycles, all_notifications}
|
|
||||||
else
|
|
||||||
Logger.warning("Some cycles failed to create: #{inspect(errors)}")
|
|
||||||
# Return partial failure with errors
|
|
||||||
# Note: When this error occurs, the transaction will be rolled back,
|
|
||||||
# so no cycles were actually persisted in the database
|
|
||||||
{:error, {:partial_failure, errors}}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -34,9 +34,7 @@ defmodule MvWeb.Layouts.Navbar do
|
||||||
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
|
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
|
||||||
<li><.link navigate="/contribution_types">{gettext("Contribution Types")}</.link></li>
|
<li><.link navigate="/contribution_types">{gettext("Contribution Types")}</.link></li>
|
||||||
<li>
|
<li>
|
||||||
<.link navigate="/membership_fee_settings">
|
<.link navigate="/contribution_settings">{gettext("Contribution Settings")}</.link>
|
||||||
{gettext("Membership Fee Settings")}
|
|
||||||
</.link>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ defmodule MvWeb.ContributionPeriodLive.Show do
|
||||||
· {gettext("Member since")}: <span class="font-mono">{@member.joined_at}</span>
|
· {gettext("Member since")}: <span class="font-mono">{@member.joined_at}</span>
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
<:actions>
|
<:actions>
|
||||||
<.link navigate={~p"/membership_fee_settings"} class="btn btn-ghost btn-sm">
|
<.link navigate={~p"/contribution_settings"} class="btn btn-ghost btn-sm">
|
||||||
<.icon name="hero-arrow-left" class="size-4" />
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
{gettext("Back to Settings")}
|
{gettext("Back to Settings")}
|
||||||
</.link>
|
</.link>
|
||||||
|
|
|
||||||
277
lib/mv_web/live/contribution_settings_live.ex
Normal file
277
lib/mv_web/live/contribution_settings_live.ex
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
defmodule MvWeb.ContributionSettingsLive do
|
||||||
|
@moduledoc """
|
||||||
|
Mock-up LiveView for Contribution Settings (Admin).
|
||||||
|
|
||||||
|
This is a preview-only page that displays the planned UI for managing
|
||||||
|
global contribution settings. It shows static mock data and is not functional.
|
||||||
|
|
||||||
|
## Planned Features (Future Implementation)
|
||||||
|
- Set default contribution type for new members
|
||||||
|
- Configure whether joining period is included in contributions
|
||||||
|
- Explanatory text with examples
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
- `default_contribution_type_id` - UUID of the default contribution type
|
||||||
|
- `include_joining_period` - Boolean whether to include joining period
|
||||||
|
|
||||||
|
## Note
|
||||||
|
This page is intentionally non-functional and serves as a UI mockup
|
||||||
|
for the upcoming Membership Contributions feature.
|
||||||
|
"""
|
||||||
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, gettext("Contribution Settings"))
|
||||||
|
|> assign(:contribution_types, mock_contribution_types())
|
||||||
|
|> assign(:selected_type_id, "1")
|
||||||
|
|> assign(:include_joining_period, true)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
|
<.mockup_warning />
|
||||||
|
|
||||||
|
<.header>
|
||||||
|
{gettext("Contribution Settings")}
|
||||||
|
<:subtitle>
|
||||||
|
{gettext("Configure global settings for membership contributions.")}
|
||||||
|
</:subtitle>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
|
<%!-- Settings Form --%>
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<.icon name="hero-cog-6-tooth" class="size-5" />
|
||||||
|
{gettext("Global Settings")}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form class="space-y-6">
|
||||||
|
<%!-- Default Contribution Type --%>
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">
|
||||||
|
{gettext("Default Contribution Type")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<select class="select select-bordered w-full" disabled>
|
||||||
|
<option :for={ct <- @contribution_types} selected={ct.id == @selected_type_id}>
|
||||||
|
{ct.name} ({format_currency(ct.amount)}, {format_interval(ct.interval)})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-sm text-base-content/60 mt-2">
|
||||||
|
{gettext(
|
||||||
|
"This contribution type is automatically assigned to all new members. Can be changed individually per member."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<%!-- Include Joining Period --%>
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<label class="label cursor-pointer justify-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-primary"
|
||||||
|
checked={@include_joining_period}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<span class="label-text font-semibold">
|
||||||
|
{gettext("Include joining period")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="ml-9 space-y-2">
|
||||||
|
<p class="text-sm text-base-content/60">
|
||||||
|
{gettext("When active: Members pay from the period of their joining.")}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-base-content/60">
|
||||||
|
{gettext("When inactive: Members pay from the next full period after joining.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-primary w-full" disabled>
|
||||||
|
<.icon name="hero-check" class="size-5" />
|
||||||
|
{gettext("Save Settings")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Examples Card --%>
|
||||||
|
<div class="card bg-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<.icon name="hero-light-bulb" class="size-5" />
|
||||||
|
{gettext("Examples")}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<.example_section
|
||||||
|
title={gettext("Yearly Interval - Joining Period Included")}
|
||||||
|
joining_date="15.03.2023"
|
||||||
|
include_joining={true}
|
||||||
|
start_date="01.01.2023"
|
||||||
|
periods={["2023", "2024", "2025"]}
|
||||||
|
note={gettext("Member pays for the year they joined")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<.example_section
|
||||||
|
title={gettext("Yearly Interval - Joining Period Excluded")}
|
||||||
|
joining_date="15.03.2023"
|
||||||
|
include_joining={false}
|
||||||
|
start_date="01.01.2024"
|
||||||
|
periods={["2024", "2025"]}
|
||||||
|
note={gettext("Member pays from the next full year")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<.example_section
|
||||||
|
title={gettext("Quarterly Interval - Joining Period Excluded")}
|
||||||
|
joining_date="15.05.2024"
|
||||||
|
include_joining={false}
|
||||||
|
start_date="01.07.2024"
|
||||||
|
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
|
||||||
|
note={gettext("Member pays from the next full quarter")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<.example_section
|
||||||
|
title={gettext("Monthly Interval - Joining Period Included")}
|
||||||
|
joining_date="15.03.2024"
|
||||||
|
include_joining={true}
|
||||||
|
start_date="01.03.2024"
|
||||||
|
periods={["03/2024", "04/2024", "05/2024", "..."]}
|
||||||
|
note={gettext("Member pays from the joining month")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.example_member_card />
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Example member card with link to period view
|
||||||
|
defp example_member_card(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="card bg-base-100 shadow-xl mt-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">
|
||||||
|
<.icon name="hero-user" class="size-5" />
|
||||||
|
{gettext("Example: Member Contribution View")}
|
||||||
|
</h2>
|
||||||
|
<p class="text-base-content/70">
|
||||||
|
{gettext(
|
||||||
|
"See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<.link navigate={~p"/contributions/member/example"} class="btn btn-primary btn-sm">
|
||||||
|
<.icon name="hero-eye" class="size-4" />
|
||||||
|
{gettext("View Example Member")}
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mock-up warning banner component - subtle orange style
|
||||||
|
defp mockup_warning(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="border border-warning text-warning bg-base-100 rounded-lg px-4 py-3 mb-6 flex items-center gap-3">
|
||||||
|
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">{gettext("Preview Mockup")}</span>
|
||||||
|
<span class="text-sm text-base-content/70 ml-2">
|
||||||
|
– {gettext("This page is not functional and only displays the planned features.")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Example section component
|
||||||
|
attr :title, :string, required: true
|
||||||
|
attr :joining_date, :string, required: true
|
||||||
|
attr :include_joining, :boolean, required: true
|
||||||
|
attr :start_date, :string, required: true
|
||||||
|
attr :periods, :list, required: true
|
||||||
|
attr :note, :string, required: true
|
||||||
|
|
||||||
|
defp example_section(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h3 class="font-semibold text-sm">{@title}</h3>
|
||||||
|
<div class="bg-base-300 rounded-lg p-3 text-sm space-y-1">
|
||||||
|
<p>
|
||||||
|
<span class="text-base-content/60">{gettext("Joining date")}:</span>
|
||||||
|
<span class="font-mono">{@joining_date}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="text-base-content/60">{gettext("Contribution start")}:</span>
|
||||||
|
<span class="font-mono font-semibold text-primary">{@start_date}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="text-base-content/60">{gettext("Generated periods")}:</span>
|
||||||
|
<span class="font-mono">
|
||||||
|
{Enum.join(@periods, ", ")}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-base-content/60 italic">→ {@note}</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mock data for demonstration
|
||||||
|
defp mock_contribution_types do
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
id: "1",
|
||||||
|
name: gettext("Regular"),
|
||||||
|
amount: Decimal.new("60.00"),
|
||||||
|
interval: :yearly
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
id: "2",
|
||||||
|
name: gettext("Reduced"),
|
||||||
|
amount: Decimal.new("30.00"),
|
||||||
|
interval: :yearly
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
id: "3",
|
||||||
|
name: gettext("Student"),
|
||||||
|
amount: Decimal.new("5.00"),
|
||||||
|
interval: :monthly
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
id: "4",
|
||||||
|
name: gettext("Family"),
|
||||||
|
amount: Decimal.new("25.00"),
|
||||||
|
interval: :quarterly
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_currency(%Decimal{} = amount) do
|
||||||
|
"#{Decimal.to_string(amount)} €"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_interval(:monthly), do: gettext("Monthly")
|
||||||
|
defp format_interval(:quarterly), do: gettext("Quarterly")
|
||||||
|
defp format_interval(:half_yearly), do: gettext("Half-yearly")
|
||||||
|
defp format_interval(:yearly), do: gettext("Yearly")
|
||||||
|
end
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
defmodule MvWeb.MembershipFeeSettingsLive do
|
|
||||||
@moduledoc """
|
|
||||||
LiveView for managing membership fee settings (Admin).
|
|
||||||
|
|
||||||
Allows administrators to configure:
|
|
||||||
- Default membership fee type for new members
|
|
||||||
- Whether to include the joining cycle in membership fee generation
|
|
||||||
"""
|
|
||||||
use MvWeb, :live_view
|
|
||||||
|
|
||||||
alias Mv.Membership
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def mount(_params, _session, socket) do
|
|
||||||
{:ok, settings} = Membership.get_settings()
|
|
||||||
|
|
||||||
membership_fee_types =
|
|
||||||
MembershipFeeType
|
|
||||||
|> Ash.Query.sort(name: :asc)
|
|
||||||
|> Ash.read!()
|
|
||||||
|
|
||||||
{:ok,
|
|
||||||
socket
|
|
||||||
|> assign(:page_title, gettext("Membership Fee Settings"))
|
|
||||||
|> assign(:settings, settings)
|
|
||||||
|> assign(:membership_fee_types, membership_fee_types)
|
|
||||||
|> assign_form()}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("validate", %{"settings" => params}, socket) do
|
|
||||||
{:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, params))}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("save", %{"settings" => params}, socket) do
|
|
||||||
case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
|
|
||||||
{:ok, updated_settings} ->
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> assign(:settings, updated_settings)
|
|
||||||
|> put_flash(:info, gettext("Settings saved successfully."))
|
|
||||||
|> assign_form()}
|
|
||||||
|
|
||||||
{:error, form} ->
|
|
||||||
{:noreply, assign(socket, form: form)}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def render(assigns) do
|
|
||||||
~H"""
|
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
|
||||||
<.header>
|
|
||||||
{gettext("Membership Fee Settings")}
|
|
||||||
<:subtitle>
|
|
||||||
{gettext("Configure global settings for membership fees.")}
|
|
||||||
</:subtitle>
|
|
||||||
</.header>
|
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-2">
|
|
||||||
<%!-- Settings Form --%>
|
|
||||||
<div class="card bg-base-100 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title">
|
|
||||||
<.icon name="hero-cog-6-tooth" class="size-5" />
|
|
||||||
{gettext("Global Settings")}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<.form
|
|
||||||
for={@form}
|
|
||||||
phx-change="validate"
|
|
||||||
phx-submit="save"
|
|
||||||
class="space-y-6"
|
|
||||||
>
|
|
||||||
<%!-- Default Membership Fee Type --%>
|
|
||||||
<fieldset class="fieldset">
|
|
||||||
<label for="default_membership_fee_type_id" class="label">
|
|
||||||
<span class="label-text font-semibold">
|
|
||||||
{gettext("Default Membership Fee Type")}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="default_membership_fee_type_id"
|
|
||||||
name="settings[default_membership_fee_type_id]"
|
|
||||||
class={[
|
|
||||||
"select select-bordered w-full",
|
|
||||||
if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "")
|
|
||||||
]}
|
|
||||||
phx-debounce="blur"
|
|
||||||
aria-label={gettext("Default Membership Fee Type")}
|
|
||||||
>
|
|
||||||
<option value="">{gettext("None (no default)")}</option>
|
|
||||||
<option
|
|
||||||
:for={fee_type <- @membership_fee_types}
|
|
||||||
value={fee_type.id}
|
|
||||||
selected={fee_type.id == @form[:default_membership_fee_type_id].value}
|
|
||||||
>
|
|
||||||
{fee_type.name} ({format_currency(fee_type.amount)}, {format_interval(
|
|
||||||
fee_type.interval
|
|
||||||
)})
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<%= for {msg, _opts} <- @form.errors[:default_membership_fee_type_id] || [] do %>
|
|
||||||
<p class="text-error text-sm mt-1">{msg}</p>
|
|
||||||
<% end %>
|
|
||||||
<p class="text-sm text-base-content/60 mt-2">
|
|
||||||
{gettext(
|
|
||||||
"This membership fee type is automatically assigned to all new members. Can be changed individually per member."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<%!-- Include Joining Cycle --%>
|
|
||||||
<fieldset class="fieldset">
|
|
||||||
<label class="label cursor-pointer justify-start gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="settings[include_joining_cycle]"
|
|
||||||
class="checkbox checkbox-primary"
|
|
||||||
checked={@form[:include_joining_cycle].value}
|
|
||||||
phx-debounce="blur"
|
|
||||||
/>
|
|
||||||
<span class="label-text font-semibold">
|
|
||||||
{gettext("Include joining cycle")}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<%= for {msg, _opts} <- @form.errors[:include_joining_cycle] || [] do %>
|
|
||||||
<p class="text-error text-sm ml-9 mt-1">{msg}</p>
|
|
||||||
<% end %>
|
|
||||||
<div class="ml-9 space-y-2">
|
|
||||||
<p class="text-sm text-base-content/60">
|
|
||||||
{gettext("When active: Members pay from the cycle of their joining.")}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-base-content/60">
|
|
||||||
{gettext("When inactive: Members pay from the next full cycle after joining.")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary w-full">
|
|
||||||
<.icon name="hero-check" class="size-5" />
|
|
||||||
{gettext("Save Settings")}
|
|
||||||
</button>
|
|
||||||
</.form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Examples Card --%>
|
|
||||||
<div class="card bg-base-200">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title">
|
|
||||||
<.icon name="hero-light-bulb" class="size-5" />
|
|
||||||
{gettext("Examples")}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<.example_section
|
|
||||||
title={gettext("Yearly Interval - Joining Cycle Included")}
|
|
||||||
joining_date="15.03.2023"
|
|
||||||
include_joining={true}
|
|
||||||
start_date="01.01.2023"
|
|
||||||
periods={["2023", "2024", "2025"]}
|
|
||||||
note={gettext("Member pays for the year they joined")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<.example_section
|
|
||||||
title={gettext("Yearly Interval - Joining Cycle Excluded")}
|
|
||||||
joining_date="15.03.2023"
|
|
||||||
include_joining={false}
|
|
||||||
start_date="01.01.2024"
|
|
||||||
periods={["2024", "2025"]}
|
|
||||||
note={gettext("Member pays from the next full year")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<.example_section
|
|
||||||
title={gettext("Quarterly Interval - Joining Cycle Excluded")}
|
|
||||||
joining_date="15.05.2024"
|
|
||||||
include_joining={false}
|
|
||||||
start_date="01.07.2024"
|
|
||||||
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
|
|
||||||
note={gettext("Member pays from the next full quarter")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<.example_section
|
|
||||||
title={gettext("Monthly Interval - Joining Cycle Included")}
|
|
||||||
joining_date="15.03.2024"
|
|
||||||
include_joining={true}
|
|
||||||
start_date="01.03.2024"
|
|
||||||
periods={["03/2024", "04/2024", "05/2024", "..."]}
|
|
||||||
note={gettext("Member pays from the joining month")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layouts.app>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
# Example section component
|
|
||||||
attr :title, :string, required: true
|
|
||||||
attr :joining_date, :string, required: true
|
|
||||||
attr :include_joining, :boolean, required: true
|
|
||||||
attr :start_date, :string, required: true
|
|
||||||
attr :periods, :list, required: true
|
|
||||||
attr :note, :string, required: true
|
|
||||||
|
|
||||||
defp example_section(assigns) do
|
|
||||||
~H"""
|
|
||||||
<div class="space-y-2">
|
|
||||||
<h3 class="font-semibold text-sm">{@title}</h3>
|
|
||||||
<div class="bg-base-300 rounded-lg p-3 text-sm space-y-1">
|
|
||||||
<p>
|
|
||||||
<span class="text-base-content/80">{gettext("Joining date")}:</span>
|
|
||||||
<span class="font-mono">{@joining_date}</span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span class="text-base-content/80">{gettext("Membership fee start")}:</span>
|
|
||||||
<span class="font-mono font-semibold text-base-content">{@start_date}</span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span class="text-base-content/80">{gettext("Generated cycles")}:</span>
|
|
||||||
<span class="font-mono">
|
|
||||||
{Enum.join(@periods, ", ")}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-base-content/80 italic">→ {@note}</p>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_currency(%Decimal{} = amount) do
|
|
||||||
"#{Decimal.to_string(amount)} €"
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_interval(:monthly), do: gettext("Monthly")
|
|
||||||
defp format_interval(:quarterly), do: gettext("Quarterly")
|
|
||||||
defp format_interval(:half_yearly), do: gettext("Half-yearly")
|
|
||||||
defp format_interval(:yearly), do: gettext("Yearly")
|
|
||||||
|
|
||||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
|
||||||
form =
|
|
||||||
AshPhoenix.Form.for_update(
|
|
||||||
settings,
|
|
||||||
:update_membership_fee_settings,
|
|
||||||
api: Membership,
|
|
||||||
as: "settings",
|
|
||||||
forms: [auto?: true]
|
|
||||||
)
|
|
||||||
|
|
||||||
assign(socket, form: to_form(form))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -69,11 +69,9 @@ defmodule MvWeb.Router do
|
||||||
|
|
||||||
live "/settings", GlobalSettingsLive
|
live "/settings", GlobalSettingsLive
|
||||||
|
|
||||||
# Membership Fee Settings
|
|
||||||
live "/membership_fee_settings", MembershipFeeSettingsLive
|
|
||||||
|
|
||||||
# Contribution Management (Mock-ups)
|
# Contribution Management (Mock-ups)
|
||||||
live "/contribution_types", ContributionTypeLive.Index, :index
|
live "/contribution_types", ContributionTypeLive.Index, :index
|
||||||
|
live "/contribution_settings", ContributionSettingsLive
|
||||||
live "/contributions/member/:id", ContributionPeriodLive.Show, :show
|
live "/contributions/member/:id", ContributionPeriodLive.Show, :show
|
||||||
|
|
||||||
post "/set_locale", LocaleController, :set_locale
|
post "/set_locale", LocaleController, :set_locale
|
||||||
|
|
|
||||||
|
|
@ -672,8 +672,8 @@ msgstr "Vereinsdaten"
|
||||||
msgid "Manage global settings for the association."
|
msgid "Manage global settings for the association."
|
||||||
msgstr "Passe übergreifende Einstellungen für den Verein an."
|
msgstr "Passe übergreifende Einstellungen für den Verein an."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Save Settings"
|
msgid "Save Settings"
|
||||||
msgstr "Einstellungen speichern"
|
msgstr "Einstellungen speichern"
|
||||||
|
|
@ -930,6 +930,17 @@ msgstr "Löschen nicht möglich – es sind Mitglieder zugewiesen"
|
||||||
msgid "Change Contribution Type"
|
msgid "Change Contribution Type"
|
||||||
msgstr "Beitragsart ändern"
|
msgstr "Beitragsart ändern"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Configure global settings for membership contributions."
|
||||||
|
msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
|
||||||
|
|
||||||
|
#: lib/mv_web/components/layouts/navbar.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Contribution Settings"
|
||||||
|
msgstr "Beitragseinstellungen"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Contribution Start"
|
msgid "Contribution Start"
|
||||||
|
|
@ -941,6 +952,11 @@ msgstr "Beitragsbeginn"
|
||||||
msgid "Contribution Types"
|
msgid "Contribution Types"
|
||||||
msgstr "Beitragsarten"
|
msgstr "Beitragsarten"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Contribution start"
|
||||||
|
msgstr "Beitragsbeginn"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Contribution type"
|
msgid "Contribution type"
|
||||||
|
|
@ -966,16 +982,27 @@ msgstr "Beiträge für %{name}"
|
||||||
msgid "Current"
|
msgid "Current"
|
||||||
msgstr "Aktuell"
|
msgstr "Aktuell"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Default Contribution Type"
|
||||||
|
msgstr "Standard-Beitragsart"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Deletion"
|
msgid "Deletion"
|
||||||
msgstr "Löschen"
|
msgstr "Löschen"
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Example: Member Contribution View"
|
||||||
|
msgstr "Beispiel: Ansicht Mitgliedsbeiträge"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Examples"
|
msgid "Examples"
|
||||||
msgstr "Beispiele"
|
msgstr "Beispiele"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Family"
|
msgid "Family"
|
||||||
|
|
@ -986,14 +1013,19 @@ msgstr "Familie"
|
||||||
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
||||||
msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln."
|
msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln."
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Generated periods"
|
||||||
|
msgstr "Generierte Zyklen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Global Settings"
|
msgid "Global Settings"
|
||||||
msgstr "Vereinsdaten"
|
msgstr "Vereinsdaten"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Half-yearly"
|
msgid "Half-yearly"
|
||||||
msgstr "Halbjährlich"
|
msgstr "Halbjährlich"
|
||||||
|
|
@ -1009,13 +1041,18 @@ msgstr "Halbjährlicher Beitrag für Fördermitglieder"
|
||||||
msgid "Honorary"
|
msgid "Honorary"
|
||||||
msgstr "Ehrenamtlich"
|
msgstr "Ehrenamtlich"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Include joining period"
|
||||||
|
msgstr "Beitrittsdatum einbeziehen"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Interval"
|
msgid "Interval"
|
||||||
msgstr "Zyklus"
|
msgstr "Zyklus"
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Joining date"
|
msgid "Joining date"
|
||||||
msgstr "Beitrittsdatum"
|
msgstr "Beitrittsdatum"
|
||||||
|
|
@ -1050,22 +1087,22 @@ msgstr "Als unbezahlt markieren"
|
||||||
msgid "Member Contributions"
|
msgid "Member Contributions"
|
||||||
msgstr "Mitgliedsbeiträge"
|
msgstr "Mitgliedsbeiträge"
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member pays for the year they joined"
|
msgid "Member pays for the year they joined"
|
||||||
msgstr "Mitglied zahlt für das Beitrittsjahr"
|
msgstr "Mitglied zahlt für das Beitrittsjahr"
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member pays from the joining month"
|
msgid "Member pays from the joining month"
|
||||||
msgstr "Mitglied zahlt ab Beitrittsmonat"
|
msgstr "Mitglied zahlt ab Beitrittsmonat"
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member pays from the next full quarter"
|
msgid "Member pays from the next full quarter"
|
||||||
msgstr "Mitglied zahlt ab dem nächsten vollständigen Quartal"
|
msgstr "Mitglied zahlt ab dem nächsten vollständigen Quartal"
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member pays from the next full year"
|
msgid "Member pays from the next full year"
|
||||||
msgstr "Mitglied zahlt ab dem nächsten vollständigen Jahr"
|
msgstr "Mitglied zahlt ab dem nächsten vollständigen Jahr"
|
||||||
|
|
@ -1081,12 +1118,17 @@ msgid "Members can only switch between contribution types with the same payment
|
||||||
msgstr "Mitglieder können nur zwischen Beitragsarten mit demselben Zahlungszyklus wechseln (z. B. jährlich zu jährlich). Dadurch werden komplexe Überlappungen vermieden."
|
msgstr "Mitglieder können nur zwischen Beitragsarten mit demselben Zahlungszyklus wechseln (z. B. jährlich zu jährlich). Dadurch werden komplexe Überlappungen vermieden."
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Monthly"
|
msgid "Monthly"
|
||||||
msgstr "Monatlich"
|
msgstr "Monatlich"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Monthly Interval - Joining Period Included"
|
||||||
|
msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Monthly fee for students and trainees"
|
msgid "Monthly fee for students and trainees"
|
||||||
|
|
@ -1123,24 +1165,31 @@ msgid "Paid via bank transfer"
|
||||||
msgstr "Bezahlt durch Überweisung"
|
msgstr "Bezahlt durch Überweisung"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Preview Mockup"
|
msgid "Preview Mockup"
|
||||||
msgstr "Vorschau"
|
msgstr "Vorschau"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Quarterly"
|
msgid "Quarterly"
|
||||||
msgstr "Vierteljährlich"
|
msgstr "Vierteljährlich"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Quarterly Interval - Joining Period Excluded"
|
||||||
|
msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Quarterly fee for family memberships"
|
msgid "Quarterly fee for family memberships"
|
||||||
msgstr "Vierteljährlicher Beitrag für Familienmitgliedschaften"
|
msgstr "Vierteljährlicher Beitrag für Familienmitgliedschaften"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Reduced"
|
msgid "Reduced"
|
||||||
|
|
@ -1152,6 +1201,7 @@ msgid "Reduced fee for unemployed, pensioners, or low income"
|
||||||
msgstr "Ermäßigter Beitrag für Arbeitslose, Rentner*innen oder Geringverdienende"
|
msgstr "Ermäßigter Beitrag für Arbeitslose, Rentner*innen oder Geringverdienende"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Regular"
|
msgid "Regular"
|
||||||
|
|
@ -1162,6 +1212,11 @@ msgstr "Regulär"
|
||||||
msgid "Reopen"
|
msgid "Reopen"
|
||||||
msgstr "Wieder öffnen"
|
msgstr "Wieder öffnen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
|
||||||
|
msgstr "Beispielhafte Anzeige der Beitragsperioden für ein einzelnes Mitglied. In diesem Beispiel wird Maria Weber mit mehreren Zyklen angezeigt."
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Standard membership fee for regular members"
|
msgid "Standard membership fee for regular members"
|
||||||
|
|
@ -1172,6 +1227,7 @@ msgstr "Regulärer Mitgliedsbeitrag für Vollmitglieder"
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr "Status"
|
msgstr "Status"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Student"
|
msgid "Student"
|
||||||
|
|
@ -1192,7 +1248,13 @@ msgstr "Pausieren"
|
||||||
msgid "Suspended"
|
msgid "Suspended"
|
||||||
msgstr "Pausiert"
|
msgstr "Pausiert"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member."
|
||||||
|
msgstr "Dieser Beitragstyp wird automatisch neuen Mitgliedern zugewiesen. Kann individuell angepasst werden."
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "This page is not functional and only displays the planned features."
|
msgid "This page is not functional and only displays the planned features."
|
||||||
|
|
@ -1213,18 +1275,43 @@ msgstr "Gesamtbeiträge"
|
||||||
msgid "Unpaid"
|
msgid "Unpaid"
|
||||||
msgstr "Unbezahlt"
|
msgstr "Unbezahlt"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "View Example Member"
|
||||||
|
msgstr "Beispielmitglied anzeigen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "When active: Members pay from the period of their joining."
|
||||||
|
msgstr "Wenn aktiviert: Mitglieder zahlen ab dem Zeitraum ihres Beitritts."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "When inactive: Members pay from the next full period after joining."
|
||||||
|
msgstr "Wenn deaktiviert: Mitglieder zahlen ab dem nächsten vollen Beitragszyklus nach dem Beitritt."
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Why are not all contribution types shown?"
|
msgid "Why are not all contribution types shown?"
|
||||||
msgstr "Warum werden nicht alle Beitragsarten angezeigt?"
|
msgstr "Warum werden nicht alle Beitragsarten angezeigt?"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Yearly"
|
msgid "Yearly"
|
||||||
msgstr "jährlich"
|
msgstr "jährlich"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Yearly Interval - Joining Period Excluded"
|
||||||
|
msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Yearly Interval - Joining Period Included"
|
||||||
|
msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
|
||||||
|
|
||||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Columns"
|
msgid "Columns"
|
||||||
|
|
@ -1326,82 +1413,6 @@ msgstr "Textfeld"
|
||||||
msgid "Yes/No-Selection"
|
msgid "Yes/No-Selection"
|
||||||
msgstr "Ja/Nein-Auswahl"
|
msgstr "Ja/Nein-Auswahl"
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Configure global settings for membership fees."
|
|
||||||
msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Default Membership Fee Type"
|
|
||||||
msgstr "Standard-Mitgliedsbeitragsart"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Generated cycles"
|
|
||||||
msgstr "Generierte Zyklen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Include joining cycle"
|
|
||||||
msgstr "Beitrittsdatum einbeziehen"
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/navbar.ex
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Membership Fee Settings"
|
|
||||||
msgstr "Mitgliedsbeitragseinstellungen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Membership fee start"
|
|
||||||
msgstr "Beitragsbeginn"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Monthly Interval - Joining Cycle Included"
|
|
||||||
msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "None (no default)"
|
|
||||||
msgstr "Keine (kein Standard)"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Quarterly Interval - Joining Cycle Excluded"
|
|
||||||
msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Settings saved successfully."
|
|
||||||
msgstr "Einstellungen erfolgreich gespeichert"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member."
|
|
||||||
msgstr "Diese Mitgliedsbeitragsart wird automatisch allen neuen Mitgliedern zugewiesen. Kann individuell pro Mitglied geändert werden."
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "When active: Members pay from the cycle of their joining."
|
|
||||||
msgstr "Wenn aktiviert: Mitglieder zahlen ab dem Zeitraum ihres Beitritts."
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "When inactive: Members pay from the next full cycle after joining."
|
|
||||||
msgstr "Wenn deaktiviert: Mitglieder zahlen ab dem nächsten vollen Beitragszyklus nach dem Beitritt."
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Yearly Interval - Joining Cycle Excluded"
|
|
||||||
msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Yearly Interval - Joining Cycle Included"
|
|
||||||
msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All payment statuses"
|
msgid "All payment statuses"
|
||||||
|
|
@ -1427,21 +1438,11 @@ msgstr "Benutzerdefinierten Feldwert speichern"
|
||||||
#~ msgid "Auto-generated identifier (immutable)"
|
#~ msgid "Auto-generated identifier (immutable)"
|
||||||
#~ msgstr "Automatisch generierter Bezeichner (unveränderlich)"
|
#~ msgstr "Automatisch generierter Bezeichner (unveränderlich)"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
#~ #: lib/mv_web/live/member_live/form.ex
|
||||||
|
#~ #: lib/mv_web/live/member_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Configure global settings for membership contributions."
|
#~ msgid "Birth Date"
|
||||||
#~ msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
|
#~ msgstr "Geburtsdatum"
|
||||||
|
|
||||||
#~ #: lib/mv_web/components/layouts/navbar.ex
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Contribution Settings"
|
|
||||||
#~ msgstr "Beitragseinstellungen"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
|
||||||
#~ msgid "Contribution start"
|
|
||||||
#~ msgstr "Beitragsbeginn"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
|
@ -1454,41 +1455,27 @@ msgstr "Benutzerdefinierten Feldwert speichern"
|
||||||
#~ msgid "Custom Field Values"
|
#~ msgid "Custom Field Values"
|
||||||
#~ msgstr "Benutzerdefinierte Feldwerte"
|
#~ msgstr "Benutzerdefinierte Feldwerte"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
#~ #: lib/mv_web/live/member_live/form.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Default Contribution Type"
|
#~ msgid "Fields marked with an asterisk (*) cannot be empty."
|
||||||
#~ msgstr "Standard-Beitragsart"
|
#~ msgstr "Felder, die mit einem Sternchen (*) markiert sind, dürfen nicht leer bleiben."
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
#~ #: lib/mv_web/live/custom_field_live/form.ex
|
||||||
|
#~ #: lib/mv_web/live/user_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Example: Member Contribution View"
|
#~ msgid "ID"
|
||||||
#~ msgstr "Beispiel: Ansicht Mitgliedsbeiträge"
|
#~ msgstr "ID"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/membership_fee_settings_live.ex
|
#~ #: lib/mv_web/live/member_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Failed to save settings. Please check the errors below."
|
#~ msgid "Id"
|
||||||
#~ msgstr "Einstellungen konnten nicht gespeichert werden. Bitte prüfen Sie die Fehler unten."
|
#~ msgstr "ID"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Generated periods"
|
|
||||||
#~ msgstr "Generierte Zyklen"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
|
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Immutable"
|
#~ msgid "Immutable"
|
||||||
#~ msgstr "Unveränderlich"
|
#~ msgstr "Unveränderlich"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Include joining period"
|
|
||||||
#~ msgstr "Beitrittsdatum einbeziehen"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Monthly Interval - Joining Period Included"
|
|
||||||
#~ msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "New Custom field"
|
#~ msgid "New Custom field"
|
||||||
|
|
@ -1500,42 +1487,23 @@ msgstr "Benutzerdefinierten Feldwert speichern"
|
||||||
#~ msgid "Not set"
|
#~ msgid "Not set"
|
||||||
#~ msgstr "Nicht gesetzt"
|
#~ msgstr "Nicht gesetzt"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
#~ #: lib/mv_web/live/user_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Quarterly Interval - Joining Period Excluded"
|
#~ msgid "OIDC ID"
|
||||||
#~ msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen"
|
#~ msgstr "OIDC ID"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
|
#~ msgid "Show in Overview"
|
||||||
#~ msgstr "Beispielhafte Anzeige der Beitragsperioden für ein einzelnes Mitglied. In diesem Beispiel wird Maria Weber mit mehreren Zyklen angezeigt."
|
#~ msgstr "In der Mitglieder-Übersicht anzeigen"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
#~ #: lib/mv_web/live/member_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member."
|
#~ msgid "This is a member record from your database."
|
||||||
#~ msgstr "Dieser Beitragstyp wird automatisch neuen Mitgliedern zugewiesen. Kann individuell angepasst werden."
|
#~ msgstr "Dies ist ein Mitglied aus deiner Datenbank."
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
#~ #: lib/mv_web/live/custom_field_live/form.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "View Example Member"
|
#~ msgid "Use this form to manage custom_field records in your database."
|
||||||
#~ msgstr "Beispielmitglied anzeigen"
|
#~ msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten."
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "When active: Members pay from the period of their joining."
|
|
||||||
#~ msgstr "Wenn aktiviert: Mitglieder zahlen ab dem Zeitraum ihres Beitritts."
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "When inactive: Members pay from the next full period after joining."
|
|
||||||
#~ msgstr "Wenn deaktiviert: Mitglieder zahlen ab dem nächsten vollen Beitragszyklus nach dem Beitritt."
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Yearly Interval - Joining Period Excluded"
|
|
||||||
#~ msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen"
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Yearly Interval - Joining Period Included"
|
|
||||||
#~ msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
|
|
||||||
|
|
|
||||||
|
|
@ -673,8 +673,8 @@ msgstr ""
|
||||||
msgid "Manage global settings for the association."
|
msgid "Manage global settings for the association."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Save Settings"
|
msgid "Save Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -931,6 +931,17 @@ msgstr ""
|
||||||
msgid "Change Contribution Type"
|
msgid "Change Contribution Type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Configure global settings for membership contributions."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/layouts/navbar.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Contribution Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Contribution Start"
|
msgid "Contribution Start"
|
||||||
|
|
@ -942,6 +953,11 @@ msgstr ""
|
||||||
msgid "Contribution Types"
|
msgid "Contribution Types"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Contribution start"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Contribution type"
|
msgid "Contribution type"
|
||||||
|
|
@ -967,16 +983,27 @@ msgstr ""
|
||||||
msgid "Current"
|
msgid "Current"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Default Contribution Type"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Deletion"
|
msgid "Deletion"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Example: Member Contribution View"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Examples"
|
msgid "Examples"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Family"
|
msgid "Family"
|
||||||
|
|
@ -987,14 +1014,19 @@ msgstr ""
|
||||||
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Generated periods"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Global Settings"
|
msgid "Global Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Half-yearly"
|
msgid "Half-yearly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1010,13 +1042,18 @@ msgstr ""
|
||||||
msgid "Honorary"
|
msgid "Honorary"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Include joining period"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Interval"
|
msgid "Interval"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Joining date"
|
msgid "Joining date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1051,22 +1088,22 @@ msgstr ""
|
||||||
msgid "Member Contributions"
|
msgid "Member Contributions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member pays for the year they joined"
|
msgid "Member pays for the year they joined"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member pays from the joining month"
|
msgid "Member pays from the joining month"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member pays from the next full quarter"
|
msgid "Member pays from the next full quarter"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member pays from the next full year"
|
msgid "Member pays from the next full year"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1082,12 +1119,17 @@ msgid "Members can only switch between contribution types with the same payment
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Monthly"
|
msgid "Monthly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Monthly Interval - Joining Period Included"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Monthly fee for students and trainees"
|
msgid "Monthly fee for students and trainees"
|
||||||
|
|
@ -1124,24 +1166,31 @@ msgid "Paid via bank transfer"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Preview Mockup"
|
msgid "Preview Mockup"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Quarterly"
|
msgid "Quarterly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Quarterly Interval - Joining Period Excluded"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Quarterly fee for family memberships"
|
msgid "Quarterly fee for family memberships"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Reduced"
|
msgid "Reduced"
|
||||||
|
|
@ -1153,6 +1202,7 @@ msgid "Reduced fee for unemployed, pensioners, or low income"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Regular"
|
msgid "Regular"
|
||||||
|
|
@ -1163,6 +1213,11 @@ msgstr ""
|
||||||
msgid "Reopen"
|
msgid "Reopen"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Standard membership fee for regular members"
|
msgid "Standard membership fee for regular members"
|
||||||
|
|
@ -1173,6 +1228,7 @@ msgstr ""
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Student"
|
msgid "Student"
|
||||||
|
|
@ -1193,7 +1249,13 @@ msgstr ""
|
||||||
msgid "Suspended"
|
msgid "Suspended"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "This page is not functional and only displays the planned features."
|
msgid "This page is not functional and only displays the planned features."
|
||||||
|
|
@ -1214,18 +1276,43 @@ msgstr ""
|
||||||
msgid "Unpaid"
|
msgid "Unpaid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "View Example Member"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "When active: Members pay from the period of their joining."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "When inactive: Members pay from the next full period after joining."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Why are not all contribution types shown?"
|
msgid "Why are not all contribution types shown?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Yearly"
|
msgid "Yearly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Yearly Interval - Joining Period Excluded"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Yearly Interval - Joining Period Included"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Columns"
|
msgid "Columns"
|
||||||
|
|
@ -1327,82 +1414,6 @@ msgstr ""
|
||||||
msgid "Yes/No-Selection"
|
msgid "Yes/No-Selection"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Configure global settings for membership fees."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Default Membership Fee Type"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Generated cycles"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Include joining cycle"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/navbar.ex
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Membership Fee Settings"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Membership fee start"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Monthly Interval - Joining Cycle Included"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "None (no default)"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Quarterly Interval - Joining Cycle Excluded"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Settings saved successfully."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "When active: Members pay from the cycle of their joining."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "When inactive: Members pay from the next full cycle after joining."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Yearly Interval - Joining Cycle Excluded"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Yearly Interval - Joining Cycle Included"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All payment statuses"
|
msgid "All payment statuses"
|
||||||
|
|
|
||||||
|
|
@ -673,8 +673,8 @@ msgstr ""
|
||||||
msgid "Manage global settings for the association."
|
msgid "Manage global settings for the association."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Save Settings"
|
msgid "Save Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -931,6 +931,17 @@ msgstr ""
|
||||||
msgid "Change Contribution Type"
|
msgid "Change Contribution Type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Configure global settings for membership contributions."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/layouts/navbar.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Contribution Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Contribution Start"
|
msgid "Contribution Start"
|
||||||
|
|
@ -942,6 +953,11 @@ msgstr ""
|
||||||
msgid "Contribution Types"
|
msgid "Contribution Types"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Contribution start"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Contribution type"
|
msgid "Contribution type"
|
||||||
|
|
@ -967,16 +983,27 @@ msgstr ""
|
||||||
msgid "Current"
|
msgid "Current"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Default Contribution Type"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Deletion"
|
msgid "Deletion"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Example: Member Contribution View"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Examples"
|
msgid "Examples"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Family"
|
msgid "Family"
|
||||||
|
|
@ -987,14 +1014,19 @@ msgstr ""
|
||||||
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Generated periods"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Global Settings"
|
msgid "Global Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Half-yearly"
|
msgid "Half-yearly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1010,13 +1042,18 @@ msgstr ""
|
||||||
msgid "Honorary"
|
msgid "Honorary"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Include joining period"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Interval"
|
msgid "Interval"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Joining date"
|
msgid "Joining date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1051,22 +1088,22 @@ msgstr ""
|
||||||
msgid "Member Contributions"
|
msgid "Member Contributions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member pays for the year they joined"
|
msgid "Member pays for the year they joined"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member pays from the joining month"
|
msgid "Member pays from the joining month"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member pays from the next full quarter"
|
msgid "Member pays from the next full quarter"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member pays from the next full year"
|
msgid "Member pays from the next full year"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1082,12 +1119,17 @@ msgid "Members can only switch between contribution types with the same payment
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Monthly"
|
msgid "Monthly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Monthly Interval - Joining Period Included"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Monthly fee for students and trainees"
|
msgid "Monthly fee for students and trainees"
|
||||||
|
|
@ -1124,24 +1166,31 @@ msgid "Paid via bank transfer"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Preview Mockup"
|
msgid "Preview Mockup"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Quarterly"
|
msgid "Quarterly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Quarterly Interval - Joining Period Excluded"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Quarterly fee for family memberships"
|
msgid "Quarterly fee for family memberships"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Reduced"
|
msgid "Reduced"
|
||||||
|
|
@ -1153,6 +1202,7 @@ msgid "Reduced fee for unemployed, pensioners, or low income"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Regular"
|
msgid "Regular"
|
||||||
|
|
@ -1163,6 +1213,11 @@ msgstr ""
|
||||||
msgid "Reopen"
|
msgid "Reopen"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Standard membership fee for regular members"
|
msgid "Standard membership fee for regular members"
|
||||||
|
|
@ -1173,6 +1228,7 @@ msgstr ""
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Student"
|
msgid "Student"
|
||||||
|
|
@ -1193,7 +1249,13 @@ msgstr ""
|
||||||
msgid "Suspended"
|
msgid "Suspended"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "This page is not functional and only displays the planned features."
|
msgid "This page is not functional and only displays the planned features."
|
||||||
|
|
@ -1214,18 +1276,43 @@ msgstr ""
|
||||||
msgid "Unpaid"
|
msgid "Unpaid"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "View Example Member"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "When active: Members pay from the period of their joining."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "When inactive: Members pay from the next full period after joining."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Why are not all contribution types shown?"
|
msgid "Why are not all contribution types shown?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Yearly"
|
msgid "Yearly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Yearly Interval - Joining Period Excluded"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/contribution_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Yearly Interval - Joining Period Included"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Columns"
|
msgid "Columns"
|
||||||
|
|
@ -1327,82 +1414,6 @@ msgstr ""
|
||||||
msgid "Yes/No-Selection"
|
msgid "Yes/No-Selection"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Configure global settings for membership fees."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Default Membership Fee Type"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Generated cycles"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Include joining cycle"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/navbar.ex
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Membership Fee Settings"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Membership fee start"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Monthly Interval - Joining Cycle Included"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "None (no default)"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Quarterly Interval - Joining Cycle Excluded"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Settings saved successfully."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "When active: Members pay from the cycle of their joining."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "When inactive: Members pay from the next full cycle after joining."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Yearly Interval - Joining Cycle Excluded"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Yearly Interval - Joining Cycle Included"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All payment statuses"
|
msgid "All payment statuses"
|
||||||
|
|
@ -1425,64 +1436,46 @@ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Configure global settings for membership contributions."
|
#~ msgid "Auto-generated identifier (immutable)"
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/components/layouts/navbar.ex
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Contribution Settings"
|
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_live/form.ex
|
#~ #: lib/mv_web/live/member_live/form.ex
|
||||||
#~ #: lib/mv_web/live/member_live/show.ex
|
#~ #: lib/mv_web/live/member_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Contribution start"
|
#~ msgid "Birth Date"
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Copy emails"
|
#~ msgid "Copy emails"
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
#~ #: lib/mv_web/live/member_live/form.ex
|
||||||
|
#~ #: lib/mv_web/live/member_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Default Contribution Type"
|
#~ msgid "Custom Field Values"
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
#~ #: lib/mv_web/live/member_live/form.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Example: Member Contribution View"
|
#~ msgid "Fields marked with an asterisk (*) cannot be empty."
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Failed to save settings. Please check the errors below."
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#~ #: lib/mv_web/live/user_live/show.ex
|
#~ #: lib/mv_web/live/user_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Generated periods"
|
#~ msgid "ID"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/show.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Id"
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
|
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Immutable"
|
#~ msgid "Immutable"
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Include joining period"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Monthly Interval - Joining Period Included"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "New Custom field"
|
#~ msgid "New Custom field"
|
||||||
|
|
@ -1493,42 +1486,23 @@ msgstr ""
|
||||||
#~ msgid "Not set"
|
#~ msgid "Not set"
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
#~ #: lib/mv_web/live/user_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Quarterly Interval - Joining Period Excluded"
|
#~ msgid "OIDC ID"
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
|
#~ msgid "Show in Overview"
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
#~ #: lib/mv_web/live/member_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member."
|
#~ msgid "This is a member record from your database."
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
#~ #: lib/mv_web/live/custom_field_live/form.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "View Example Member"
|
#~ msgid "Use this form to manage custom_field records in your database."
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "When active: Members pay from the period of their joining."
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "When inactive: Members pay from the next full period after joining."
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Yearly Interval - Joining Period Excluded"
|
|
||||||
#~ msgstr ""
|
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
|
||||||
#~ #, elixir-autogen, elixir-format
|
|
||||||
#~ msgid "Yearly Interval - Joining Period Included"
|
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
defmodule Mv.Repo.Migrations.AddMembershipFeeSettings do
|
|
||||||
@moduledoc """
|
|
||||||
Adds membership fee settings to the settings table.
|
|
||||||
|
|
||||||
Note: The members table columns (membership_fee_start_date, membership_fee_type_id)
|
|
||||||
were already added in migration 20251211151449_add_membership_fees_tables.
|
|
||||||
"""
|
|
||||||
|
|
||||||
use Ecto.Migration
|
|
||||||
|
|
||||||
def up do
|
|
||||||
# Add membership fee settings to the settings table
|
|
||||||
alter table(:settings) do
|
|
||||||
add_if_not_exists :include_joining_cycle, :boolean, null: false, default: true
|
|
||||||
add_if_not_exists :default_membership_fee_type_id, :uuid
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def down do
|
|
||||||
alter table(:settings) do
|
|
||||||
remove_if_exists :default_membership_fee_type_id, :uuid
|
|
||||||
remove_if_exists :include_joining_cycle, :boolean
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -5,39 +5,6 @@
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
|
|
||||||
# Create example membership fee types
|
|
||||||
for fee_type_attrs <- [
|
|
||||||
%{
|
|
||||||
name: "Standard (Jährlich)",
|
|
||||||
amount: Decimal.new("120.00"),
|
|
||||||
interval: :yearly,
|
|
||||||
description: "Standard jährlicher Mitgliedsbeitrag"
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Standard (Halbjährlich)",
|
|
||||||
amount: Decimal.new("65.00"),
|
|
||||||
interval: :half_yearly,
|
|
||||||
description: "Standard halbjährlicher Mitgliedsbeitrag"
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Standard (Vierteljährlich)",
|
|
||||||
amount: Decimal.new("35.00"),
|
|
||||||
interval: :quarterly,
|
|
||||||
description: "Standard vierteljährlicher Mitgliedsbeitrag"
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Standard (Monatlich)",
|
|
||||||
amount: Decimal.new("12.00"),
|
|
||||||
interval: :monthly,
|
|
||||||
description: "Standard monatlicher Mitgliedsbeitrag"
|
|
||||||
}
|
|
||||||
] do
|
|
||||||
MembershipFeeType
|
|
||||||
|> Ash.Changeset.for_create(:create, fee_type_attrs)
|
|
||||||
|> Ash.create!(upsert?: true, upsert_identity: :unique_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
for attrs <- [
|
for attrs <- [
|
||||||
# Basic example fields (for testing)
|
# Basic example fields (for testing)
|
||||||
|
|
@ -353,7 +320,6 @@ end
|
||||||
IO.puts("✅ Seeds completed successfully!")
|
IO.puts("✅ Seeds completed successfully!")
|
||||||
IO.puts("📝 Created sample data:")
|
IO.puts("📝 Created sample data:")
|
||||||
IO.puts(" - Global settings: club_name = #{default_club_name}")
|
IO.puts(" - Global settings: club_name = #{default_club_name}")
|
||||||
IO.puts(" - Membership fee types: 4 types (Yearly, Half-yearly, Quarterly, Monthly)")
|
|
||||||
IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)")
|
IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)")
|
||||||
IO.puts(" - Admin user: admin@mv.local (password: testpassword)")
|
IO.puts(" - Admin user: admin@mv.local (password: testpassword)")
|
||||||
IO.puts(" - Sample members: Hans, Greta, Friedrich")
|
IO.puts(" - Sample members: Hans, Greta, Friedrich")
|
||||||
|
|
|
||||||
|
|
@ -1,245 +0,0 @@
|
||||||
{
|
|
||||||
"attributes": [
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "fragment(\"uuid_generate_v7()\")",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": true,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "id",
|
|
||||||
"type": "uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "first_name",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "last_name",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "email",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "paid",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "phone_number",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "join_date",
|
|
||||||
"type": "date"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "exit_date",
|
|
||||||
"type": "date"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "notes",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "city",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "street",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "house_number",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "postal_code",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "search_vector",
|
|
||||||
"type": "tsvector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "membership_fee_start_date",
|
|
||||||
"type": "date"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": {
|
|
||||||
"deferrable": false,
|
|
||||||
"destination_attribute": "id",
|
|
||||||
"destination_attribute_default": null,
|
|
||||||
"destination_attribute_generated": null,
|
|
||||||
"index?": false,
|
|
||||||
"match_type": null,
|
|
||||||
"match_with": null,
|
|
||||||
"multitenancy": {
|
|
||||||
"attribute": null,
|
|
||||||
"global": null,
|
|
||||||
"strategy": null
|
|
||||||
},
|
|
||||||
"name": "members_membership_fee_type_id_fkey",
|
|
||||||
"on_delete": null,
|
|
||||||
"on_update": null,
|
|
||||||
"primary_key?": true,
|
|
||||||
"schema": "public",
|
|
||||||
"table": "membership_fee_types"
|
|
||||||
},
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "membership_fee_type_id",
|
|
||||||
"type": "uuid"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"base_filter": null,
|
|
||||||
"check_constraints": [],
|
|
||||||
"custom_indexes": [],
|
|
||||||
"custom_statements": [],
|
|
||||||
"has_create_action": true,
|
|
||||||
"hash": "6ECD721659E1CC7CB4219293153BCED585111A49765B9DB0D1CAE0B37C54949E",
|
|
||||||
"identities": [
|
|
||||||
{
|
|
||||||
"all_tenants?": false,
|
|
||||||
"base_filter": null,
|
|
||||||
"index_name": "members_unique_email_index",
|
|
||||||
"keys": [
|
|
||||||
{
|
|
||||||
"type": "atom",
|
|
||||||
"value": "email"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "unique_email",
|
|
||||||
"nils_distinct?": true,
|
|
||||||
"where": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"multitenancy": {
|
|
||||||
"attribute": null,
|
|
||||||
"global": null,
|
|
||||||
"strategy": null
|
|
||||||
},
|
|
||||||
"repo": "Elixir.Mv.Repo",
|
|
||||||
"schema": null,
|
|
||||||
"table": "members"
|
|
||||||
}
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
{
|
|
||||||
"attributes": [
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "fragment(\"uuid_generate_v7()\")",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": true,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "id",
|
|
||||||
"type": "uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "cycle_start",
|
|
||||||
"type": "date"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": 2,
|
|
||||||
"size": null,
|
|
||||||
"source": "amount",
|
|
||||||
"type": "decimal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "\"unpaid\"",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "status",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "notes",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": {
|
|
||||||
"deferrable": false,
|
|
||||||
"destination_attribute": "id",
|
|
||||||
"destination_attribute_default": null,
|
|
||||||
"destination_attribute_generated": null,
|
|
||||||
"index?": false,
|
|
||||||
"match_type": null,
|
|
||||||
"match_with": null,
|
|
||||||
"multitenancy": {
|
|
||||||
"attribute": null,
|
|
||||||
"global": null,
|
|
||||||
"strategy": null
|
|
||||||
},
|
|
||||||
"name": "membership_fee_cycles_member_id_fkey",
|
|
||||||
"on_delete": null,
|
|
||||||
"on_update": null,
|
|
||||||
"primary_key?": true,
|
|
||||||
"schema": "public",
|
|
||||||
"table": "members"
|
|
||||||
},
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "member_id",
|
|
||||||
"type": "uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": {
|
|
||||||
"deferrable": false,
|
|
||||||
"destination_attribute": "id",
|
|
||||||
"destination_attribute_default": null,
|
|
||||||
"destination_attribute_generated": null,
|
|
||||||
"index?": false,
|
|
||||||
"match_type": null,
|
|
||||||
"match_with": null,
|
|
||||||
"multitenancy": {
|
|
||||||
"attribute": null,
|
|
||||||
"global": null,
|
|
||||||
"strategy": null
|
|
||||||
},
|
|
||||||
"name": "membership_fee_cycles_membership_fee_type_id_fkey",
|
|
||||||
"on_delete": null,
|
|
||||||
"on_update": null,
|
|
||||||
"primary_key?": true,
|
|
||||||
"schema": "public",
|
|
||||||
"table": "membership_fee_types"
|
|
||||||
},
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "membership_fee_type_id",
|
|
||||||
"type": "uuid"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"base_filter": null,
|
|
||||||
"check_constraints": [],
|
|
||||||
"custom_indexes": [],
|
|
||||||
"custom_statements": [],
|
|
||||||
"has_create_action": true,
|
|
||||||
"hash": "802FB11B08D041501AC395454D84719992B71C0BEAE83B0833F3086486ABD679",
|
|
||||||
"identities": [
|
|
||||||
{
|
|
||||||
"all_tenants?": false,
|
|
||||||
"base_filter": null,
|
|
||||||
"index_name": "membership_fee_cycles_unique_cycle_per_member_index",
|
|
||||||
"keys": [
|
|
||||||
{
|
|
||||||
"type": "atom",
|
|
||||||
"value": "member_id"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "atom",
|
|
||||||
"value": "cycle_start"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "unique_cycle_per_member",
|
|
||||||
"nils_distinct?": true,
|
|
||||||
"where": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"multitenancy": {
|
|
||||||
"attribute": null,
|
|
||||||
"global": null,
|
|
||||||
"strategy": null
|
|
||||||
},
|
|
||||||
"repo": "Elixir.Mv.Repo",
|
|
||||||
"schema": null,
|
|
||||||
"table": "membership_fee_cycles"
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
{
|
|
||||||
"attributes": [
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "fragment(\"uuid_generate_v7()\")",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": true,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "id",
|
|
||||||
"type": "uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "name",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": 2,
|
|
||||||
"size": null,
|
|
||||||
"source": "amount",
|
|
||||||
"type": "decimal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "interval",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "description",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"base_filter": null,
|
|
||||||
"check_constraints": [],
|
|
||||||
"custom_indexes": [],
|
|
||||||
"custom_statements": [],
|
|
||||||
"has_create_action": true,
|
|
||||||
"hash": "C58959BF589FEB75A9F05C2C717C04B641ED14E09FF2503C8B0637392AE5A335",
|
|
||||||
"identities": [
|
|
||||||
{
|
|
||||||
"all_tenants?": false,
|
|
||||||
"base_filter": null,
|
|
||||||
"index_name": "membership_fee_types_unique_name_index",
|
|
||||||
"keys": [
|
|
||||||
{
|
|
||||||
"type": "atom",
|
|
||||||
"value": "name"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"name": "unique_name",
|
|
||||||
"nils_distinct?": true,
|
|
||||||
"where": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"multitenancy": {
|
|
||||||
"attribute": null,
|
|
||||||
"global": null,
|
|
||||||
"strategy": null
|
|
||||||
},
|
|
||||||
"repo": "Elixir.Mv.Repo",
|
|
||||||
"schema": null,
|
|
||||||
"table": "membership_fee_types"
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
{
|
|
||||||
"attributes": [
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "fragment(\"gen_random_uuid()\")",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": true,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "id",
|
|
||||||
"type": "uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "club_name",
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "member_field_visibility",
|
|
||||||
"type": "map"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "true",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "include_joining_cycle",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": true,
|
|
||||||
"default": "nil",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "default_membership_fee_type_id",
|
|
||||||
"type": "uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "inserted_at",
|
|
||||||
"type": "utc_datetime_usec"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"allow_nil?": false,
|
|
||||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
|
||||||
"generated?": false,
|
|
||||||
"precision": null,
|
|
||||||
"primary_key?": false,
|
|
||||||
"references": null,
|
|
||||||
"scale": null,
|
|
||||||
"size": null,
|
|
||||||
"source": "updated_at",
|
|
||||||
"type": "utc_datetime_usec"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"base_filter": null,
|
|
||||||
"check_constraints": [],
|
|
||||||
"custom_indexes": [],
|
|
||||||
"custom_statements": [],
|
|
||||||
"has_create_action": true,
|
|
||||||
"hash": "CD12EA080677C99D81C2A4A98F0DE419F7BDE1FA8C22206423C9D80305B064D2",
|
|
||||||
"identities": [],
|
|
||||||
"multitenancy": {
|
|
||||||
"attribute": null,
|
|
||||||
"global": null,
|
|
||||||
"strategy": null
|
|
||||||
},
|
|
||||||
"repo": "Elixir.Mv.Repo",
|
|
||||||
"schema": null,
|
|
||||||
"table": "settings"
|
|
||||||
}
|
|
||||||
|
|
@ -1,360 +0,0 @@
|
||||||
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
|
|
||||||
|
|
||||||
test "returns status of current cycle for monthly interval" do
|
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
# Create a cycle that is active today (current month)
|
|
||||||
today = Date.utc_today()
|
|
||||||
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
|
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
|
||||||
cycle_start: cycle_start,
|
|
||||||
status: :unpaid
|
|
||||||
})
|
|
||||||
|
|
||||||
member = Ash.load!(member, :current_cycle_status)
|
|
||||||
assert member.current_cycle_status == :unpaid
|
|
||||||
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
|
|
||||||
|
|
||||||
test "returns status of last completed cycle for monthly interval" do
|
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
today = Date.utc_today()
|
|
||||||
# Create cycles: last month (completed), current month (not completed)
|
|
||||||
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
|
|
||||||
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
|
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
|
||||||
cycle_start: last_month_start,
|
|
||||||
status: :paid
|
|
||||||
})
|
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
|
||||||
cycle_start: current_month_start,
|
|
||||||
status: :unpaid
|
|
||||||
})
|
|
||||||
|
|
||||||
member = Ash.load!(member, :last_cycle_status)
|
|
||||||
# Should return status of last month (last completed)
|
|
||||||
assert member.last_cycle_status == :paid
|
|
||||||
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 overdue cycles for monthly interval" do
|
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
today = Date.utc_today()
|
|
||||||
|
|
||||||
# Create cycles: two months ago (unpaid, ended), last month (paid, ended), current month (unpaid, not ended)
|
|
||||||
two_months_ago_start =
|
|
||||||
Date.add(today, -65) |> CalendarCycles.calculate_cycle_start(:monthly)
|
|
||||||
|
|
||||||
last_month_start = Date.add(today, -32) |> CalendarCycles.calculate_cycle_start(:monthly)
|
|
||||||
current_month_start = CalendarCycles.calculate_cycle_start(today, :monthly)
|
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
|
||||||
cycle_start: two_months_ago_start,
|
|
||||||
status: :unpaid
|
|
||||||
})
|
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
|
||||||
cycle_start: last_month_start,
|
|
||||||
status: :paid
|
|
||||||
})
|
|
||||||
|
|
||||||
create_cycle(member, fee_type, %{
|
|
||||||
cycle_start: current_month_start,
|
|
||||||
status: :unpaid
|
|
||||||
})
|
|
||||||
|
|
||||||
member = Ash.load!(member, :overdue_count)
|
|
||||||
# Should only count two_months_ago (unpaid and ended)
|
|
||||||
assert member.overdue_count == 1
|
|
||||||
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
|
|
||||||
|
|
@ -1,453 +0,0 @@
|
||||||
defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
|
||||||
@moduledoc """
|
|
||||||
Integration tests for membership fee type changes and cycle regeneration.
|
|
||||||
"""
|
|
||||||
use Mv.DataCase, async: true
|
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
# 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",
|
|
||||||
join_date: ~D[2023-01-15]
|
|
||||||
}
|
|
||||||
|
|
||||||
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 "type change cycle regeneration" do
|
|
||||||
test "future unpaid cycles are regenerated with new amount" do
|
|
||||||
today = Date.utc_today()
|
|
||||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
|
|
||||||
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
|
|
||||||
|
|
||||||
# Create member without fee type first to avoid auto-generation
|
|
||||||
member = create_member(%{})
|
|
||||||
|
|
||||||
# Manually assign fee type (this will trigger cycle generation)
|
|
||||||
member =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: yearly_type1.id
|
|
||||||
})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Cycle generation runs synchronously in the same transaction
|
|
||||||
# No need to wait for async completion
|
|
||||||
|
|
||||||
# Create cycles: one in the past (paid), one current (unpaid)
|
|
||||||
# Note: Future cycles are not automatically generated by CycleGenerator,
|
|
||||||
# so we only test with current cycle
|
|
||||||
past_cycle_start = CalendarCycles.calculate_cycle_start(~D[2023-01-01], :yearly)
|
|
||||||
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
|
||||||
|
|
||||||
# Past cycle (paid) - should remain unchanged
|
|
||||||
# Check if it already exists (from auto-generation), if not create it
|
|
||||||
case MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|
|
||||||
|> Ash.read_one() do
|
|
||||||
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
|
||||||
# Update to paid
|
|
||||||
existing_cycle
|
|
||||||
|> Ash.Changeset.for_update(:update, %{status: :paid})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
create_cycle(member, yearly_type1, %{
|
|
||||||
cycle_start: past_cycle_start,
|
|
||||||
status: :paid,
|
|
||||||
amount: Decimal.new("100.00")
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
# Current cycle (unpaid) - should be regenerated
|
|
||||||
# Delete if exists (from auto-generation), then create with old amount
|
|
||||||
case MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
|
||||||
|> Ash.read_one() do
|
|
||||||
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
|
||||||
Ash.destroy!(existing_cycle)
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
_current_cycle =
|
|
||||||
create_cycle(member, yearly_type1, %{
|
|
||||||
cycle_start: current_cycle_start,
|
|
||||||
status: :unpaid,
|
|
||||||
amount: Decimal.new("100.00")
|
|
||||||
})
|
|
||||||
|
|
||||||
# Change membership fee type (same interval, different amount)
|
|
||||||
assert {:ok, _updated_member} =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: yearly_type2.id
|
|
||||||
})
|
|
||||||
|> Ash.update()
|
|
||||||
|
|
||||||
# Cycle regeneration runs synchronously in the same transaction
|
|
||||||
# No need to wait for async completion
|
|
||||||
|
|
||||||
# Verify past cycle is unchanged
|
|
||||||
past_cycle_after =
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|
|
||||||
|> Ash.read_one!()
|
|
||||||
|
|
||||||
assert past_cycle_after.status == :paid
|
|
||||||
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
|
|
||||||
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
|
|
||||||
|
|
||||||
# Verify current cycle was deleted and regenerated
|
|
||||||
# Check that cycle with new type exists (regenerated)
|
|
||||||
new_current_cycle =
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
|
||||||
|> Ash.read_one!()
|
|
||||||
|
|
||||||
# Verify it has the new type and amount
|
|
||||||
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
|
|
||||||
assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00"))
|
|
||||||
assert new_current_cycle.status == :unpaid
|
|
||||||
|
|
||||||
# Verify old cycle with old type doesn't exist anymore
|
|
||||||
old_current_cycles =
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(
|
|
||||||
member_id == ^member.id and cycle_start == ^current_cycle_start and
|
|
||||||
membership_fee_type_id == ^yearly_type1.id
|
|
||||||
)
|
|
||||||
|> Ash.read!()
|
|
||||||
|
|
||||||
assert Enum.empty?(old_current_cycles)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "paid cycles remain unchanged" do
|
|
||||||
today = Date.utc_today()
|
|
||||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
|
|
||||||
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
|
|
||||||
|
|
||||||
# Create member without fee type first to avoid auto-generation
|
|
||||||
member = create_member(%{})
|
|
||||||
|
|
||||||
# Manually assign fee type (this will trigger cycle generation)
|
|
||||||
member =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: yearly_type1.id
|
|
||||||
})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Cycle generation runs synchronously in the same transaction
|
|
||||||
# No need to wait for async completion
|
|
||||||
|
|
||||||
# Get the current cycle and mark it as paid
|
|
||||||
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
|
||||||
|
|
||||||
# Find current cycle and mark as paid
|
|
||||||
paid_cycle =
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
|
||||||
|> Ash.read_one!()
|
|
||||||
|> Ash.Changeset.for_update(:mark_as_paid)
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Change membership fee type
|
|
||||||
assert {:ok, _updated_member} =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: yearly_type2.id
|
|
||||||
})
|
|
||||||
|> Ash.update()
|
|
||||||
|
|
||||||
# Cycle regeneration runs synchronously in the same transaction
|
|
||||||
# No need to wait for async completion
|
|
||||||
|
|
||||||
# Verify paid cycle is unchanged (not deleted and regenerated)
|
|
||||||
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, paid_cycle.id)
|
|
||||||
assert cycle_after.status == :paid
|
|
||||||
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
|
|
||||||
assert cycle_after.membership_fee_type_id == yearly_type1.id
|
|
||||||
end
|
|
||||||
|
|
||||||
test "suspended cycles remain unchanged" do
|
|
||||||
today = Date.utc_today()
|
|
||||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
|
|
||||||
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
|
|
||||||
|
|
||||||
# Create member without fee type first to avoid auto-generation
|
|
||||||
member = create_member(%{})
|
|
||||||
|
|
||||||
# Manually assign fee type (this will trigger cycle generation)
|
|
||||||
member =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: yearly_type1.id
|
|
||||||
})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Cycle generation runs synchronously in the same transaction
|
|
||||||
# No need to wait for async completion
|
|
||||||
|
|
||||||
# Get the current cycle and mark it as suspended
|
|
||||||
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
|
||||||
|
|
||||||
# Find current cycle and mark as suspended
|
|
||||||
suspended_cycle =
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
|
||||||
|> Ash.read_one!()
|
|
||||||
|> Ash.Changeset.for_update(:mark_as_suspended)
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Change membership fee type
|
|
||||||
assert {:ok, _updated_member} =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: yearly_type2.id
|
|
||||||
})
|
|
||||||
|> Ash.update()
|
|
||||||
|
|
||||||
# Cycle regeneration runs synchronously in the same transaction
|
|
||||||
# No need to wait for async completion
|
|
||||||
|
|
||||||
# Verify suspended cycle is unchanged (not deleted and regenerated)
|
|
||||||
{:ok, cycle_after} = Ash.get(MembershipFeeCycle, suspended_cycle.id)
|
|
||||||
assert cycle_after.status == :suspended
|
|
||||||
assert Decimal.equal?(cycle_after.amount, Decimal.new("100.00"))
|
|
||||||
assert cycle_after.membership_fee_type_id == yearly_type1.id
|
|
||||||
end
|
|
||||||
|
|
||||||
test "only cycles that haven't ended yet are deleted" do
|
|
||||||
today = Date.utc_today()
|
|
||||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
|
|
||||||
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
|
|
||||||
|
|
||||||
# Create member without fee type first to avoid auto-generation
|
|
||||||
member = create_member(%{})
|
|
||||||
|
|
||||||
# Manually assign fee type (this will trigger cycle generation)
|
|
||||||
member =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: yearly_type1.id
|
|
||||||
})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Cycle generation runs synchronously in the same transaction
|
|
||||||
# No need to wait for async completion
|
|
||||||
|
|
||||||
# Create cycles: one in the past (unpaid, ended), one current (unpaid, not ended)
|
|
||||||
past_cycle_start =
|
|
||||||
CalendarCycles.calculate_cycle_start(
|
|
||||||
Date.add(today, -365),
|
|
||||||
:yearly
|
|
||||||
)
|
|
||||||
|
|
||||||
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
|
||||||
|
|
||||||
# Past cycle (unpaid) - should remain unchanged (cycle_start < today)
|
|
||||||
# Delete existing cycle if it exists (from auto-generation)
|
|
||||||
case MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^past_cycle_start)
|
|
||||||
|> Ash.read_one() do
|
|
||||||
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
|
||||||
Ash.destroy!(existing_cycle)
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
past_cycle =
|
|
||||||
create_cycle(member, yearly_type1, %{
|
|
||||||
cycle_start: past_cycle_start,
|
|
||||||
status: :unpaid,
|
|
||||||
amount: Decimal.new("100.00")
|
|
||||||
})
|
|
||||||
|
|
||||||
# Current cycle (unpaid) - should be regenerated (cycle_start >= today)
|
|
||||||
# Delete existing cycle if it exists (from auto-generation)
|
|
||||||
case MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
|
||||||
|> Ash.read_one() do
|
|
||||||
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
|
||||||
Ash.destroy!(existing_cycle)
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
_current_cycle =
|
|
||||||
create_cycle(member, yearly_type1, %{
|
|
||||||
cycle_start: current_cycle_start,
|
|
||||||
status: :unpaid,
|
|
||||||
amount: Decimal.new("100.00")
|
|
||||||
})
|
|
||||||
|
|
||||||
# Change membership fee type
|
|
||||||
assert {:ok, _updated_member} =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: yearly_type2.id
|
|
||||||
})
|
|
||||||
|> Ash.update()
|
|
||||||
|
|
||||||
# Cycle regeneration runs synchronously in the same transaction
|
|
||||||
# No need to wait for async completion
|
|
||||||
|
|
||||||
# Verify past cycle is unchanged
|
|
||||||
{:ok, past_cycle_after} = Ash.get(MembershipFeeCycle, past_cycle.id)
|
|
||||||
assert past_cycle_after.status == :unpaid
|
|
||||||
assert Decimal.equal?(past_cycle_after.amount, Decimal.new("100.00"))
|
|
||||||
assert past_cycle_after.membership_fee_type_id == yearly_type1.id
|
|
||||||
|
|
||||||
# Verify current cycle was regenerated
|
|
||||||
# Check that cycle with new type exists
|
|
||||||
new_current_cycle =
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
|
||||||
|> Ash.read_one!()
|
|
||||||
|
|
||||||
assert new_current_cycle.membership_fee_type_id == yearly_type2.id
|
|
||||||
assert Decimal.equal?(new_current_cycle.amount, Decimal.new("150.00"))
|
|
||||||
|
|
||||||
# Verify old cycle with old type doesn't exist anymore
|
|
||||||
old_current_cycles =
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(
|
|
||||||
member_id == ^member.id and cycle_start == ^current_cycle_start and
|
|
||||||
membership_fee_type_id == ^yearly_type1.id
|
|
||||||
)
|
|
||||||
|> Ash.read!()
|
|
||||||
|
|
||||||
assert Enum.empty?(old_current_cycles)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "member calculations update after type change" do
|
|
||||||
today = Date.utc_today()
|
|
||||||
yearly_type1 = create_fee_type(%{interval: :yearly, amount: Decimal.new("100.00")})
|
|
||||||
yearly_type2 = create_fee_type(%{interval: :yearly, amount: Decimal.new("150.00")})
|
|
||||||
|
|
||||||
# Create member with join_date = today to avoid past cycles
|
|
||||||
# This ensures no overdue cycles exist
|
|
||||||
member = create_member(%{join_date: today})
|
|
||||||
|
|
||||||
# Manually assign fee type (this will trigger cycle generation)
|
|
||||||
member =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: yearly_type1.id
|
|
||||||
})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Cycle generation runs synchronously in the same transaction
|
|
||||||
# No need to wait for async completion
|
|
||||||
|
|
||||||
# Get current cycle start
|
|
||||||
current_cycle_start = CalendarCycles.calculate_cycle_start(today, :yearly)
|
|
||||||
|
|
||||||
# Ensure only one cycle exists (the current one)
|
|
||||||
# Delete all cycles except the current one
|
|
||||||
existing_cycles =
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member.id)
|
|
||||||
|> Ash.read!()
|
|
||||||
|
|
||||||
Enum.each(existing_cycles, fn cycle ->
|
|
||||||
if cycle.cycle_start != current_cycle_start do
|
|
||||||
Ash.destroy!(cycle)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
# Ensure current cycle exists and is unpaid
|
|
||||||
case MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^current_cycle_start)
|
|
||||||
|> Ash.read_one() do
|
|
||||||
{:ok, existing_cycle} when not is_nil(existing_cycle) ->
|
|
||||||
# Update to unpaid if it's not
|
|
||||||
if existing_cycle.status != :unpaid do
|
|
||||||
existing_cycle
|
|
||||||
|> Ash.Changeset.for_update(:mark_as_unpaid)
|
|
||||||
|> Ash.update!()
|
|
||||||
end
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
# Create if it doesn't exist
|
|
||||||
create_cycle(member, yearly_type1, %{
|
|
||||||
cycle_start: current_cycle_start,
|
|
||||||
status: :unpaid,
|
|
||||||
amount: Decimal.new("100.00")
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
# Load calculations before change
|
|
||||||
member = Ash.load!(member, [:current_cycle_status, :overdue_count])
|
|
||||||
assert member.current_cycle_status == :unpaid
|
|
||||||
assert member.overdue_count == 0
|
|
||||||
|
|
||||||
# Change membership fee type
|
|
||||||
assert {:ok, updated_member} =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: yearly_type2.id
|
|
||||||
})
|
|
||||||
|> Ash.update()
|
|
||||||
|
|
||||||
# Cycle regeneration runs synchronously in the same transaction
|
|
||||||
# No need to wait for async completion
|
|
||||||
|
|
||||||
# Reload member with calculations
|
|
||||||
updated_member = Ash.load!(updated_member, [:current_cycle_status, :overdue_count])
|
|
||||||
|
|
||||||
# Calculations should still work (cycle was regenerated)
|
|
||||||
assert updated_member.current_cycle_status == :unpaid
|
|
||||||
assert updated_member.overdue_count == 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
defmodule Mv.Membership.MembershipFeeSettingsTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for membership fee settings in the Settings resource.
|
|
||||||
"""
|
|
||||||
use Mv.DataCase, async: true
|
|
||||||
|
|
||||||
alias Mv.Membership.Setting
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
|
|
||||||
describe "membership fee settings" do
|
|
||||||
test "default values are correct" do
|
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
|
||||||
assert settings.include_joining_cycle == true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "settings can be read" do
|
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
|
||||||
assert %Setting{} = settings
|
|
||||||
end
|
|
||||||
|
|
||||||
test "settings can be written via update_membership_fee_settings" do
|
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
|
||||||
|
|
||||||
{:ok, updated} =
|
|
||||||
settings
|
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|
||||||
include_joining_cycle: false
|
|
||||||
})
|
|
||||||
|> Ash.update()
|
|
||||||
|
|
||||||
assert updated.include_joining_cycle == false
|
|
||||||
end
|
|
||||||
|
|
||||||
test "default_membership_fee_type_id can be nil (optional)" do
|
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
|
||||||
|
|
||||||
{:ok, updated} =
|
|
||||||
settings
|
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|
||||||
default_membership_fee_type_id: nil
|
|
||||||
})
|
|
||||||
|> Ash.update()
|
|
||||||
|
|
||||||
assert updated.default_membership_fee_type_id == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "default_membership_fee_type_id validation: must exist if set" do
|
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
|
||||||
|
|
||||||
# Create a valid fee type
|
|
||||||
{:ok, fee_type} =
|
|
||||||
Ash.create(MembershipFeeType, %{
|
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
|
||||||
amount: Decimal.new("100.00"),
|
|
||||||
interval: :yearly
|
|
||||||
})
|
|
||||||
|
|
||||||
# Setting a valid fee type should work
|
|
||||||
{:ok, updated} =
|
|
||||||
settings
|
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|
||||||
default_membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|> Ash.update()
|
|
||||||
|
|
||||||
assert updated.default_membership_fee_type_id == fee_type.id
|
|
||||||
end
|
|
||||||
|
|
||||||
test "default_membership_fee_type_id validation: fails if not found" do
|
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
|
||||||
|
|
||||||
# Use a non-existent UUID
|
|
||||||
fake_uuid = Ecto.UUID.generate()
|
|
||||||
|
|
||||||
assert {:error, error} =
|
|
||||||
settings
|
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|
||||||
default_membership_fee_type_id: fake_uuid
|
|
||||||
})
|
|
||||||
|> Ash.update()
|
|
||||||
|
|
||||||
assert error_on_field?(error, :default_membership_fee_type_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to check if an error occurred on a specific field
|
|
||||||
defp error_on_field?(%Ash.Error.Invalid{} = error, field) do
|
|
||||||
Enum.any?(error.errors, fn e ->
|
|
||||||
case e do
|
|
||||||
%{field: ^field} -> true
|
|
||||||
%{fields: fields} when is_list(fields) -> field in fields
|
|
||||||
_ -> false
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp error_on_field?(_, _), do: false
|
|
||||||
end
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDateTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for the SetMembershipFeeStartDate change module.
|
|
||||||
"""
|
|
||||||
use Mv.DataCase, async: false
|
|
||||||
|
|
||||||
alias Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
|
||||||
|
|
||||||
# Helper to set up settings with specific include_joining_cycle value
|
|
||||||
defp setup_settings(include_joining_cycle) do
|
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
|
||||||
|
|
||||||
settings
|
|
||||||
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
|
||||||
|> Ash.update!()
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "calculate_start_date/3" do
|
|
||||||
test "include_joining_cycle = true: start date is first day of joining cycle (yearly)" do
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :yearly, true)
|
|
||||||
assert result == ~D[2024-01-01]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "include_joining_cycle = false: start date is first day of next cycle (yearly)" do
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :yearly, false)
|
|
||||||
assert result == ~D[2025-01-01]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "include_joining_cycle = true: start date is first day of joining cycle (quarterly)" do
|
|
||||||
# Q1: Jan-Mar, Q2: Apr-Jun, Q3: Jul-Sep, Q4: Oct-Dec
|
|
||||||
# March is in Q1
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :quarterly, true)
|
|
||||||
assert result == ~D[2024-01-01]
|
|
||||||
|
|
||||||
# May is in Q2
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-05-15], :quarterly, true)
|
|
||||||
assert result == ~D[2024-04-01]
|
|
||||||
|
|
||||||
# August is in Q3
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-08-15], :quarterly, true)
|
|
||||||
assert result == ~D[2024-07-01]
|
|
||||||
|
|
||||||
# November is in Q4
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-11-15], :quarterly, true)
|
|
||||||
assert result == ~D[2024-10-01]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "include_joining_cycle = false: start date is first day of next cycle (quarterly)" do
|
|
||||||
# March is in Q1, next is Q2
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :quarterly, false)
|
|
||||||
assert result == ~D[2024-04-01]
|
|
||||||
|
|
||||||
# June is in Q2, next is Q3
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-06-15], :quarterly, false)
|
|
||||||
assert result == ~D[2024-07-01]
|
|
||||||
|
|
||||||
# September is in Q3, next is Q4
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-09-15], :quarterly, false)
|
|
||||||
assert result == ~D[2024-10-01]
|
|
||||||
|
|
||||||
# December is in Q4, next is Q1 of next year
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-15], :quarterly, false)
|
|
||||||
assert result == ~D[2025-01-01]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "include_joining_cycle = true: start date is first day of joining cycle (half_yearly)" do
|
|
||||||
# H1: Jan-Jun, H2: Jul-Dec
|
|
||||||
# March is in H1
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :half_yearly, true)
|
|
||||||
assert result == ~D[2024-01-01]
|
|
||||||
|
|
||||||
# September is in H2
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-09-15], :half_yearly, true)
|
|
||||||
assert result == ~D[2024-07-01]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "include_joining_cycle = false: start date is first day of next cycle (half_yearly)" do
|
|
||||||
# March is in H1, next is H2
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :half_yearly, false)
|
|
||||||
assert result == ~D[2024-07-01]
|
|
||||||
|
|
||||||
# September is in H2, next is H1 of next year
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-09-15], :half_yearly, false)
|
|
||||||
assert result == ~D[2025-01-01]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "include_joining_cycle = true: start date is first day of joining cycle (monthly)" do
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :monthly, true)
|
|
||||||
assert result == ~D[2024-03-01]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "include_joining_cycle = false: start date is first day of next cycle (monthly)" do
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-03-15], :monthly, false)
|
|
||||||
assert result == ~D[2024-04-01]
|
|
||||||
|
|
||||||
# December goes to next year
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-15], :monthly, false)
|
|
||||||
assert result == ~D[2025-01-01]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "joining on first day of cycle with include_joining_cycle = true" do
|
|
||||||
# When joining exactly on cycle start, should return that date
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-01-01], :yearly, true)
|
|
||||||
assert result == ~D[2024-01-01]
|
|
||||||
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-04-01], :quarterly, true)
|
|
||||||
assert result == ~D[2024-04-01]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "joining on first day of cycle with include_joining_cycle = false" do
|
|
||||||
# When joining exactly on cycle start and include=false, should return next cycle
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-01-01], :yearly, false)
|
|
||||||
assert result == ~D[2025-01-01]
|
|
||||||
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-04-01], :quarterly, false)
|
|
||||||
assert result == ~D[2024-07-01]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "joining on last day of cycle" do
|
|
||||||
# Joining on Dec 31 with yearly cycle
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-31], :yearly, true)
|
|
||||||
assert result == ~D[2024-01-01]
|
|
||||||
|
|
||||||
result = SetMembershipFeeStartDate.calculate_start_date(~D[2024-12-31], :yearly, false)
|
|
||||||
assert result == ~D[2025-01-01]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "change/3 integration" do
|
|
||||||
test "sets membership_fee_start_date automatically on member creation" do
|
|
||||||
setup_settings(true)
|
|
||||||
|
|
||||||
# Create a fee type
|
|
||||||
fee_type =
|
|
||||||
Mv.MembershipFees.MembershipFeeType
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "Test Type #{System.unique_integer([:positive])}",
|
|
||||||
amount: Decimal.new("50.00"),
|
|
||||||
interval: :yearly
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
# Create member with join_date and fee type but no explicit start date
|
|
||||||
member =
|
|
||||||
Mv.Membership.Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "User",
|
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
|
||||||
join_date: ~D[2024-03-15],
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
# Should have auto-calculated start date (2024-01-01 for yearly with include_joining_cycle=true)
|
|
||||||
assert member.membership_fee_start_date == ~D[2024-01-01]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "does not override manually set membership_fee_start_date" do
|
|
||||||
setup_settings(true)
|
|
||||||
|
|
||||||
# Create a fee type
|
|
||||||
fee_type =
|
|
||||||
Mv.MembershipFees.MembershipFeeType
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "Test Type #{System.unique_integer([:positive])}",
|
|
||||||
amount: Decimal.new("50.00"),
|
|
||||||
interval: :yearly
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
# Create member with explicit start date
|
|
||||||
manual_start_date = ~D[2024-07-01]
|
|
||||||
|
|
||||||
member =
|
|
||||||
Mv.Membership.Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "User",
|
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
|
||||||
join_date: ~D[2024-03-15],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: manual_start_date
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
# Should keep the manually set date
|
|
||||||
assert member.membership_fee_start_date == manual_start_date
|
|
||||||
end
|
|
||||||
|
|
||||||
test "respects include_joining_cycle = false setting" do
|
|
||||||
setup_settings(false)
|
|
||||||
|
|
||||||
# Create a fee type
|
|
||||||
fee_type =
|
|
||||||
Mv.MembershipFees.MembershipFeeType
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "Test Type #{System.unique_integer([:positive])}",
|
|
||||||
amount: Decimal.new("50.00"),
|
|
||||||
interval: :yearly
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
# Create member
|
|
||||||
member =
|
|
||||||
Mv.Membership.Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "User",
|
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
|
||||||
join_date: ~D[2024-03-15],
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
# Should have next cycle start date (2025-01-01 for yearly with include_joining_cycle=false)
|
|
||||||
assert member.membership_fee_start_date == ~D[2025-01-01]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "does not set start date without join_date" do
|
|
||||||
setup_settings(true)
|
|
||||||
|
|
||||||
# Create a fee type
|
|
||||||
fee_type =
|
|
||||||
Mv.MembershipFees.MembershipFeeType
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "Test Type #{System.unique_integer([:positive])}",
|
|
||||||
amount: Decimal.new("50.00"),
|
|
||||||
interval: :yearly
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
# Create member without join_date
|
|
||||||
member =
|
|
||||||
Mv.Membership.Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "User",
|
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
# No join_date
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
# Should not have auto-calculated start date
|
|
||||||
assert is_nil(member.membership_fee_start_date)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "does not set start date without membership_fee_type_id" do
|
|
||||||
setup_settings(true)
|
|
||||||
|
|
||||||
# Create member without fee type
|
|
||||||
member =
|
|
||||||
Mv.Membership.Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "User",
|
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
|
||||||
join_date: ~D[2024-03-15]
|
|
||||||
# No membership_fee_type_id
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
# Should not have auto-calculated start date
|
|
||||||
assert is_nil(member.membership_fee_start_date)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for ValidateSameInterval change module.
|
|
||||||
"""
|
|
||||||
use Mv.DataCase, async: true
|
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
alias Mv.MembershipFees.Changes.ValidateSameInterval
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
describe "validate_interval_match/1" do
|
|
||||||
test "allows change to type with same interval" do
|
|
||||||
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"})
|
|
||||||
yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"})
|
|
||||||
|
|
||||||
member = create_member(%{membership_fee_type_id: yearly_type1.id})
|
|
||||||
|
|
||||||
changeset =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: yearly_type2.id
|
|
||||||
})
|
|
||||||
|> ValidateSameInterval.change(%{}, %{})
|
|
||||||
|
|
||||||
assert changeset.valid?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "prevents change to type with different interval" do
|
|
||||||
yearly_type = create_fee_type(%{interval: :yearly})
|
|
||||||
monthly_type = create_fee_type(%{interval: :monthly})
|
|
||||||
|
|
||||||
member = create_member(%{membership_fee_type_id: yearly_type.id})
|
|
||||||
|
|
||||||
changeset =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: monthly_type.id
|
|
||||||
})
|
|
||||||
|> ValidateSameInterval.change(%{}, %{})
|
|
||||||
|
|
||||||
refute changeset.valid?
|
|
||||||
assert %{errors: errors} = changeset
|
|
||||||
|
|
||||||
assert Enum.any?(errors, fn error ->
|
|
||||||
error.field == :membership_fee_type_id and
|
|
||||||
error.message =~ "yearly" and
|
|
||||||
error.message =~ "monthly"
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "allows first assignment of membership fee type" do
|
|
||||||
yearly_type = create_fee_type(%{interval: :yearly})
|
|
||||||
# No fee type assigned
|
|
||||||
member = create_member(%{})
|
|
||||||
|
|
||||||
changeset =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: yearly_type.id
|
|
||||||
})
|
|
||||||
|> ValidateSameInterval.change(%{}, %{})
|
|
||||||
|
|
||||||
assert changeset.valid?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "prevents removal of membership fee type" do
|
|
||||||
yearly_type = create_fee_type(%{interval: :yearly})
|
|
||||||
member = create_member(%{membership_fee_type_id: yearly_type.id})
|
|
||||||
|
|
||||||
changeset =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: nil
|
|
||||||
})
|
|
||||||
|> ValidateSameInterval.change(%{}, %{})
|
|
||||||
|
|
||||||
refute changeset.valid?
|
|
||||||
assert %{errors: errors} = changeset
|
|
||||||
|
|
||||||
assert Enum.any?(errors, fn error ->
|
|
||||||
error.field == :membership_fee_type_id and
|
|
||||||
error.message =~ "Cannot remove membership fee type"
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "does nothing when membership_fee_type_id is not changed" do
|
|
||||||
yearly_type = create_fee_type(%{interval: :yearly})
|
|
||||||
member = create_member(%{membership_fee_type_id: yearly_type.id})
|
|
||||||
|
|
||||||
changeset =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
first_name: "New Name"
|
|
||||||
})
|
|
||||||
|> ValidateSameInterval.change(%{}, %{})
|
|
||||||
|
|
||||||
assert changeset.valid?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "error message is clear and helpful" do
|
|
||||||
yearly_type = create_fee_type(%{interval: :yearly})
|
|
||||||
quarterly_type = create_fee_type(%{interval: :quarterly})
|
|
||||||
|
|
||||||
member = create_member(%{membership_fee_type_id: yearly_type.id})
|
|
||||||
|
|
||||||
changeset =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: quarterly_type.id
|
|
||||||
})
|
|
||||||
|> ValidateSameInterval.change(%{}, %{})
|
|
||||||
|
|
||||||
error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id))
|
|
||||||
assert error.message =~ "yearly"
|
|
||||||
assert error.message =~ "quarterly"
|
|
||||||
assert error.message =~ "same-interval"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles all interval types correctly" do
|
|
||||||
intervals = [:monthly, :quarterly, :half_yearly, :yearly]
|
|
||||||
|
|
||||||
for interval1 <- intervals,
|
|
||||||
interval2 <- intervals,
|
|
||||||
interval1 != interval2 do
|
|
||||||
type1 =
|
|
||||||
create_fee_type(%{
|
|
||||||
interval: interval1,
|
|
||||||
name: "Type #{interval1} #{System.unique_integer([:positive])}"
|
|
||||||
})
|
|
||||||
|
|
||||||
type2 =
|
|
||||||
create_fee_type(%{
|
|
||||||
interval: interval2,
|
|
||||||
name: "Type #{interval2} #{System.unique_integer([:positive])}"
|
|
||||||
})
|
|
||||||
|
|
||||||
member = create_member(%{membership_fee_type_id: type1.id})
|
|
||||||
|
|
||||||
changeset =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: type2.id
|
|
||||||
})
|
|
||||||
|> ValidateSameInterval.change(%{}, %{})
|
|
||||||
|
|
||||||
refute changeset.valid?,
|
|
||||||
"Should prevent change from #{interval1} to #{interval2}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "integration with update_member action" do
|
|
||||||
test "validation works when updating member via update_member action" do
|
|
||||||
yearly_type = create_fee_type(%{interval: :yearly})
|
|
||||||
monthly_type = create_fee_type(%{interval: :monthly})
|
|
||||||
|
|
||||||
member = create_member(%{membership_fee_type_id: yearly_type.id})
|
|
||||||
|
|
||||||
# Try to update member with different interval type
|
|
||||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: monthly_type.id
|
|
||||||
})
|
|
||||||
|> Ash.update()
|
|
||||||
|
|
||||||
# Check that error is about interval mismatch
|
|
||||||
error_message = extract_error_message(error)
|
|
||||||
assert error_message =~ "yearly"
|
|
||||||
assert error_message =~ "monthly"
|
|
||||||
assert error_message =~ "same-interval"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "allows update when interval matches" do
|
|
||||||
yearly_type1 = create_fee_type(%{interval: :yearly, name: "Yearly Type 1"})
|
|
||||||
yearly_type2 = create_fee_type(%{interval: :yearly, name: "Yearly Type 2"})
|
|
||||||
|
|
||||||
member = create_member(%{membership_fee_type_id: yearly_type1.id})
|
|
||||||
|
|
||||||
# Update member with same-interval type
|
|
||||||
assert {:ok, updated_member} =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: yearly_type2.id
|
|
||||||
})
|
|
||||||
|> Ash.update()
|
|
||||||
|
|
||||||
assert updated_member.membership_fee_type_id == yearly_type2.id
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) do
|
|
||||||
errors
|
|
||||||
|> Enum.filter(&(&1.field == :membership_fee_type_id))
|
|
||||||
|> Enum.map_join(" ", & &1.message)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
|
||||||
@moduledoc """
|
|
||||||
Integration tests for membership fee cycle generation triggered by member actions.
|
|
||||||
"""
|
|
||||||
use Mv.DataCase, async: false
|
|
||||||
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
# 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 set up settings
|
|
||||||
defp setup_settings(include_joining_cycle) do
|
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
|
||||||
|
|
||||||
settings
|
|
||||||
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
|
||||||
|> Ash.update!()
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to get cycles for a member
|
|
||||||
defp get_member_cycles(member_id) do
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member_id)
|
|
||||||
|> Ash.Query.sort(cycle_start: :asc)
|
|
||||||
|> Ash.read!()
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "member creation triggers cycle generation" do
|
|
||||||
test "creates cycles when member is created with fee type and join_date" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
member =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "User",
|
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
|
||||||
join_date: ~D[2023-03-15],
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
|
|
||||||
# Should have cycles for 2023 and 2024 (and possibly current year)
|
|
||||||
assert length(cycles) >= 2
|
|
||||||
|
|
||||||
# Verify cycles have correct data
|
|
||||||
Enum.each(cycles, fn cycle ->
|
|
||||||
assert cycle.member_id == member.id
|
|
||||||
assert cycle.membership_fee_type_id == fee_type.id
|
|
||||||
assert Decimal.equal?(cycle.amount, fee_type.amount)
|
|
||||||
assert cycle.status == :unpaid
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "does not create cycles when member has no fee type" do
|
|
||||||
setup_settings(true)
|
|
||||||
|
|
||||||
member =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "User",
|
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
|
||||||
join_date: ~D[2023-03-15]
|
|
||||||
# No membership_fee_type_id
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
|
|
||||||
assert cycles == []
|
|
||||||
end
|
|
||||||
|
|
||||||
test "does not create cycles when member has no join_date" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
member =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "User",
|
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
# No join_date
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
|
|
||||||
assert cycles == []
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "member update triggers cycle generation" do
|
|
||||||
test "generates cycles when fee type is assigned to existing member" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Create member without fee type
|
|
||||||
member =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "User",
|
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
|
||||||
join_date: ~D[2023-03-15]
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
# Verify no cycles yet
|
|
||||||
assert get_member_cycles(member.id) == []
|
|
||||||
|
|
||||||
# Update to assign fee type
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
|
|
||||||
# Should have generated cycles
|
|
||||||
assert length(cycles) >= 2
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "concurrent cycle generation" do
|
|
||||||
test "handles multiple members being created concurrently" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Create multiple members concurrently
|
|
||||||
tasks =
|
|
||||||
Enum.map(1..5, fn i ->
|
|
||||||
Task.async(fn ->
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Test#{i}",
|
|
||||||
last_name: "User#{i}",
|
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
|
||||||
join_date: ~D[2023-03-15],
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
members = Enum.map(tasks, &Task.await/1)
|
|
||||||
|
|
||||||
# Each member should have cycles
|
|
||||||
Enum.each(members, fn member ->
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
assert length(cycles) >= 2, "Member #{member.id} should have at least 2 cycles"
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "idempotent cycle generation" do
|
|
||||||
test "running generation multiple times does not create duplicate cycles" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
member =
|
|
||||||
Member
|
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "User",
|
|
||||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
|
||||||
join_date: ~D[2023-03-15],
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
initial_cycles = get_member_cycles(member.id)
|
|
||||||
initial_count = length(initial_cycles)
|
|
||||||
|
|
||||||
# Use a fixed "today" date to avoid date dependency
|
|
||||||
# Use a date far enough in the future to ensure all cycles are generated
|
|
||||||
today = ~D[2025-12-31]
|
|
||||||
|
|
||||||
# Manually trigger generation again with fixed "today" date
|
|
||||||
{:ok, _, _} =
|
|
||||||
Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
||||||
|
|
||||||
final_cycles = get_member_cycles(member.id)
|
|
||||||
final_count = length(final_cycles)
|
|
||||||
|
|
||||||
# Should have same number of cycles (idempotent)
|
|
||||||
assert final_count == initial_count
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Tests for MembershipFeeCycle resource, focusing on status management actions.
|
Tests for MembershipFeeCycle resource.
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
|
|
@ -8,200 +8,275 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
|
|
||||||
# Helper to create a membership fee type
|
setup do
|
||||||
defp create_fee_type(attrs) do
|
# Create a member for testing
|
||||||
default_attrs = %{
|
{:ok, member} =
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
Ash.create(Member, %{
|
||||||
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",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs = Map.merge(default_attrs, attrs)
|
|
||||||
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|
||||||
|> Ash.create!()
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "status defaults" do
|
|
||||||
test "status defaults to :unpaid when creating a cycle" do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
cycle =
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
cycle_start: ~D[2024-01-01],
|
|
||||||
amount: Decimal.new("50.00"),
|
|
||||||
member_id: member.id,
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
})
|
})
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
|
# Create a fee type for testing
|
||||||
|
{:ok, fee_type} =
|
||||||
|
Ash.create(MembershipFeeType, %{
|
||||||
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :monthly
|
||||||
|
})
|
||||||
|
|
||||||
|
%{member: member, fee_type: fee_type}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "create MembershipFeeCycle" do
|
||||||
|
test "can create cycle with valid attributes", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, %MembershipFeeCycle{} = cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert cycle.cycle_start == ~D[2025-01-01]
|
||||||
|
assert Decimal.equal?(cycle.amount, Decimal.new("100.00"))
|
||||||
|
assert cycle.member_id == member.id
|
||||||
|
assert cycle.membership_fee_type_id == fee_type.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create cycle with notes", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id,
|
||||||
|
notes: "First payment cycle"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert cycle.notes == "First payment cycle"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires cycle_start", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert error_on_field?(error, :cycle_start)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires amount", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert error_on_field?(error, :amount)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires member_id", %{fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert error_on_field?(error, :member_id) or error_on_field?(error, :member)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires membership_fee_type_id", %{member: member} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
|
||||||
|
assert error_on_field?(error, :membership_fee_type_id) or
|
||||||
|
error_on_field?(error, :membership_fee_type)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "status defaults to :unpaid", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
assert cycle.status == :unpaid
|
assert cycle.status == :unpaid
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "validates status enum values - unpaid", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id,
|
||||||
|
status: :unpaid
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert cycle.status == :unpaid
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "mark_as_paid" do
|
test "validates status enum values - paid", %{member: member, fee_type: fee_type} do
|
||||||
test "sets status to :paid" do
|
attrs = %{
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
cycle_start: ~D[2025-02-01],
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
amount: Decimal.new("100.00"),
|
||||||
cycle = create_cycle(member, fee_type, %{status: :unpaid})
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id,
|
||||||
|
status: :paid
|
||||||
|
}
|
||||||
|
|
||||||
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_paid)
|
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
assert updated.status == :paid
|
assert cycle.status == :paid
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can set notes when marking as paid" do
|
test "validates status enum values - suspended", %{member: member, fee_type: fee_type} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
attrs = %{
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
cycle_start: ~D[2025-03-01],
|
||||||
cycle = create_cycle(member, fee_type, %{status: :unpaid})
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id,
|
||||||
|
status: :suspended
|
||||||
|
}
|
||||||
|
|
||||||
assert {:ok, updated} =
|
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
Ash.update(cycle, %{notes: "Payment received via bank transfer"},
|
assert cycle.status == :suspended
|
||||||
action: :mark_as_paid
|
|
||||||
)
|
|
||||||
|
|
||||||
assert updated.status == :paid
|
|
||||||
assert updated.notes == "Payment received via bank transfer"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can change from suspended to paid" do
|
test "rejects invalid status values", %{member: member, fee_type: fee_type} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
attrs = %{
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
cycle_start: ~D[2025-01-01],
|
||||||
cycle = create_cycle(member, fee_type, %{status: :suspended})
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id,
|
||||||
|
status: :cancelled
|
||||||
|
}
|
||||||
|
|
||||||
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_paid)
|
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
assert updated.status == :paid
|
assert error_on_field?(error, :status)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects negative amount", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-04-01],
|
||||||
|
amount: Decimal.new("-50.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert error_on_field?(error, :amount)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts zero amount", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-05-01],
|
||||||
|
amount: Decimal.new("0.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert Decimal.equal?(cycle.amount, Decimal.new("0.00"))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "mark_as_suspended" do
|
describe "uniqueness constraint" do
|
||||||
test "sets status to :suspended" do
|
test "cannot create duplicate cycle for same member and cycle_start", %{
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
member: member,
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
fee_type: fee_type
|
||||||
cycle = create_cycle(member, fee_type, %{status: :unpaid})
|
} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_suspended)
|
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
assert updated.status == :suspended
|
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
|
||||||
|
# Should fail due to uniqueness constraint
|
||||||
|
assert is_struct(error, Ash.Error.Invalid)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can set notes when marking as suspended" do
|
test "can create cycles for same member with different cycle_start", %{
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
member: member,
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
fee_type: fee_type
|
||||||
cycle = create_cycle(member, fee_type, %{status: :unpaid})
|
} do
|
||||||
|
attrs1 = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
assert {:ok, updated} =
|
attrs2 = %{
|
||||||
Ash.update(cycle, %{notes: "Waived due to special circumstances"},
|
cycle_start: ~D[2025-02-01],
|
||||||
action: :mark_as_suspended
|
amount: Decimal.new("100.00"),
|
||||||
)
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
assert updated.status == :suspended
|
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs1)
|
||||||
assert updated.notes == "Waived due to special circumstances"
|
assert {:ok, _cycle2} = Ash.create(MembershipFeeCycle, attrs2)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can change from paid to suspended" do
|
test "can create cycles for different members with same cycle_start", %{fee_type: fee_type} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
{:ok, member1} =
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
Ash.create(Member, %{
|
||||||
cycle = create_cycle(member, fee_type, %{status: :paid})
|
first_name: "Member",
|
||||||
|
last_name: "One",
|
||||||
|
email: "member.one.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|
||||||
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_suspended)
|
{:ok, member2} =
|
||||||
assert updated.status == :suspended
|
Ash.create(Member, %{
|
||||||
|
first_name: "Member",
|
||||||
|
last_name: "Two",
|
||||||
|
email: "member.two.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
attrs1 = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member1.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs2 = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member2.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs1)
|
||||||
|
assert {:ok, _cycle2} = Ash.create(MembershipFeeCycle, attrs2)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "mark_as_unpaid" do
|
# Helper to check if an error occurred on a specific field
|
||||||
test "sets status to :unpaid" do
|
defp error_on_field?(%Ash.Error.Invalid{} = error, field) do
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
Enum.any?(error.errors, fn e ->
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
case e do
|
||||||
cycle = create_cycle(member, fee_type, %{status: :paid})
|
%{field: ^field} -> true
|
||||||
|
%{fields: fields} when is_list(fields) -> field in fields
|
||||||
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
|
_ -> false
|
||||||
assert updated.status == :unpaid
|
end
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can set notes when marking as unpaid" do
|
defp error_on_field?(_, _), do: false
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
cycle = create_cycle(member, fee_type, %{status: :paid})
|
|
||||||
|
|
||||||
assert {:ok, updated} =
|
|
||||||
Ash.update(cycle, %{notes: "Payment was reversed"}, action: :mark_as_unpaid)
|
|
||||||
|
|
||||||
assert updated.status == :unpaid
|
|
||||||
assert updated.notes == "Payment was reversed"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can change from suspended to unpaid" do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
cycle = create_cycle(member, fee_type, %{status: :suspended})
|
|
||||||
|
|
||||||
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
|
|
||||||
assert updated.status == :unpaid
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "status transitions" do
|
|
||||||
test "all status transitions are allowed" do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
|
||||||
|
|
||||||
# unpaid -> paid
|
|
||||||
cycle1 = create_cycle(member, fee_type, %{status: :unpaid})
|
|
||||||
assert {:ok, c1} = Ash.update(cycle1, %{}, action: :mark_as_paid)
|
|
||||||
assert c1.status == :paid
|
|
||||||
|
|
||||||
# paid -> suspended
|
|
||||||
assert {:ok, c2} = Ash.update(c1, %{}, action: :mark_as_suspended)
|
|
||||||
assert c2.status == :suspended
|
|
||||||
|
|
||||||
# suspended -> unpaid
|
|
||||||
assert {:ok, c3} = Ash.update(c2, %{}, action: :mark_as_unpaid)
|
|
||||||
assert c3.status == :unpaid
|
|
||||||
|
|
||||||
# unpaid -> suspended
|
|
||||||
assert {:ok, c4} = Ash.update(c3, %{}, action: :mark_as_suspended)
|
|
||||||
assert c4.status == :suspended
|
|
||||||
|
|
||||||
# suspended -> paid
|
|
||||||
assert {:ok, c5} = Ash.update(c4, %{}, action: :mark_as_paid)
|
|
||||||
assert c5.status == :paid
|
|
||||||
|
|
||||||
# paid -> unpaid
|
|
||||||
assert {:ok, c6} = Ash.update(c5, %{}, action: :mark_as_unpaid)
|
|
||||||
assert c6.status == :unpaid
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
|
||||||
@moduledoc """
|
|
||||||
Integration tests for MembershipFeeType CRUD operations.
|
|
||||||
"""
|
|
||||||
use Mv.DataCase, async: false
|
|
||||||
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
describe "admin can create membership fee type" do
|
|
||||||
test "creates type with all fields" do
|
|
||||||
attrs = %{
|
|
||||||
name: "Standard Membership",
|
|
||||||
amount: Decimal.new("120.00"),
|
|
||||||
interval: :yearly,
|
|
||||||
description: "Standard yearly membership fee"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert {:ok, %MembershipFeeType{} = fee_type} = Ash.create(MembershipFeeType, attrs)
|
|
||||||
|
|
||||||
assert fee_type.name == "Standard Membership"
|
|
||||||
assert Decimal.equal?(fee_type.amount, Decimal.new("120.00"))
|
|
||||||
assert fee_type.interval == :yearly
|
|
||||||
assert fee_type.description == "Standard yearly membership fee"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "admin can update membership fee type" do
|
|
||||||
setup do
|
|
||||||
{:ok, fee_type} =
|
|
||||||
Ash.create(MembershipFeeType, %{
|
|
||||||
name: "Original Name",
|
|
||||||
amount: Decimal.new("100.00"),
|
|
||||||
interval: :yearly,
|
|
||||||
description: "Original description"
|
|
||||||
})
|
|
||||||
|
|
||||||
%{fee_type: fee_type}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can update name", %{fee_type: fee_type} do
|
|
||||||
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"})
|
|
||||||
assert updated.name == "Updated Name"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can update amount", %{fee_type: fee_type} do
|
|
||||||
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")})
|
|
||||||
assert Decimal.equal?(updated.amount, Decimal.new("150.00"))
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can update description", %{fee_type: fee_type} do
|
|
||||||
assert {:ok, updated} = Ash.update(fee_type, %{description: "Updated description"})
|
|
||||||
assert updated.description == "Updated description"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot update interval", %{fee_type: fee_type} do
|
|
||||||
# Currently, interval is not in the accept list, so it's rejected as "NoSuchInput"
|
|
||||||
# After implementing validation, it should return a validation error
|
|
||||||
assert {:error, error} = Ash.update(fee_type, %{interval: :monthly})
|
|
||||||
# For now, check that it's an error (either NoSuchInput or validation error)
|
|
||||||
assert %Ash.Error.Invalid{} = error
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "admin cannot delete membership fee type when in use" do
|
|
||||||
test "cannot delete when members are assigned" do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Create a member with this fee type
|
|
||||||
{:ok, _member} =
|
|
||||||
Ash.create(Member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "Member",
|
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|
|
||||||
assert {:error, error} = Ash.destroy(fee_type)
|
|
||||||
error_message = extract_error_message(error)
|
|
||||||
assert error_message =~ "member(s) are assigned"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot delete when cycles exist" do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Create a member with this fee type
|
|
||||||
{:ok, member} =
|
|
||||||
Ash.create(Member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "Member",
|
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|
|
||||||
# Create a cycle for this fee type
|
|
||||||
{:ok, _cycle} =
|
|
||||||
Ash.create(MembershipFeeCycle, %{
|
|
||||||
cycle_start: ~D[2025-01-01],
|
|
||||||
amount: Decimal.new("100.00"),
|
|
||||||
member_id: member.id,
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|
|
||||||
assert {:error, error} = Ash.destroy(fee_type)
|
|
||||||
error_message = extract_error_message(error)
|
|
||||||
assert error_message =~ "cycle(s) reference"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot delete when used as default in settings" do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Set as default in settings
|
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
|
||||||
|
|
||||||
settings
|
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|
||||||
default_membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Try to delete
|
|
||||||
assert {:error, error} = Ash.destroy(fee_type)
|
|
||||||
error_message = extract_error_message(error)
|
|
||||||
assert error_message =~ "used as default in settings"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "settings integration" do
|
|
||||||
test "default_membership_fee_type_id is used during member creation" do
|
|
||||||
# Create a fee type
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Set it as default in settings
|
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
|
||||||
|
|
||||||
settings
|
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|
||||||
default_membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Create a member without explicitly setting membership_fee_type_id
|
|
||||||
# Note: This test assumes that the Member resource automatically assigns
|
|
||||||
# the default_membership_fee_type_id during creation. If this is not yet
|
|
||||||
# implemented, this test will fail initially (which is expected in TDD).
|
|
||||||
# For now, we skip this test as the auto-assignment feature is not yet implemented.
|
|
||||||
{:ok, member} =
|
|
||||||
Ash.create(Member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "Member",
|
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
|
||||||
})
|
|
||||||
|
|
||||||
# TODO: When auto-assignment is implemented, uncomment this assertion
|
|
||||||
# assert member.membership_fee_type_id == fee_type.id
|
|
||||||
# For now, we just verify the member was created successfully
|
|
||||||
assert %Member{} = member
|
|
||||||
end
|
|
||||||
|
|
||||||
test "include_joining_cycle is used during cycle generation" do
|
|
||||||
# This test verifies that the include_joining_cycle setting affects
|
|
||||||
# cycle generation. The actual cycle generation logic is tested in
|
|
||||||
# CycleGeneratorTest, but this integration test ensures the setting
|
|
||||||
# is properly used.
|
|
||||||
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Set include_joining_cycle to false
|
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
|
||||||
|
|
||||||
settings
|
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|
||||||
include_joining_cycle: false
|
|
||||||
})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Create a member with join_date in the middle of a year
|
|
||||||
{:ok, member} =
|
|
||||||
Ash.create(Member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "Member",
|
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com",
|
|
||||||
join_date: ~D[2023-03-15],
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|
|
||||||
# Verify that membership_fee_start_date was calculated correctly
|
|
||||||
# (should be 2024-01-01, not 2023-01-01, because include_joining_cycle = false)
|
|
||||||
assert member.membership_fee_start_date == ~D[2024-01-01]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to extract error message from various error types
|
|
||||||
defp extract_error_message(%Ash.Error.Invalid{} = error) do
|
|
||||||
Enum.map_join(error.errors, " ", fn
|
|
||||||
%{message: message} -> message
|
|
||||||
%{detail: detail} -> detail
|
|
||||||
_ -> ""
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_error_message(_), do: ""
|
|
||||||
end
|
|
||||||
|
|
@ -155,95 +155,6 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
||||||
assert {:ok, updated} = Ash.update(fee_type, %{description: nil})
|
assert {:ok, updated} = Ash.update(fee_type, %{description: nil})
|
||||||
assert updated.description == nil
|
assert updated.description == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "interval immutability: update fails when interval is changed", %{fee_type: fee_type} do
|
|
||||||
# Currently, interval is not in the accept list, so it's rejected as "NoSuchInput"
|
|
||||||
# After implementing validation, it should return a validation error
|
|
||||||
assert {:error, error} = Ash.update(fee_type, %{interval: :monthly})
|
|
||||||
# For now, check that it's an error (either NoSuchInput or validation error)
|
|
||||||
assert %Ash.Error.Invalid{} = error
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "delete MembershipFeeType" do
|
|
||||||
setup do
|
|
||||||
{:ok, fee_type} =
|
|
||||||
Ash.create(MembershipFeeType, %{
|
|
||||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
|
||||||
amount: Decimal.new("100.00"),
|
|
||||||
interval: :yearly
|
|
||||||
})
|
|
||||||
|
|
||||||
%{fee_type: fee_type}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "can delete when not in use", %{fee_type: fee_type} do
|
|
||||||
result = Ash.destroy(fee_type)
|
|
||||||
# Ash.destroy returns :ok or {:ok, _} depending on version
|
|
||||||
assert result == :ok or match?({:ok, _}, result)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot delete when members are assigned", %{fee_type: fee_type} do
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
|
|
||||||
# Create a member with this fee type
|
|
||||||
{:ok, _member} =
|
|
||||||
Ash.create(Member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "Member",
|
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|
|
||||||
assert {:error, error} = Ash.destroy(fee_type)
|
|
||||||
# Check for either validation error message or DB constraint error
|
|
||||||
error_message = extract_error_message(error)
|
|
||||||
assert error_message =~ "member" or error_message =~ "referenced"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot delete when cycles exist", %{fee_type: fee_type} do
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
|
|
||||||
# Create a member with this fee type
|
|
||||||
{:ok, member} =
|
|
||||||
Ash.create(Member, %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "Member",
|
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com",
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|
|
||||||
# Create a cycle for this fee type
|
|
||||||
{:ok, _cycle} =
|
|
||||||
Ash.create(MembershipFeeCycle, %{
|
|
||||||
cycle_start: ~D[2025-01-01],
|
|
||||||
amount: Decimal.new("100.00"),
|
|
||||||
member_id: member.id,
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|
|
||||||
assert {:error, error} = Ash.destroy(fee_type)
|
|
||||||
# Check for either validation error message or DB constraint error
|
|
||||||
error_message = extract_error_message(error)
|
|
||||||
assert error_message =~ "cycle" or error_message =~ "referenced"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cannot delete when used as default in settings", %{fee_type: fee_type} do
|
|
||||||
# Set as default in settings
|
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
|
||||||
|
|
||||||
settings
|
|
||||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
|
||||||
default_membership_fee_type_id: fee_type.id
|
|
||||||
})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Try to delete
|
|
||||||
assert {:error, error} = Ash.destroy(fee_type)
|
|
||||||
error_message = extract_error_message(error)
|
|
||||||
assert error_message =~ "used as default in settings"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to check if an error occurred on a specific field
|
# Helper to check if an error occurred on a specific field
|
||||||
|
|
@ -258,15 +169,4 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp error_on_field?(_, _), do: false
|
defp error_on_field?(_, _), do: false
|
||||||
|
|
||||||
# Helper to extract error message from various error types
|
|
||||||
defp extract_error_message(%Ash.Error.Invalid{} = error) do
|
|
||||||
Enum.map_join(error.errors, " ", fn
|
|
||||||
%{message: message} -> message
|
|
||||||
%{detail: detail} -> detail
|
|
||||||
_ -> ""
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_error_message(_), do: ""
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,644 +0,0 @@
|
||||||
defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|
||||||
@moduledoc """
|
|
||||||
Edge case tests for the CycleGenerator module.
|
|
||||||
|
|
||||||
Tests cover:
|
|
||||||
- Member joins today
|
|
||||||
- Member left yesterday
|
|
||||||
- Year boundary handling
|
|
||||||
- Leap year handling
|
|
||||||
- Members with no existing cycles
|
|
||||||
- Members with existing cycles
|
|
||||||
"""
|
|
||||||
use Mv.DataCase, async: false
|
|
||||||
|
|
||||||
alias Mv.MembershipFees.CycleGenerator
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
# 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. Note: If membership_fee_type_id is provided,
|
|
||||||
# cycles will be auto-generated during creation in test environment.
|
|
||||||
defp create_member(attrs) do
|
|
||||||
default_attrs = %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "User",
|
|
||||||
email: "test#{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 member and explicitly generate cycles with a fixed "today" date.
|
|
||||||
# This avoids date dependency issues in tests.
|
|
||||||
#
|
|
||||||
# Note: We first create the member without fee_type_id, then assign it via update,
|
|
||||||
# which triggers the after_action hook. However, we then explicitly regenerate
|
|
||||||
# cycles with the fixed "today" date to ensure consistency.
|
|
||||||
defp create_member_with_cycles(attrs, today) do
|
|
||||||
# Extract membership_fee_type_id if present
|
|
||||||
fee_type_id = Map.get(attrs, :membership_fee_type_id)
|
|
||||||
|
|
||||||
# Create member WITHOUT fee type first to avoid auto-generation with real today
|
|
||||||
attrs_without_fee_type = Map.delete(attrs, :membership_fee_type_id)
|
|
||||||
|
|
||||||
member =
|
|
||||||
create_member(attrs_without_fee_type)
|
|
||||||
|
|
||||||
# Assign fee type if provided (this will trigger auto-generation with real today)
|
|
||||||
member =
|
|
||||||
if fee_type_id do
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type_id})
|
|
||||||
|> Ash.update!()
|
|
||||||
else
|
|
||||||
member
|
|
||||||
end
|
|
||||||
|
|
||||||
# Explicitly regenerate cycles with fixed "today" date to override any auto-generated cycles
|
|
||||||
# This ensures the test uses the fixed date, not the real current date
|
|
||||||
if fee_type_id && member.join_date do
|
|
||||||
# Delete any existing cycles first to ensure clean state
|
|
||||||
existing_cycles = get_member_cycles(member.id)
|
|
||||||
Enum.each(existing_cycles, &Ash.destroy!(&1))
|
|
||||||
|
|
||||||
# Generate cycles with fixed "today" date
|
|
||||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
||||||
end
|
|
||||||
|
|
||||||
member
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to get cycles for a member
|
|
||||||
defp get_member_cycles(member_id) do
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member_id)
|
|
||||||
|> Ash.Query.sort(cycle_start: :asc)
|
|
||||||
|> Ash.read!()
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to set up settings
|
|
||||||
defp setup_settings(include_joining_cycle) do
|
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
|
||||||
|
|
||||||
settings
|
|
||||||
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
|
||||||
|> Ash.update!()
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "member joins today" do
|
|
||||||
test "current cycle is generated (yearly)" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
|
||||||
|
|
||||||
# Create member WITHOUT fee type first to avoid auto-generation with real today
|
|
||||||
member =
|
|
||||||
create_member(%{
|
|
||||||
join_date: today,
|
|
||||||
membership_fee_start_date: ~D[2024-01-01]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Assign fee type
|
|
||||||
member =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Explicitly generate cycles with fixed "today" date
|
|
||||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
|
|
||||||
# Should have the current year's cycle
|
|
||||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year)
|
|
||||||
assert 2024 in cycle_years
|
|
||||||
end
|
|
||||||
|
|
||||||
test "current cycle is generated (monthly)" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
|
||||||
|
|
||||||
# Create member WITHOUT fee type first to avoid auto-generation with real today
|
|
||||||
member =
|
|
||||||
create_member(%{
|
|
||||||
join_date: today,
|
|
||||||
membership_fee_start_date: ~D[2024-06-01]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Assign fee type
|
|
||||||
member =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Explicitly generate cycles with fixed "today" date
|
|
||||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
|
|
||||||
# Should have June 2024 cycle
|
|
||||||
assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-06-01] end)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "current cycle is generated (quarterly)" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :quarterly})
|
|
||||||
|
|
||||||
today = ~D[2024-05-15]
|
|
||||||
|
|
||||||
# Create member and generate cycles with fixed "today" date
|
|
||||||
member =
|
|
||||||
create_member_with_cycles(
|
|
||||||
%{
|
|
||||||
join_date: today,
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2024-04-01]
|
|
||||||
},
|
|
||||||
today
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
|
|
||||||
# Should have Q2 2024 cycle
|
|
||||||
assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-04-01] end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "member left yesterday" do
|
|
||||||
test "no future cycles are generated" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
|
||||||
yesterday = Date.add(today, -1)
|
|
||||||
|
|
||||||
# Create member and generate cycles with fixed "today" date
|
|
||||||
member =
|
|
||||||
create_member_with_cycles(
|
|
||||||
%{
|
|
||||||
join_date: ~D[2022-03-15],
|
|
||||||
exit_date: yesterday,
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
|
||||||
},
|
|
||||||
today
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
|
||||||
|
|
||||||
# 2024 should be included because the member was still active during that cycle
|
|
||||||
assert 2022 in cycle_years
|
|
||||||
assert 2023 in cycle_years
|
|
||||||
assert 2024 in cycle_years
|
|
||||||
|
|
||||||
# 2025 should NOT be included
|
|
||||||
refute 2025 in cycle_years
|
|
||||||
end
|
|
||||||
|
|
||||||
test "exit during first month of year stops at that year (monthly)" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
|
||||||
|
|
||||||
# Create member - cycles will be auto-generated
|
|
||||||
member =
|
|
||||||
create_member(%{
|
|
||||||
join_date: ~D[2024-01-15],
|
|
||||||
exit_date: ~D[2024-03-15],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2024-01-01]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
cycle_months = Enum.map(cycles, & &1.cycle_start.month) |> Enum.sort()
|
|
||||||
|
|
||||||
assert 1 in cycle_months
|
|
||||||
assert 2 in cycle_months
|
|
||||||
assert 3 in cycle_months
|
|
||||||
|
|
||||||
# April and beyond should NOT be included
|
|
||||||
refute 4 in cycle_months
|
|
||||||
refute 5 in cycle_months
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "member has no cycles initially" do
|
|
||||||
test "returns error when fee type is not assigned" do
|
|
||||||
setup_settings(true)
|
|
||||||
|
|
||||||
# Create member WITHOUT fee type (no auto-generation)
|
|
||||||
member =
|
|
||||||
create_member(%{
|
|
||||||
join_date: ~D[2022-03-15],
|
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Verify no cycles exist initially
|
|
||||||
initial_cycles = get_member_cycles(member.id)
|
|
||||||
assert initial_cycles == []
|
|
||||||
|
|
||||||
# Trying to generate cycles without fee type should return error
|
|
||||||
result = CycleGenerator.generate_cycles_for_member(member.id)
|
|
||||||
assert result == {:error, :no_membership_fee_type}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates all cycles when member is created with fee type" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
|
||||||
|
|
||||||
# Create member and generate cycles with fixed "today" date
|
|
||||||
member =
|
|
||||||
create_member_with_cycles(
|
|
||||||
%{
|
|
||||||
join_date: ~D[2022-03-15],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
|
||||||
},
|
|
||||||
today
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
|
|
||||||
# Should have generated all cycles from 2022 to 2024 (3 cycles)
|
|
||||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
|
||||||
assert 2022 in cycle_years
|
|
||||||
assert 2023 in cycle_years
|
|
||||||
assert 2024 in cycle_years
|
|
||||||
# Should NOT have 2025 (today is 2024-06-15)
|
|
||||||
refute 2025 in cycle_years
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "member has existing cycles" do
|
|
||||||
test "generates from last cycle (not duplicating existing)" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Create member WITHOUT fee type first
|
|
||||||
member =
|
|
||||||
create_member(%{
|
|
||||||
join_date: ~D[2022-03-15],
|
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Manually create an existing cycle for 2022
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
cycle_start: ~D[2022-01-01],
|
|
||||||
member_id: member.id,
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
amount: fee_type.amount,
|
|
||||||
status: :paid
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
# Now assign fee type
|
|
||||||
member =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Explicitly generate cycles with fixed "today" date
|
|
||||||
today = ~D[2024-06-15]
|
|
||||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
all_cycles = get_member_cycles(member.id)
|
|
||||||
all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
|
|
||||||
|
|
||||||
# Should have 2022 (manually created), 2023 and 2024 (auto-generated)
|
|
||||||
assert 2022 in all_cycle_years
|
|
||||||
assert 2023 in all_cycle_years
|
|
||||||
assert 2024 in all_cycle_years
|
|
||||||
|
|
||||||
# Verify no duplicates
|
|
||||||
assert length(all_cycles) == length(all_cycle_years)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "year boundary handling" do
|
|
||||||
test "cycles span across year boundaries correctly (yearly)" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
|
||||||
|
|
||||||
# Create member and generate cycles with fixed "today" date
|
|
||||||
member =
|
|
||||||
create_member_with_cycles(
|
|
||||||
%{
|
|
||||||
join_date: ~D[2023-11-15],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2023-01-01]
|
|
||||||
},
|
|
||||||
today
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
|
||||||
|
|
||||||
# Should have 2023 and 2024
|
|
||||||
assert 2023 in cycle_years
|
|
||||||
assert 2024 in cycle_years
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cycles span across year boundaries correctly (quarterly)" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :quarterly})
|
|
||||||
|
|
||||||
today = ~D[2024-12-15]
|
|
||||||
|
|
||||||
# Create member and generate cycles with fixed "today" date
|
|
||||||
member =
|
|
||||||
create_member_with_cycles(
|
|
||||||
%{
|
|
||||||
join_date: ~D[2024-10-15],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2024-10-01]
|
|
||||||
},
|
|
||||||
today
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
|
|
||||||
|
|
||||||
# Should have Q4 2024
|
|
||||||
assert ~D[2024-10-01] in cycle_starts
|
|
||||||
end
|
|
||||||
|
|
||||||
test "December to January transition (monthly)" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
|
||||||
|
|
||||||
today = ~D[2024-12-31]
|
|
||||||
|
|
||||||
# Create member and generate cycles with fixed "today" date
|
|
||||||
member =
|
|
||||||
create_member_with_cycles(
|
|
||||||
%{
|
|
||||||
join_date: ~D[2024-12-15],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2024-12-01]
|
|
||||||
},
|
|
||||||
today
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
|
|
||||||
|
|
||||||
# Should have Dec 2024
|
|
||||||
assert ~D[2024-12-01] in cycle_starts
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "leap year handling" do
|
|
||||||
test "February cycles in leap year" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
|
||||||
|
|
||||||
today = ~D[2024-03-15]
|
|
||||||
|
|
||||||
# 2024 is a leap year
|
|
||||||
# Create member and generate cycles with fixed "today" date
|
|
||||||
member =
|
|
||||||
create_member_with_cycles(
|
|
||||||
%{
|
|
||||||
join_date: ~D[2024-02-15],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2024-02-01]
|
|
||||||
},
|
|
||||||
today
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
|
|
||||||
# Should have February 2024 cycle
|
|
||||||
feb_cycle = Enum.find(cycles, fn c -> c.cycle_start == ~D[2024-02-01] end)
|
|
||||||
|
|
||||||
assert feb_cycle != nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "February cycles in non-leap year" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :monthly})
|
|
||||||
|
|
||||||
today = ~D[2023-03-15]
|
|
||||||
|
|
||||||
# 2023 is NOT a leap year
|
|
||||||
# Create member and generate cycles with fixed "today" date
|
|
||||||
member =
|
|
||||||
create_member_with_cycles(
|
|
||||||
%{
|
|
||||||
join_date: ~D[2023-02-15],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2023-02-01]
|
|
||||||
},
|
|
||||||
today
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
|
|
||||||
# Should have February 2023 cycle
|
|
||||||
feb_cycle = Enum.find(cycles, fn c -> c.cycle_start == ~D[2023-02-01] end)
|
|
||||||
|
|
||||||
assert feb_cycle != nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "yearly cycle in leap year" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
today = ~D[2024-12-31]
|
|
||||||
|
|
||||||
# Create member and generate cycles with fixed "today" date
|
|
||||||
member =
|
|
||||||
create_member_with_cycles(
|
|
||||||
%{
|
|
||||||
join_date: ~D[2024-02-29],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2024-01-01]
|
|
||||||
},
|
|
||||||
today
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
|
|
||||||
# Should have 2024 cycle
|
|
||||||
cycle_2024 = Enum.find(cycles, fn c -> c.cycle_start == ~D[2024-01-01] end)
|
|
||||||
|
|
||||||
assert cycle_2024 != nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "include_joining_cycle variations" do
|
|
||||||
test "include_joining_cycle = true starts from joining cycle" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
|
||||||
|
|
||||||
# Member joins mid-2023, should get 2023 cycle with include_joining_cycle=true
|
|
||||||
# Create member and generate cycles with fixed "today" date
|
|
||||||
member =
|
|
||||||
create_member_with_cycles(
|
|
||||||
%{
|
|
||||||
join_date: ~D[2023-06-15],
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
# membership_fee_start_date will be auto-calculated
|
|
||||||
},
|
|
||||||
today
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
|
||||||
|
|
||||||
# Should include 2023 (joining year)
|
|
||||||
assert 2023 in cycle_years
|
|
||||||
end
|
|
||||||
|
|
||||||
test "include_joining_cycle = false starts from next cycle" do
|
|
||||||
setup_settings(false)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
|
||||||
|
|
||||||
# Member joins mid-2023, should start from 2024 with include_joining_cycle=false
|
|
||||||
# Create member and generate cycles with fixed "today" date
|
|
||||||
member =
|
|
||||||
create_member_with_cycles(
|
|
||||||
%{
|
|
||||||
join_date: ~D[2023-06-15],
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
# membership_fee_start_date will be auto-calculated
|
|
||||||
},
|
|
||||||
today
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check all cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
|
||||||
|
|
||||||
# Should NOT include 2023 (joining year)
|
|
||||||
refute 2023 in cycle_years
|
|
||||||
|
|
||||||
# Should start from 2024
|
|
||||||
assert 2024 in cycle_years
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "inactive member processing" do
|
|
||||||
test "inactive members receive cycles up to exit_date via generate_cycles_for_all_members" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Create an inactive member (left in 2023) WITHOUT fee type initially
|
|
||||||
# This simulates a member that was created before the fee system existed
|
|
||||||
member =
|
|
||||||
create_member(%{
|
|
||||||
join_date: ~D[2021-03-15],
|
|
||||||
exit_date: ~D[2023-06-15]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Now assign fee type (simulating a retroactive assignment)
|
|
||||||
member =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2021-01-01]
|
|
||||||
})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Run batch generation with a "today" date after the member left
|
|
||||||
today = ~D[2024-06-15]
|
|
||||||
{:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today)
|
|
||||||
|
|
||||||
# The inactive member should have been processed
|
|
||||||
assert results.total >= 1
|
|
||||||
|
|
||||||
# Check the member's cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
|
|
||||||
|
|
||||||
# Should have 2021, 2022, 2023 (exit year included)
|
|
||||||
assert 2021 in cycle_years
|
|
||||||
assert 2022 in cycle_years
|
|
||||||
assert 2023 in cycle_years
|
|
||||||
|
|
||||||
# Should NOT have 2024 (after exit)
|
|
||||||
refute 2024 in cycle_years
|
|
||||||
end
|
|
||||||
|
|
||||||
test "exit_date on cycle_start still generates that cycle" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
today = ~D[2024-12-31]
|
|
||||||
|
|
||||||
# Member exits exactly on cycle start (2024-01-01)
|
|
||||||
# Create member and generate cycles with fixed "today" date
|
|
||||||
member =
|
|
||||||
create_member_with_cycles(
|
|
||||||
%{
|
|
||||||
join_date: ~D[2022-03-15],
|
|
||||||
exit_date: ~D[2024-01-01],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
|
||||||
},
|
|
||||||
today
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check cycles
|
|
||||||
cycles = get_member_cycles(member.id)
|
|
||||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
|
||||||
|
|
||||||
# 2024 should be included because exit_date == cycle_start means
|
|
||||||
# the member was still a member on that day
|
|
||||||
assert 2022 in cycle_years
|
|
||||||
assert 2023 in cycle_years
|
|
||||||
assert 2024 in cycle_years
|
|
||||||
|
|
||||||
# 2025 should NOT be included
|
|
||||||
refute 2025 in cycle_years
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,428 +0,0 @@
|
||||||
defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for the CycleGenerator module.
|
|
||||||
"""
|
|
||||||
use Mv.DataCase, async: false
|
|
||||||
|
|
||||||
alias Mv.MembershipFees.CycleGenerator
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
# 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 without triggering cycle generation
|
|
||||||
defp create_member_without_cycles(attrs) do
|
|
||||||
default_attrs = %{
|
|
||||||
first_name: "Test",
|
|
||||||
last_name: "User",
|
|
||||||
email: "test#{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 set up settings with specific include_joining_cycle value
|
|
||||||
defp setup_settings(include_joining_cycle) do
|
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
|
||||||
|
|
||||||
settings
|
|
||||||
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
|
||||||
|> Ash.update!()
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper to get cycles for a member
|
|
||||||
defp get_member_cycles(member_id) do
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member_id)
|
|
||||||
|> Ash.Query.sort(cycle_start: :asc)
|
|
||||||
|> Ash.read!()
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "generate_cycles_for_member/2" do
|
|
||||||
test "generates cycles from start date to today" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Create member WITHOUT fee type first to avoid auto-generation
|
|
||||||
member =
|
|
||||||
create_member_without_cycles(%{
|
|
||||||
join_date: ~D[2022-03-15],
|
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Assign fee type
|
|
||||||
member =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Explicitly generate cycles with fixed "today" date to avoid date dependency
|
|
||||||
today = ~D[2024-06-15]
|
|
||||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
||||||
|
|
||||||
# Verify cycles were generated
|
|
||||||
all_cycles = get_member_cycles(member.id)
|
|
||||||
cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
|
|
||||||
|
|
||||||
# With include_joining_cycle=true and join_date=2022-03-15,
|
|
||||||
# start_date should be 2022-01-01
|
|
||||||
# Should have cycles for 2022, 2023, 2024
|
|
||||||
assert 2022 in cycle_years
|
|
||||||
assert 2023 in cycle_years
|
|
||||||
assert 2024 in cycle_years
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates cycles from last existing cycle" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Create member without fee type first to avoid auto-generation
|
|
||||||
member =
|
|
||||||
create_member_without_cycles(%{
|
|
||||||
join_date: ~D[2022-03-15],
|
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Manually create a cycle for 2022
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
cycle_start: ~D[2022-01-01],
|
|
||||||
member_id: member.id,
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
amount: fee_type.amount,
|
|
||||||
status: :paid
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
|
|
||||||
# Now assign fee type to member
|
|
||||||
member =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Generate cycles with specific "today" date
|
|
||||||
today = ~D[2024-06-15]
|
|
||||||
{:ok, new_cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
||||||
|
|
||||||
# Should generate only 2023 and 2024 (2022 already exists)
|
|
||||||
new_cycle_years = Enum.map(new_cycles, & &1.cycle_start.year) |> Enum.sort()
|
|
||||||
|
|
||||||
assert 2022 not in new_cycle_years
|
|
||||||
end
|
|
||||||
|
|
||||||
test "respects left_at boundary (stops generation)" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
member =
|
|
||||||
create_member_without_cycles(%{
|
|
||||||
join_date: ~D[2022-03-15],
|
|
||||||
exit_date: ~D[2023-06-15],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Generate cycles with specific "today" date far in the future
|
|
||||||
today = ~D[2025-06-15]
|
|
||||||
{:ok, cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
||||||
|
|
||||||
# With exit_date in 2023, should only generate 2022 and 2023 cycles
|
|
||||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
|
||||||
|
|
||||||
# Should not have 2024 or 2025 cycles
|
|
||||||
assert 2024 not in cycle_years
|
|
||||||
assert 2025 not in cycle_years
|
|
||||||
end
|
|
||||||
|
|
||||||
test "skips existing cycles (idempotent)" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
member =
|
|
||||||
create_member_without_cycles(%{
|
|
||||||
join_date: ~D[2023-03-15],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2023-01-01]
|
|
||||||
})
|
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
|
||||||
|
|
||||||
# First generation
|
|
||||||
{:ok, _first_cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
||||||
|
|
||||||
# Second generation (should be idempotent)
|
|
||||||
{:ok, second_cycles, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
|
||||||
|
|
||||||
# Second call should return empty list (no new cycles)
|
|
||||||
assert second_cycles == []
|
|
||||||
end
|
|
||||||
|
|
||||||
test "does not fill gaps when cycles were deleted" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Create member without fee type first to control which cycles exist
|
|
||||||
member =
|
|
||||||
create_member_without_cycles(%{
|
|
||||||
join_date: ~D[2020-03-15],
|
|
||||||
membership_fee_start_date: ~D[2020-01-01]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Manually create cycles for 2020, 2021, 2022, 2023
|
|
||||||
for year <- [2020, 2021, 2022, 2023] do
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
cycle_start: Date.new!(year, 1, 1),
|
|
||||||
member_id: member.id,
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
amount: fee_type.amount,
|
|
||||||
status: :unpaid
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
end
|
|
||||||
|
|
||||||
# Delete the 2021 cycle (create a gap)
|
|
||||||
cycle_2021 =
|
|
||||||
MembershipFeeCycle
|
|
||||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^~D[2021-01-01])
|
|
||||||
|> Ash.read_one!()
|
|
||||||
|
|
||||||
Ash.destroy!(cycle_2021)
|
|
||||||
|
|
||||||
# Now assign fee type to member (this triggers generation)
|
|
||||||
# Since cycles already exist (2020, 2022, 2023), the generator will
|
|
||||||
# start from the last existing cycle (2023) and go forward
|
|
||||||
member =
|
|
||||||
member
|
|
||||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
|
||||||
|> Ash.update!()
|
|
||||||
|
|
||||||
# Verify gap was NOT filled and new cycles were generated from last existing
|
|
||||||
all_cycles = get_member_cycles(member.id)
|
|
||||||
all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort()
|
|
||||||
|
|
||||||
# 2021 should NOT exist (gap was not filled)
|
|
||||||
refute 2021 in all_cycle_years, "Gap at 2021 should NOT be filled"
|
|
||||||
|
|
||||||
# 2020, 2022, 2023 should exist (original cycles)
|
|
||||||
assert 2020 in all_cycle_years
|
|
||||||
assert 2022 in all_cycle_years
|
|
||||||
assert 2023 in all_cycle_years
|
|
||||||
|
|
||||||
# 2024 and 2025 should exist (generated after last existing cycle 2023)
|
|
||||||
assert 2024 in all_cycle_years
|
|
||||||
assert 2025 in all_cycle_years
|
|
||||||
end
|
|
||||||
|
|
||||||
test "sets correct amount from membership fee type" do
|
|
||||||
setup_settings(true)
|
|
||||||
amount = Decimal.new("75.50")
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly, amount: amount})
|
|
||||||
|
|
||||||
member =
|
|
||||||
create_member_without_cycles(%{
|
|
||||||
join_date: ~D[2024-03-15],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2024-01-01]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Verify cycles were generated with correct amount
|
|
||||||
all_cycles = get_member_cycles(member.id)
|
|
||||||
refute Enum.empty?(all_cycles), "Expected cycles to be generated"
|
|
||||||
|
|
||||||
# All cycles should have the correct amount
|
|
||||||
Enum.each(all_cycles, fn cycle ->
|
|
||||||
assert Decimal.equal?(cycle.amount, amount)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles NULL membership_fee_start_date by calculating from join_date" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :quarterly})
|
|
||||||
|
|
||||||
# Create member without membership_fee_start_date - it will be auto-calculated
|
|
||||||
# and cycles will be auto-generated
|
|
||||||
member =
|
|
||||||
create_member_without_cycles(%{
|
|
||||||
join_date: ~D[2024-02-15],
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
# No membership_fee_start_date - should be calculated
|
|
||||||
})
|
|
||||||
|
|
||||||
# Verify cycles were auto-generated
|
|
||||||
all_cycles = get_member_cycles(member.id)
|
|
||||||
|
|
||||||
# With include_joining_cycle=true and join_date=2024-02-15 (quarterly),
|
|
||||||
# start_date should be 2024-01-01 (Q1 start)
|
|
||||||
# Should have Q1, Q2, Q3, Q4 2024 cycles (based on current date)
|
|
||||||
refute Enum.empty?(all_cycles), "Expected cycles to be generated"
|
|
||||||
|
|
||||||
cycle_starts = Enum.map(all_cycles, & &1.cycle_start) |> Enum.sort(Date)
|
|
||||||
first_cycle_start = List.first(cycle_starts)
|
|
||||||
|
|
||||||
# First cycle should start in Q1 2024 (2024-01-01)
|
|
||||||
assert first_cycle_start == ~D[2024-01-01]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns error when member has no membership_fee_type" do
|
|
||||||
# Create member without fee type - no auto-generation will occur
|
|
||||||
member =
|
|
||||||
create_member_without_cycles(%{
|
|
||||||
join_date: ~D[2024-03-15]
|
|
||||||
# No membership_fee_type_id
|
|
||||||
})
|
|
||||||
|
|
||||||
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
|
|
||||||
assert reason == :no_membership_fee_type
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns error when member has no join_date" do
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Create member without join_date - no auto-generation will occur
|
|
||||||
# (after_action hook checks for join_date)
|
|
||||||
member =
|
|
||||||
create_member_without_cycles(%{
|
|
||||||
membership_fee_type_id: fee_type.id
|
|
||||||
# No join_date
|
|
||||||
})
|
|
||||||
|
|
||||||
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
|
|
||||||
assert reason == :no_join_date
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns error when member not found" do
|
|
||||||
fake_id = Ash.UUID.generate()
|
|
||||||
{:error, reason} = CycleGenerator.generate_cycles_for_member(fake_id)
|
|
||||||
assert reason == :member_not_found
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "generate_cycle_starts/3" do
|
|
||||||
test "generates correct cycle starts for yearly interval" do
|
|
||||||
starts = CycleGenerator.generate_cycle_starts(~D[2022-01-01], ~D[2024-06-15], :yearly)
|
|
||||||
|
|
||||||
assert starts == [~D[2022-01-01], ~D[2023-01-01], ~D[2024-01-01]]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates correct cycle starts for quarterly interval" do
|
|
||||||
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-09-15], :quarterly)
|
|
||||||
|
|
||||||
assert starts == [~D[2024-01-01], ~D[2024-04-01], ~D[2024-07-01]]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates correct cycle starts for monthly interval" do
|
|
||||||
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-03-15], :monthly)
|
|
||||||
|
|
||||||
assert starts == [~D[2024-01-01], ~D[2024-02-01], ~D[2024-03-01]]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates correct cycle starts for half_yearly interval" do
|
|
||||||
starts = CycleGenerator.generate_cycle_starts(~D[2023-01-01], ~D[2024-09-15], :half_yearly)
|
|
||||||
|
|
||||||
assert starts == [~D[2023-01-01], ~D[2023-07-01], ~D[2024-01-01], ~D[2024-07-01]]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns empty list when start_date is after end_date" do
|
|
||||||
starts = CycleGenerator.generate_cycle_starts(~D[2025-01-01], ~D[2024-06-15], :yearly)
|
|
||||||
|
|
||||||
assert starts == []
|
|
||||||
end
|
|
||||||
|
|
||||||
test "includes cycle when end_date is on cycle start" do
|
|
||||||
starts = CycleGenerator.generate_cycle_starts(~D[2024-01-01], ~D[2024-01-01], :yearly)
|
|
||||||
|
|
||||||
assert starts == [~D[2024-01-01]]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "generate_cycles_for_all_members/1" do
|
|
||||||
test "generates cycles for multiple members" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
# Create multiple members
|
|
||||||
_member1 =
|
|
||||||
create_member_without_cycles(%{
|
|
||||||
join_date: ~D[2024-01-15],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2024-01-01]
|
|
||||||
})
|
|
||||||
|
|
||||||
_member2 =
|
|
||||||
create_member_without_cycles(%{
|
|
||||||
join_date: ~D[2024-02-15],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2024-01-01]
|
|
||||||
})
|
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
|
||||||
{:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today)
|
|
||||||
|
|
||||||
assert is_map(results)
|
|
||||||
assert Map.has_key?(results, :success)
|
|
||||||
assert Map.has_key?(results, :failed)
|
|
||||||
assert Map.has_key?(results, :total)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "lock mechanism" do
|
|
||||||
test "prevents concurrent generation for same member" do
|
|
||||||
setup_settings(true)
|
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
|
||||||
|
|
||||||
member =
|
|
||||||
create_member_without_cycles(%{
|
|
||||||
join_date: ~D[2022-03-15],
|
|
||||||
membership_fee_type_id: fee_type.id,
|
|
||||||
membership_fee_start_date: ~D[2022-01-01]
|
|
||||||
})
|
|
||||||
|
|
||||||
today = ~D[2024-06-15]
|
|
||||||
|
|
||||||
# Run two concurrent generations
|
|
||||||
task1 =
|
|
||||||
Task.async(fn -> CycleGenerator.generate_cycles_for_member(member.id, today: today) end)
|
|
||||||
|
|
||||||
task2 =
|
|
||||||
Task.async(fn -> CycleGenerator.generate_cycles_for_member(member.id, today: today) end)
|
|
||||||
|
|
||||||
result1 = Task.await(task1)
|
|
||||||
result2 = Task.await(task2)
|
|
||||||
|
|
||||||
# Both should succeed
|
|
||||||
assert match?({:ok, _, _}, result1)
|
|
||||||
assert match?({:ok, _, _}, result2)
|
|
||||||
|
|
||||||
# One should have created cycles, the other should have empty list (idempotent)
|
|
||||||
{:ok, cycles1, _} = result1
|
|
||||||
{:ok, cycles2, _} = result2
|
|
||||||
|
|
||||||
# Combined should not have duplicates
|
|
||||||
all_cycles = cycles1 ++ cycles2
|
|
||||||
unique_starts = all_cycles |> Enum.map(& &1.cycle_start) |> Enum.uniq()
|
|
||||||
|
|
||||||
assert length(all_cycles) == length(unique_starts)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue