# 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](./membership-fee-overview.md) - Business logic and requirements
- [database-schema-readme.md](./database-schema-readme.md) - Database documentation
- [database_schema.dbml](./database_schema.dbml) - Database schema definition

---

## Table of Contents

1. [Architecture Principles](#architecture-principles)
2. [Domain Structure](#domain-structure)
3. [Data Architecture](#data-architecture)
4. [Business Logic Architecture](#business-logic-architecture)
5. [Integration Points](#integration-points)
6. [Acceptance Criteria](#acceptance-criteria)
7. [Testing Strategy](#testing-strategy)
8. [Security Considerations](#security-considerations)
9. [Performance Considerations](#performance-considerations)

---

## Architecture Principles

### Core Design Decisions

1. **Single Responsibility:**
   - Each module has one clear responsibility
   - Cycle generation separated from status management
   - Calendar logic isolated in dedicated module

2. **No Redundancy:**
   - No `cycle_end` field (calculated from `cycle_start` + `interval`)
   - No `interval_type` field (read from `membership_fee_type.interval`)
   - Eliminates data inconsistencies

3. **Immutability Where Important:**
   - `membership_fee_type.interval` cannot be changed after creation
   - Prevents complex migration scenarios
   - Enforced via Ash change validation

4. **Historical Accuracy:**
   - `amount` stored per cycle for audit trail
   - Enables tracking of membership fee changes over time
   - Old cycles retain original amounts

5. **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

**Public API:**
The domain exposes code interface functions:
- `create_membership_fee_type/1`, `list_membership_fee_types/0`, `update_membership_fee_type/2`, `destroy_membership_fee_type/1`
- `create_membership_fee_cycle/1`, `list_membership_fee_cycles/0`, `update_membership_fee_cycle/2`, `destroy_membership_fee_cycle/1`

**Note:** In LiveViews, direct `Ash.read`, `Ash.create`, `Ash.update`, `Ash.destroy` calls are used with `domain: Mv.MembershipFees` instead of code interface functions. This is acceptable for LiveView forms that use `AshPhoenix.Form`.

**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](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema documentation.

### New Tables

1. **`membership_fee_types`**
   - Purpose: Define membership fee types with fixed intervals
   - Key Constraint: `interval` field immutable after creation
   - Relationships: has_many members, has_many membership_fee_cycles

2. **`membership_fee_cycles`**
   - Purpose: Individual membership fee cycles for members
   - Key Design: NO `cycle_end` or `interval_type` fields (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 start
- `exit_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:**

1. Member membership fee type assigned (via Ash change)
2. Member created with membership fee type (via Ash change)
3. Scheduled job runs (daily/weekly cron)
4. Admin manual regeneration (UI action)

**Algorithm Steps:**

1. Retrieve member with membership fee type and dates
2. Determine 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 current membership fee type's amount
5. 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 start
- `calculate_cycle_end/2` - Given cycle_start and interval, calculate end
- `next_cycle_start/2` - Given cycle_start and interval, find next
- `is_current_cycle?/2` - Check if cycle contains today
- `is_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 :paid
- `mark_as_suspended` - Set status to :suspended  
- `mark_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:**

1. Keep all existing cycles unchanged
2. Find future unpaid cycles
3. Delete future unpaid cycles
4. 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:**

1. Add fields via migration
2. Add relationships (belongs_to, has_many)
3. Add calculations (current_cycle_status, overdue_count)
4. 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

**Status:** ✅ Implemented. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns.

**PermissionSets (lib/mv/authorization/permission_sets.ex):**

- **MembershipFeeType:** All permission sets can read (:all); only admin has create/update/destroy (:all).
- **MembershipFeeCycle:** All can read (:all); read_only has read only; normal_user and admin have read + create + update + destroy (:all).
- **Manual "Regenerate Cycles" (UI + server):** The "Regenerate Cycles" button in the member detail view is shown to users who have MembershipFeeCycle create permission (normal_user and admin). UI access is gated by `can_create_cycle`. The LiveView handler also enforces `can?(:create, MembershipFeeCycle)` server-side before running regeneration (so e.g. a read_only user cannot trigger it via DevTools). Regeneration runs with system actor.

**Resource Policies:**

- **MembershipFeeType** (`lib/membership_fees/membership_fee_type.ex`): `authorizers: [Ash.Policy.Authorizer]`, single policy with `HasPermission` for read/create/update/destroy.
- **MembershipFeeCycle** (`lib/membership_fees/membership_fee_cycle.ex`): Same pattern; update includes mark_as_paid, mark_as_suspended, mark_as_unpaid.

### LiveView Integration

**New LiveViews Required:**

1. MembershipFeeType index/form (admin)
2. MembershipFeeCycle table component (member detail view)
   - Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent`
   - Displays all cycles in a table with status management
   - Allows changing cycle status, editing amounts, and manually regenerating cycles (normal_user and admin)
3. Settings form section (admin)
4. 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?/3` helper 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:**

1. Database constraints (NOT NULL, UNIQUE, CHECK)
2. Ash validations (business rules)
3. 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 lookups
- `membership_fee_cycles(membership_fee_type_id)` - For type queries
- `membership_fee_cycles(status)` - For unpaid filters
- `membership_fee_cycles(cycle_start)` - For date range queries
- `membership_fee_cycles(member_id, cycle_start)` - Composite unique index
- `members(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**
