mitgliederverwaltung/docs/membership-fee-architecture.md

11 KiB

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 (business logic, worked examples, UI mockups), database-schema-readme.md, 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 and 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 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.