22 KiB
Membership Fees - Technical Architecture
Project: Mila - Membership Management System
Feature: Membership Fee Management
Version: 1.0
Last Updated: 2026-01-13
Status: ✅ Implemented
Purpose
This document defines the technical architecture for the Membership Fees system. It focuses on architectural decisions, patterns, module structure, and integration points without concrete implementation details.
Related Documents:
- membership-fee-overview.md - Business logic and requirements
- database-schema-readme.md - Database documentation
- database_schema.dbml - Database schema definition
Table of Contents
- Architecture Principles
- Domain Structure
- Data Architecture
- Business Logic Architecture
- Integration Points
- Acceptance Criteria
- Testing Strategy
- Security Considerations
- Performance Considerations
Architecture Principles
Core Design Decisions
-
Single Responsibility:
- Each module has one clear responsibility
- Cycle generation separated from status management
- Calendar logic isolated in dedicated module
-
No Redundancy:
- No
cycle_endfield (calculated fromcycle_start+interval) - No
interval_typefield (read frommembership_fee_type.interval) - Eliminates data inconsistencies
- No
-
Immutability Where Important:
membership_fee_type.intervalcannot be changed after creation- Prevents complex migration scenarios
- Enforced via Ash change validation
-
Historical Accuracy:
amountstored per cycle for audit trail- Enables tracking of membership fee changes over time
- Old cycles retain original amounts
-
Calendar-Based Cycles:
- All cycles aligned to calendar boundaries
- Simplifies date calculations
- Predictable cycle generation
Domain Structure
Ash Domain: Mv.MembershipFees
Purpose: Encapsulates all membership fee-related resources and logic
Resources:
MembershipFeeType- Membership fee type definitions (admin-managed)MembershipFeeCycle- Individual membership fee cycles per member
Extensions:
- Member resource extended with membership fee fields
Module Organization
lib/
├── membership_fees/
│ ├── membership_fees.ex # Ash domain definition
│ ├── membership_fee_type.ex # MembershipFeeType resource
│ ├── membership_fee_cycle.ex # MembershipFeeCycle resource
│ └── changes/
│ ├── prevent_interval_change.ex # Validates interval immutability
│ ├── set_membership_fee_start_date.ex # Auto-sets start date
│ └── validate_same_interval.ex # Validates interval match on type change
├── mv/
│ └── membership_fees/
│ ├── cycle_generator.ex # Cycle generation algorithm
│ └── calendar_cycles.ex # Calendar cycle calculations
└── membership/
└── member.ex # Extended with membership fee relationships
Separation of Concerns
Domain Layer (Ash Resources):
- Data validation
- Relationship management
- Policy enforcement
- Action definitions
Business Logic Layer (Mv.MembershipFees):
- Cycle generation algorithm
- Calendar calculations
- Date boundary handling
- Status transitions
UI Layer (LiveView):
- User interaction
- Display logic
- Authorization checks
- Form handling
Data Architecture
Database Schema Extensions
See: database-schema-readme.md and database_schema.dbml for complete schema documentation.
New Tables
-
membership_fee_types- Purpose: Define membership fee types with fixed intervals
- Key Constraint:
intervalfield immutable after creation - Relationships: has_many members, has_many membership_fee_cycles
-
membership_fee_cycles- Purpose: Individual membership fee cycles for members
- Key Design: NO
cycle_endorinterval_typefields (calculated) - Relationships: belongs_to member, belongs_to membership_fee_type
- Composite uniqueness: One cycle per member per cycle_start
Member Table Extensions
Fields Added:
membership_fee_type_id(FK, NOT NULL with default from settings)membership_fee_start_date(Date, nullable)
Existing Fields Used:
join_date- For calculating membership fee startexit_date- For limiting cycle generation- These fields must remain member fields and should not be replaced by custom fields in the future
Settings Integration
Global Settings:
membership_fees.include_joining_cycle(Boolean)membership_fees.default_membership_fee_type_id(UUID)
Storage: Existing settings mechanism (TBD: dedicated table or configuration resource)
Foreign Key Behaviors
| Relationship | On Delete | Rationale |
|---|---|---|
membership_fee_cycles.member_id → members.id |
CASCADE | Remove membership fee cycles when member deleted |
membership_fee_cycles.membership_fee_type_id → membership_fee_types.id |
RESTRICT | Prevent membership fee type deletion if cycles exist |
members.membership_fee_type_id → membership_fee_types.id |
RESTRICT | Prevent membership fee type deletion if assigned to members |
Business Logic Architecture
Cycle Generation System
Component: Mv.MembershipFees.CycleGenerator
Responsibilities:
- Calculate which cycles should exist for a member
- Generate missing cycles
- Respect membership_fee_start_date and exit_date boundaries
- Skip existing cycles (idempotent)
- Use PostgreSQL advisory locks per member to prevent race conditions
Triggers:
- Member membership fee type assigned (via Ash change)
- Member created with membership fee type (via Ash change)
- Scheduled job runs (daily/weekly cron)
- Admin manual regeneration (UI action)
Algorithm Steps:
- Retrieve member with membership fee type and dates
- Determine generation start point:
- If NO cycles exist: Start from
membership_fee_start_date(or calculated fromjoin_date) - If cycles exist: Start from the cycle AFTER the last existing one
- If NO cycles exist: Start from
- Generate all cycle starts from the determined start point to today (or
exit_date) - Create new cycles with current membership fee type's amount
- Use PostgreSQL advisory locks per member to prevent race conditions
Edge Case Handling:
- If membership_fee_start_date is NULL: Calculate from join_date + global setting
- If exit_date is set: Stop generation at exit_date
- 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
Component: Mv.MembershipFees.CalendarCycles
Responsibilities:
- Calculate cycle boundaries based on interval type
- Determine current cycle
- Determine last completed cycle
- Calculate cycle_end from cycle_start + interval
Functions (high-level):
calculate_cycle_start/3- Given date and interval, find cycle startcalculate_cycle_end/2- Given cycle_start and interval, calculate endnext_cycle_start/2- Given cycle_start and interval, find nextis_current_cycle?/2- Check if cycle contains todayis_last_completed_cycle?/2- Check if cycle just ended
Interval Logic:
- Monthly: Start = 1st of month, End = last day of month
- Quarterly: Start = 1st of quarter (Jan/Apr/Jul/Oct), End = last day of quarter
- Half-yearly: Start = 1st of half (Jan/Jul), End = last day of half
- Yearly: Start = Jan 1st, End = Dec 31st
Status Management
Component: Ash actions on MembershipFeeCycle
Status Transitions:
- Simple state machine: unpaid ↔ paid ↔ suspended
- No complex validation (all transitions allowed)
- Permissions checked via Ash policies
Actions Required:
mark_as_paid- Set status to :paidmark_as_suspended- Set status to :suspendedmark_as_unpaid- Set status to :unpaid (error correction)
Bulk Operations:
bulk_mark_as_paid- Mark multiple cycles as paid (efficiency)- low priority, can be a future issue
Membership Fee Type Change Handling
Component: Ash change on Member.membership_fee_type_id
Validation:
- Check if new type has same interval as old type
- If different: Reject change (MVP constraint)
- If same: Allow change
Side Effects on Allowed Change:
- Keep all existing cycles unchanged
- Find future unpaid cycles
- Delete future unpaid cycles
- Regenerate cycles with new membership_fee_type_id and amount
Implementation Pattern:
- Use Ash change module to validate
- Use after_action hook to trigger regeneration synchronously
- Regeneration runs in the same transaction as the member update 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
Integration Points
Member Resource Integration
Extension Points:
- Add fields via migration
- Add relationships (belongs_to, has_many)
- Add calculations (current_cycle_status, overdue_count)
- Add changes (auto-set membership_fee_start_date, validate interval)
Backward Compatibility:
- New fields nullable or with defaults
- Existing members get default membership fee type from settings
- No breaking changes to existing member functionality
Settings System Integration
Requirements:
- Store two global settings
- Provide UI for admin to modify
- Default values if not set
- Validation (e.g., default membership fee type must exist)
Access Pattern:
- Read settings during cycle generation
- Read settings during member creation
- Write settings only via admin UI
Permission System Integration
See: roles-and-permissions-architecture.md
Required Permissions:
MembershipFeeType.create/update/destroy- Admin onlyMembershipFeeType.read- Admin, Treasurer, BoardMembershipFeeCycle.update(status changes) - Admin, TreasurerMembershipFeeCycle.read- Admin, Treasurer, Board, Own member
Policy Patterns:
- Use existing HasPermission check
- Leverage existing roles (Admin, Kassenwart)
- Member can read own cycles (linked via member_id)
LiveView Integration
New LiveViews Required:
- MembershipFeeType index/form (admin)
- MembershipFeeCycle table component (member detail view)
- Settings form section (admin)
- Member list column (membership fee status)
Existing LiveViews to Extend:
- Member detail view: Add membership fees section
- Member list view: Add status column
- Settings page: Add membership fees section
Authorization Helpers:
- Use existing
can?/3helper for UI conditionals - Check permissions before showing actions
Acceptance Criteria
MembershipFeeType Resource
AC-MFT-1: Admin can create membership fee type with name, amount, interval, description
AC-MFT-2: Interval field is immutable after creation (validation error on change attempt)
AC-MFT-3: Admin can update name, amount, description (but not interval)
AC-MFT-4: Cannot delete membership fee type if assigned to members
AC-MFT-5: Cannot delete membership fee type if cycles exist referencing it
AC-MFT-6: Interval must be one of: monthly, quarterly, half_yearly, yearly
MembershipFeeCycle Resource
AC-MFC-1: Cycle has cycle_start, status, amount, notes, member_id, membership_fee_type_id
AC-MFC-2: cycle_end is calculated, not stored
AC-MFC-3: Status defaults to :unpaid
AC-MFC-4: One cycle per member per cycle_start (uniqueness constraint)
AC-MFC-5: Amount is set at generation time from membership_fee_type.amount
AC-MFC-6: Cycles cascade delete when member deleted
AC-MFC-7: Admin/Treasurer can change status
AC-MFC-8: Member can read own cycles
Member Extensions
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-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-5: Admin can manually override membership_fee_start_date
AC-M-6: Cannot change to membership fee type with different interval (MVP)
Cycle Generation
AC-CG-1: Cycles generated when member gets membership fee type
AC-CG-2: Cycles generated when member created (via change hook)
AC-CG-3: Scheduled job generates missing cycles daily
AC-CG-4: Generation respects membership_fee_start_date
AC-CG-5: Generation stops at exit_date if member exited
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-8: Amount comes from membership_fee_type at generation time
Calendar Logic
AC-CL-1: Monthly cycles: 1st to last day of month
AC-CL-2: Quarterly cycles: 1st of Jan/Apr/Jul/Oct to last day of quarter
AC-CL-3: Half-yearly cycles: 1st of Jan/Jul to last day of half
AC-CL-4: Yearly cycles: Jan 1 to Dec 31
AC-CL-5: cycle_end calculated correctly for all interval types
AC-CL-6: Current cycle determined correctly based on today's date
AC-CL-7: Last completed cycle determined correctly
Membership Fee Type Change
AC-TC-1: Can change to type with same interval
AC-TC-2: Cannot change to type with different interval (error message)
AC-TC-3: On allowed change: future unpaid cycles regenerated
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-6: Change is atomic (transaction) - ✅ Implemented: Regeneration runs synchronously in the same transaction as the member update
Settings
AC-S-1: Global setting: include_joining_cycle (boolean, default true)
AC-S-2: Global setting: default_membership_fee_type_id (UUID, required)
AC-S-3: Admin can modify settings via UI
AC-S-4: Settings validated (e.g., default membership fee type must exist)
AC-S-5: Settings applied to new members immediately
UI - Member List
AC-UI-ML-1: New column shows membership fee status
AC-UI-ML-2: Default: Shows last completed cycle status
AC-UI-ML-3: Optional: Toggle to show current cycle status
AC-UI-ML-4: Color coding: green (paid), red (unpaid), gray (suspended)
AC-UI-ML-5: Filter: Unpaid in last cycle
AC-UI-ML-6: Filter: Unpaid in current cycle
UI - Member Detail
AC-UI-MD-1: Membership fees section shows all cycles
AC-UI-MD-2: Table columns: Cycle, Interval, Amount, Status, Actions
AC-UI-MD-3: Checkbox per cycle for bulk marking (low prio)
AC-UI-MD-4: "Mark selected as paid" button
AC-UI-MD-5: Dropdown to change membership fee type (same interval only)
AC-UI-MD-6: Warning if different interval selected
AC-UI-MD-7: Only show actions if user has permission
UI - Membership Fee Types Admin
AC-UI-CTA-1: List all membership fee types
AC-UI-CTA-2: Show: Name, Amount, Interval, Member count
AC-UI-CTA-3: Create new membership fee type form
AC-UI-CTA-4: Edit form: Name, Amount, Description editable
AC-UI-CTA-5: Edit form: Interval grayed out (not editable)
AC-UI-CTA-6: Warning on amount change (explain impact)
AC-UI-CTA-7: Cannot delete if members assigned
AC-UI-CTA-8: Only admin can access
UI - Settings Admin
AC-UI-SA-1: Membership fees section in settings
AC-UI-SA-2: Dropdown to select default membership fee type
AC-UI-SA-3: Checkbox: Include joining cycle
AC-UI-SA-4: Explanatory text with examples
AC-UI-SA-5: Save button with validation
Testing Strategy
Unit Testing
Cycle Generator Tests:
- Correct cycle_start calculation for all interval types
- Correct cycle count from start to end date
- Respects membership_fee_start_date boundary
- Respects exit_date boundary
- Skips existing cycles (idempotent)
- Does not fill gaps when cycles were deleted
- Handles edge dates (year boundaries, leap years)
Calendar Cycles Tests:
- Cycle boundaries correct for all intervals
- cycle_end calculation correct
- Current cycle detection
- Last completed cycle detection
- Next cycle calculation
Validation Tests:
- Interval immutability enforced
- Same interval validation on type change
- Status transitions allowed
- Uniqueness constraints enforced
Integration Testing
Cycle Generation Flow:
- Member creation triggers generation
- Type assignment triggers generation
- Type change regenerates future cycles
- Scheduled job generates missing cycles
- Left member stops generation
Status Management Flow:
- Mark single cycle as paid
- Bulk mark multiple cycles (low prio)
- Status transitions work
- Permissions enforced
Membership Fee Type Management:
- Create type
- Update amount (regeneration triggered)
- Cannot update interval
- Cannot delete if in use
LiveView Testing
Member List:
- Status column displays correctly
- Toggle between last/current works
- Filters work correctly
- Color coding applied
Member Detail:
- Cycles table displays all cycles
- Checkboxes work
- Bulk marking works (low prio)
- Membership fee type change validation works
- Actions only shown with permission
Admin UI:
- Type CRUD works
- Settings save correctly
- Validations display errors
- Only authorized users can access
Edge Case Testing
Interval Change Attempt:
- Error message displayed
- No data modified
- User can cancel/choose different type
Exit with Unpaid:
- Warning shown
- Option to suspend offered
- Exit completes correctly
Amount Change:
- Warning displayed
- Only future unpaid regenerated
- Historical cycles unchanged
Date Boundaries:
- Today = cycle start handled
- Today = cycle end handled
- Leap year handled
Performance Testing
Cycle Generation:
- Generate 10 years of monthly cycles: < 100ms
- Generate for 1000 members: < 5 seconds
- Idempotent check efficient (no full scan)
Member List Query:
- With status column: < 200ms for 1000 members
- Filters applied efficiently
- No N+1 queries
Security Considerations
Authorization
Permissions Required:
- Membership fee type management: Admin only
- Membership fee cycle status changes: Admin + Treasurer
- View all cycles: Admin + Treasurer + Board
- View own cycles: All authenticated users
Policy Enforcement:
- All actions protected by Ash policies
- UI shows/hides based on permissions
- Backend validates permissions (never trust UI alone)
Data Integrity
Validation Layers:
- Database constraints (NOT NULL, UNIQUE, CHECK)
- Ash validations (business rules)
- UI validations (user experience)
Immutability Protection:
- Interval change prevented at multiple layers
- Cycle amounts immutable (audit trail)
- Settings changes logged (future)
Audit Trail
Tracked Information:
- Cycle status changes (who, when) - future enhancement
- Membership fee type amount changes (implicit via cycle amounts)
Performance Considerations
Database Indexes
Required Indexes:
membership_fee_cycles(member_id)- For member cycle lookupsmembership_fee_cycles(membership_fee_type_id)- For type queriesmembership_fee_cycles(status)- For unpaid filtersmembership_fee_cycles(cycle_start)- For date range queriesmembership_fee_cycles(member_id, cycle_start)- Composite unique indexmembers(membership_fee_type_id)- For type membership count
Query Optimization
Preloading:
- Load membership_fee_type with cycles (avoid N+1)
- Load cycles when displaying member detail
- Use Ash's load for efficient preloading
Calculated Fields:
- cycle_end calculated on-demand (not stored)
- current_cycle_status calculated when needed
- Use Ash calculations for lazy evaluation
Pagination:
- Cycle list paginated if > 50 cycles
- Member list already paginated
Caching Strategy
No caching needed in MVP:
- Membership fee types rarely change
- Cycle queries are fast
- Settings read infrequently
Future caching if needed:
- Cache settings in application memory
- Cache membership fee types list
- Invalidate on change
Scheduled Job Performance
Cycle Generation Job:
- Run daily or weekly (not hourly)
- Batch members (process 100 at a time)
- Skip members with no changes
- Log failures for retry
Future Enhancements
Phase 2: Interval Change Support
Architecture Changes:
- Add logic to handle cycle overlaps
- Calculate prorata amounts if needed
- More complex validation
- Migration path for existing cycles
Phase 3: Payment Details
Architecture Changes:
- Add PaymentTransaction resource
- Link transactions to cycles
- Support multiple payments per cycle
- Reconciliation logic
Phase 4: vereinfacht.digital Integration
Architecture Changes:
- External API client module
- Webhook handling for transactions
- Automatic matching logic
- Manual review interface
End of Architecture Document