# Membership Fees - Technical Architecture

**Feature:** Membership Fee Management — **Status:** Implemented

Architectural decisions, patterns, module structure, and integration points (no concrete implementation details).

**Related:** [membership-fee-overview.md](./membership-fee-overview.md) (business logic, worked examples, UI mockups), [database-schema-readme.md](./database-schema-readme.md), [database_schema.dbml](./database_schema.dbml).

---

## Core Design Decisions

1. **No redundant fields:**
   - No `cycle_end` field — calculated from `cycle_start` + `interval`.
   - No `interval_type` field — read from `membership_fee_type.interval`.
   - Eliminates data inconsistencies.
2. **Interval immutability:** `membership_fee_type.interval` cannot be changed after creation (enforced via an Ash validation in `Mv.MembershipFees.MembershipFeeType`, and the attribute is omitted from the update action's `accept` list). Prevents complex migration scenarios.
3. **Historical accuracy:** `amount` stored per cycle for audit trail — old cycles retain their original amount, so membership-fee changes over time stay traceable.
4. **Calendar-based cycles:** all cycles aligned to calendar boundaries; simplifies date math and makes generation predictable.
5. **Single responsibility:** cycle generation, status management, and calendar logic live in separate modules.

---

## Domain Structure

### Ash Domain: `Mv.MembershipFees`

Encapsulates all membership-fee resources and logic.

**Resources:**

- `MembershipFeeType` — membership fee type definitions (admin-managed).
- `MembershipFeeCycle` — individual membership fee cycles per member.

**Public API** (code interface): `create/list/update/destroy_membership_fee_type`, `create/list/update/destroy_membership_fee_cycle`.

**Note:** LiveViews use direct `Ash.read/create/update/destroy` with `domain: Mv.MembershipFees` instead of the code interface — acceptable for LiveView forms using `AshPhoenix.Form`.

The Member resource is extended with membership fee fields.

### Module Map

```
lib/
├── membership_fees/
│   ├── membership_fees.ex              # Ash domain definition
│   ├── membership_fee_type.ex          # MembershipFeeType resource
│   ├── membership_fee_cycle.ex         # MembershipFeeCycle resource
│   └── changes/
│       ├── 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
│       ├── cycle_generation_job.ex        # Scheduled cycle generation job
│       └── calendar_cycles.ex             # Calendar cycle calculations
└── membership/
    └── member.ex                    # Extended with membership fee relationships
```

### Separation of Concerns

- **Domain layer (Ash resources):** data validation, relationships, policy enforcement, action definitions.
- **Business logic (`Mv.MembershipFees`):** cycle generation, calendar calculations, date boundaries, status transitions.
- **UI layer (LiveView):** interaction, display, authorization checks, form handling.

---

## Data Architecture

See [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema.

### New Tables

1. **`membership_fee_types`** — fee types with fixed `interval` (immutable after creation). has_many members, has_many membership_fee_cycles.
2. **`membership_fee_cycles`** — per-member cycles. NO `cycle_end`/`interval_type` (calculated). belongs_to member, belongs_to membership_fee_type. Composite uniqueness: one cycle per member per `cycle_start`.

### Member Table Extensions

- `membership_fee_type_id` (FK, nullable — default applied from settings at the app level)
- `membership_fee_start_date` (Date, nullable)

**Existing fields used:** `join_date` (computes membership fee start), `exit_date` (limits cycle generation). These 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). Read during cycle generation and member creation; written only via admin UI. Validation: default fee type must exist.

### Foreign Key Behaviors

| Relationship | On Delete | Rationale |
|--------------|-----------|-----------|
| `membership_fee_cycles.member_id → members.id` | CASCADE | Remove cycles when member deleted |
| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent fee type deletion if cycles exist |
| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent fee type deletion if assigned to members |

---

## Business Logic Architecture

### Cycle Generation — `Mv.MembershipFees.CycleGenerator`

Calculates which cycles should exist for a member, generates the missing ones (idempotent — skips existing), respects `membership_fee_start_date` and `exit_date` boundaries, and uses **PostgreSQL advisory locks per member** to prevent race conditions.

**Triggers:** fee type assigned (Ash change); member created with fee type (Ash change); scheduled job (daily/weekly cron); admin manual regeneration (UI).

**Algorithm:**

1. Retrieve member with fee type and dates.
2. Determine generation start point:
   - No cycles exist → start from `membership_fee_start_date` (or calculated from `join_date`).
   - Cycles exist → start from the cycle AFTER the last existing one.
3. Generate all cycle starts from that point to today (or `exit_date`).
4. Create new cycles with the current fee type's amount.

**Edge cases:**

- `membership_fee_start_date` NULL → calculate from `join_date` + global setting.
- `exit_date` set → stop generation at `exit_date`.
- 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 Cycles — `Mv.MembershipFees.CalendarCycles`

Calculates cycle boundaries by interval, the current cycle, the last completed cycle, and `cycle_end` from `cycle_start` + interval.

**Functions (high-level):** `calculate_cycle_start/2,3`, `calculate_cycle_end/2`, `next_cycle_start/2`, `current_cycle?/2,3`, `last_completed_cycle?/2,3`.

**Interval logic:**

- **Monthly:** 1st of month → last day of month.
- **Quarterly:** 1st of quarter (Jan/Apr/Jul/Oct) → last day of quarter.
- **Half-yearly:** 1st of half (Jan/Jul) → last day of half.
- **Yearly:** Jan 1 → Dec 31.

### Status Management — Ash actions on `MembershipFeeCycle`

Simple state machine unpaid ↔ paid ↔ suspended; all transitions allowed; permissions checked via Ash policies. Actions: `mark_as_paid`, `mark_as_suspended`, `mark_as_unpaid` (error correction). `bulk_mark_as_paid` is low priority / future.

### Membership Fee Type Change — Ash change on `Member.membership_fee_type_id`

**Validation:** new type must have the same interval as the old type; different interval is rejected (MVP constraint).

**Side effects on allowed change:** keep all existing cycles; find future unpaid cycles, delete them, regenerate with the new `membership_fee_type_id` and amount.

**Implementation pattern:**

- Ash change module validates; `after_action` hook triggers regeneration synchronously.
- **Regeneration runs in the same transaction as the member update** to ensure atomicity. CycleGenerator uses advisory locks and transactions internally to prevent races.

**Validation behavior:**

- **Fail-closed:** if fee types cannot be loaded during validation, the change is rejected with a validation error.
- **Nil prevention:** setting `membership_fee_type_id` to nil is rejected when a current type exists.

---

## Integration Points

### Member Resource

Extension points: fields via migration; relationships (belongs_to, has_many); calculations (current_cycle_status, overdue_count); changes (auto-set `membership_fee_start_date`, validate interval). Backward compatible — new fields nullable/defaulted; existing members get the default fee type from settings.

### Settings System

Store two global settings, admin UI to modify, default values if unset, validation (default fee type must exist).

### Permission System — Implemented

See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full matrix and policy patterns.

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

- **MembershipFeeType:** all sets read (:all); only admin has create/update/destroy (:all).
- **MembershipFeeCycle:** all 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 with 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 the 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:** MembershipFeeType index/form (admin); MembershipFeeCycle table component in the member detail view — implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent` (displays all cycles with status management, amount editing, and manual regeneration for normal_user and admin); Settings form section (admin); member-list status column.

**Extended:** member detail view (membership fees section), member list view (status column), settings page (membership fees section). Use the existing `can?/3` helper for UI conditionals.

---

## Performance Notes

**Indexes:** `membership_fee_cycles` on `member_id`, `membership_fee_type_id`, `status`, `cycle_start`, composite unique `(member_id, cycle_start)`; `members(membership_fee_type_id)`.

**Query:** preload fee type with cycles to avoid N+1; `cycle_end` and `current_cycle_status` are Ash calculations (lazy, not stored); paginate cycle lists > 50.

**No caching in MVP** (fee types rarely change, queries fast). Scheduled generation job: run daily/weekly, batch members, skip unchanged, log failures for retry.

---

## Future Enhancements

- **Phase 2 — Interval change support:** cycle-overlap logic, prorata, more validation, migration path for existing cycles.
- **Phase 3 — Payment details:** `PaymentTransaction` resource linked to cycles, multiple payments per cycle, reconciliation.
- **Phase 4 — vereinfacht.digital integration:** external API client, webhook handling, automatic matching, manual review.
