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