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
- No redundant fields:
- No
cycle_endfield — calculated fromcycle_start+interval. - No
interval_typefield — read frommembership_fee_type.interval. - Eliminates data inconsistencies.
- No
- Interval immutability:
membership_fee_type.intervalcannot be changed after creation (enforced via an Ash validation inMv.MembershipFees.MembershipFeeType, and the attribute is omitted from the update action'sacceptlist). Prevents complex migration scenarios. - Historical accuracy:
amountstored per cycle for audit trail — old cycles retain their original amount, so membership-fee changes over time stay traceable. - Calendar-based cycles: all cycles aligned to calendar boundaries; simplifies date math and makes generation predictable.
- 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
membership_fee_types— fee types with fixedinterval(immutable after creation). has_many members, has_many membership_fee_cycles.membership_fee_cycles— per-member cycles. NOcycle_end/interval_type(calculated). belongs_to member, belongs_to membership_fee_type. Composite uniqueness: one cycle per member percycle_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:
- Retrieve member with fee type and dates.
- Determine generation start point:
- No cycles exist → start from
membership_fee_start_date(or calculated fromjoin_date). - Cycles exist → start from the cycle AFTER the last existing one.
- No cycles exist → start from
- Generate all cycle starts from that point to today (or
exit_date). - Create new cycles with the current fee type's amount.
Edge cases:
membership_fee_start_dateNULL → calculate fromjoin_date+ global setting.exit_dateset → stop generation atexit_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_actionhook 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_idto 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 enforcescan?(: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 withHasPermissionfor 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:
PaymentTransactionresource linked to cycles, multiple payments per cycle, reconciliation. - Phase 4 — vereinfacht.digital integration: external API client, webhook handling, automatic matching, manual review.