mitgliederverwaltung/docs/membership-fee-architecture.md

205 lines
11 KiB
Markdown

# 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.