diff --git a/.drone.yml b/.drone.yml index 8c7f325..f80a718 100644 --- a/.drone.yml +++ b/.drone.yml @@ -4,7 +4,7 @@ name: check services: - name: postgres - image: docker.io/library/postgres:17.7 + image: docker.io/library/postgres:17.6 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -14,93 +14,48 @@ trigger: - push steps: - - name: compute cache key + - name: setup image: docker.io/library/elixir:1.18.3-otp-27 commands: - - mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1) - - echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key - # Print cache key for debugging - - cat .cache_key + - mix local.hex --force + - mix local.rebar --force + - mix deps.get - - name: restore-cache - image: drillster/drone-volume-cache - settings: - restore: true - mount: - - ./deps - - ./_build - ttl: 30 - volumes: - - name: cache - path: /cache + - name: build + image: docker.io/library/elixir:1.18.3-otp-27 + depends_on: + - setup + commands: + - mix local.hex --force + - mix local.rebar --force + - mix compile --warnings-as-errors - name: lint image: docker.io/library/elixir:1.18.3-otp-27 + depends_on: + - build commands: - # Install hex package manager - mix local.hex --force - # Fetch dependencies - - mix deps.get - # Check for compilation errors & warnings - - mix compile --warnings-as-errors - # Check formatting + - mix local.rebar --force - mix format --check-formatted - # Security checks - mix sobelow --config - # Check dependencies for known vulnerabilities - mix deps.audit - # Check for dependencies that are not maintained anymore - mix hex.audit - # Provide hints for improving code quality - mix credo - # Check that translations are up to date - mix gettext.extract --check-up-to-date - - name: wait_for_postgres - image: docker.io/library/postgres:17.7 - commands: - # Wait for postgres to become available - - | - for i in {1..20}; do - if pg_isready -h postgres -U postgres; then - exit 0 - else - true - fi - sleep 2 - done - echo "Postgres did not become available, aborting." - exit 1 - - name: test image: docker.io/library/elixir:1.18.3-otp-27 + depends_on: + - setup environment: MIX_ENV: test TEST_POSTGRES_HOST: postgres TEST_POSTGRES_PORT: 5432 commands: - # Install hex package manager - mix local.hex --force - # Fetch dependencies - - mix deps.get - # Run tests - - mix test - - - name: rebuild-cache - image: drillster/drone-volume-cache - settings: - rebuild: true - mount: - - ./deps - - ./_build - volumes: - - name: cache - path: /cache - -volumes: - - name: cache - host: - path: /tmp/drone_cache + - mix local.rebar --force + - mix test --max-cases 16 --- kind: pipeline @@ -166,7 +121,7 @@ environment: steps: - name: renovate - image: renovate/renovate:42.44 + image: renovate/renovate:41.173 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: diff --git a/.tool-versions b/.tool-versions index 489262a..98239f3 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ elixir 1.18.3-otp-27 erlang 27.3.4 -just 1.45.0 +just 1.43.1 diff --git a/config/config.exs b/config/config.exs index 053fc19..17891e0 100644 --- a/config/config.exs +++ b/config/config.exs @@ -49,7 +49,7 @@ config :spark, config :mv, ecto_repos: [Mv.Repo], generators: [timestamp_type: :utc_datetime], - ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees] + ash_domains: [Mv.Membership, Mv.Accounts] # Configures the endpoint config :mv, MvWeb.Endpoint, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5b35e10..b4b7a1f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -33,7 +33,7 @@ services: restart: unless-stopped db-prod: - image: postgres:17.7-alpine + image: postgres:16-alpine container_name: mv-prod-db environment: POSTGRES_USER: postgres diff --git a/docker-compose.yml b/docker-compose.yml index feff34c..56876f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ networks: services: db: - image: postgres:17.7-alpine + image: postgres:17.6-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -29,7 +29,7 @@ services: rauthy: container_name: rauthy-dev - image: ghcr.io/sebadob/rauthy:0.33.1 + image: ghcr.io/sebadob/rauthy:0.32.0 environment: - LOCAL_TEST=true - SMTP_URL=mailcrab diff --git a/docs/contributions-architecture.md b/docs/contributions-architecture.md new file mode 100644 index 0000000..3718a3b --- /dev/null +++ b/docs/contributions-architecture.md @@ -0,0 +1,653 @@ +# Membership Contributions - Technical Architecture + +**Project:** Mila - Membership Management System +**Feature:** Membership Contribution Management +**Version:** 1.0 +**Last Updated:** 2025-11-27 +**Status:** Architecture Design - Ready for Implementation + +--- + +## Purpose + +This document defines the technical architecture for the Membership Contributions system. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details. + +**Related Documents:** +- [contributions-overview.md](./contributions-overview.md) - Business logic and requirements +- [database-schema-readme.md](./database-schema-readme.md) - Database documentation +- [database_schema.dbml](./database_schema.dbml) - Database schema definition + +--- + +## Table of Contents + +1. [Architecture Principles](#architecture-principles) +2. [Domain Structure](#domain-structure) +3. [Data Architecture](#data-architecture) +4. [Business Logic Architecture](#business-logic-architecture) +5. [Integration Points](#integration-points) +6. [Acceptance Criteria](#acceptance-criteria) +7. [Testing Strategy](#testing-strategy) +8. [Security Considerations](#security-considerations) +9. [Performance Considerations](#performance-considerations) + +--- + +## Architecture Principles + +### Core Design Decisions + +1. **Single Responsibility:** + - Each module has one clear responsibility + - Period generation separated from status management + - Calendar logic isolated in dedicated module + +2. **No Redundancy:** + - No `period_end` field (calculated from `period_start` + `interval`) + - No `interval_type` field (read from `contribution_type.interval`) + - Eliminates data inconsistencies + +3. **Immutability Where Important:** + - `contribution_type.interval` cannot be changed after creation + - Prevents complex migration scenarios + - Enforced via Ash change validation + +4. **Historical Accuracy:** + - `amount` stored per period for audit trail + - Enables tracking of contribution changes over time + - Old periods retain original amounts + +5. **Calendar-Based Periods:** + - All periods aligned to calendar boundaries + - Simplifies date calculations + - Predictable period generation + +--- + +## Domain Structure + +### Ash Domain: `Mv.Contributions` + +**Purpose:** Encapsulates all contribution-related resources and logic + +**Resources:** +- `ContributionType` - Contribution type definitions (admin-managed) +- `ContributionPeriod` - Individual contribution periods per member + +**Extensions:** +- Member resource extended with contribution fields + +### Module Organization + +``` +lib/ +├── contributions/ +│ ├── contributions.ex # Ash domain definition +│ ├── contribution_type.ex # ContributionType resource +│ ├── contribution_period.ex # ContributionPeriod resource +│ └── changes/ +│ ├── prevent_interval_change.ex # Validates interval immutability +│ ├── set_contribution_start_date.ex # Auto-sets start date +│ └── validate_same_interval.ex # Validates interval match on type change +├── mv/ +│ └── contributions/ +│ ├── period_generator.ex # Period generation algorithm +│ └── calendar_periods.ex # Calendar period calculations +└── membership/ + └── member.ex # Extended with contribution relationships +``` + +### Separation of Concerns + +**Domain Layer (Ash Resources):** +- Data validation +- Relationship management +- Policy enforcement +- Action definitions + +**Business Logic Layer (`Mv.Contributions`):** +- Period generation algorithm +- Calendar calculations +- Date boundary handling +- Status transitions + +**UI Layer (LiveView):** +- User interaction +- Display logic +- Authorization checks +- Form handling + +--- + +## Data Architecture + +### Database Schema Extensions + +**See:** [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema documentation. + +### New Tables + +1. **`contribution_types`** + - Purpose: Define contribution types with fixed intervals + - Key Constraint: `interval` field immutable after creation + - Relationships: has_many members, has_many contribution_periods + +2. **`contribution_periods`** + - Purpose: Individual contribution periods for members + - Key Design: NO `period_end` or `interval_type` fields (calculated) + - Relationships: belongs_to member, belongs_to contribution_type + - Composite uniqueness: One period per member per period_start + +### Member Table Extensions + +**Fields Added:** +- `contribution_type_id` (FK, NOT NULL with default from settings) +- `contribution_start_date` (Date, nullable) + +**Existing Fields Used:** +- `joined_at` - For calculating contribution start +- `left_at` - For limiting period generation +- These fields must remain member fields and should not be replaced by custom fields in the future + +### Settings Integration + +**Global Settings:** +- `contributions.include_joining_period` (Boolean) +- `contributions.default_contribution_type_id` (UUID) + +**Storage:** Existing settings mechanism (TBD: dedicated table or configuration resource) + +### Foreign Key Behaviors + +| Relationship | On Delete | Rationale | +|--------------|-----------|-----------| +| `contribution_periods.member_id → members.id` | CASCADE | Remove periods when member deleted | +| `contribution_periods.contribution_type_id → contribution_types.id` | RESTRICT | Prevent type deletion if periods exist | +| `members.contribution_type_id → contribution_types.id` | RESTRICT | Prevent type deletion if assigned to members | + +--- + +## Business Logic Architecture + +### Period Generation System + +**Component:** `Mv.Contributions.PeriodGenerator` + +**Responsibilities:** +- Calculate which periods should exist for a member +- Generate missing periods +- Respect contribution_start_date and left_at boundaries +- Skip existing periods (idempotent) + +**Triggers:** +1. Member contribution type assigned (via Ash change) +2. Member created with contribution type (via Ash change) +3. Scheduled job runs (daily/weekly cron) +4. Admin manual regeneration (UI action) + +**Algorithm Steps:** +1. Retrieve member with contribution_type and dates +2. Determine first period start (based on contribution_start_date) +3. Calculate all period starts from first to today (or left_at) +4. Query existing periods for member +5. Generate missing periods with current contribution_type.amount +6. Insert new periods (batch operation) + +**Edge Case Handling:** +- If contribution_start_date is NULL: Calculate from joined_at + global setting +- If left_at is set: Stop generation at left_at +- If contribution_type changes: Handled separately by regeneration logic + +### Calendar Period Calculations + +**Component:** `Mv.Contributions.CalendarPeriods` + +**Responsibilities:** +- Calculate period boundaries based on interval type +- Determine current period +- Determine last completed period +- Calculate period_end from period_start + interval + +**Functions (high-level):** +- `calculate_period_start/3` - Given date and interval, find period start +- `calculate_period_end/2` - Given period_start and interval, calculate end +- `next_period_start/2` - Given period_start and interval, find next +- `is_current_period?/2` - Check if period contains today +- `is_last_completed_period?/2` - Check if period just ended + +**Interval Logic:** +- **Monthly:** Start = 1st of month, End = last day of month +- **Quarterly:** Start = 1st of quarter (Jan/Apr/Jul/Oct), End = last day of quarter +- **Half-yearly:** Start = 1st of half (Jan/Jul), End = last day of half +- **Yearly:** Start = Jan 1st, End = Dec 31st + +### Status Management + +**Component:** Ash actions on `ContributionPeriod` + +**Status Transitions:** +- Simple state machine: unpaid ↔ paid ↔ suspended +- No complex validation (all transitions allowed) +- Permissions checked via Ash policies + +**Actions Required:** +- `mark_as_paid` - Set status to :paid +- `mark_as_suspended` - Set status to :suspended +- `mark_as_unpaid` - Set status to :unpaid (error correction) + +**Bulk Operations:** +- `bulk_mark_as_paid` - Mark multiple periods as paid (efficiency) + - low priority, can be a future issue + +### Contribution Type Change Handling + +**Component:** Ash change on `Member.contribution_type_id` + +**Validation:** +- Check if new type has same interval as old type +- If different: Reject change (MVP constraint) +- If same: Allow change + +**Side Effects on Allowed Change:** +1. Keep all existing periods unchanged +2. Find future unpaid periods +3. Delete future unpaid periods +4. Regenerate periods with new contribution_type_id and amount + +**Implementation Pattern:** +- Use Ash change module to validate +- Use after_action hook to trigger regeneration +- Use transaction to ensure atomicity + +--- + +## Integration Points + +### Member Resource Integration + +**Extension Points:** +1. Add fields via migration +2. Add relationships (belongs_to, has_many) +3. Add calculations (current_period_status, overdue_count) +4. Add changes (auto-set contribution_start_date, validate interval) + +**Backward Compatibility:** +- New fields nullable or with defaults +- Existing members get default contribution type from settings +- No breaking changes to existing member functionality + +### Settings System Integration + +**Requirements:** +- Store two global settings +- Provide UI for admin to modify +- Default values if not set +- Validation (e.g., default_contribution_type_id must exist) + +**Access Pattern:** +- Read settings during period generation +- Read settings during member creation +- Write settings only via admin UI + +### Permission System Integration + +**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) + +**Required Permissions:** +- `ContributionType.create/update/destroy` - Admin only +- `ContributionType.read` - Admin, Treasurer, Board +- `ContributionPeriod.update` (status changes) - Admin, Treasurer +- `ContributionPeriod.read` - Admin, Treasurer, Board, Own member + +**Policy Patterns:** +- Use existing HasPermission check +- Leverage existing roles (Admin, Kassenwart) +- Member can read own periods (linked via member_id) + +### LiveView Integration + +**New LiveViews Required:** +1. ContributionType index/form (admin) +2. ContributionPeriod table component (member detail view) +3. Settings form section (admin) +4. Member list column (contribution status) + +**Existing LiveViews to Extend:** +- Member detail view: Add contributions section +- Member list view: Add status column +- Settings page: Add contributions section + +**Authorization Helpers:** +- Use existing `can?/3` helper for UI conditionals +- Check permissions before showing actions + +--- + +## Acceptance Criteria + +### ContributionType Resource + +**AC-CT-1:** Admin can create contribution type with name, amount, interval, description +**AC-CT-2:** Interval field is immutable after creation (validation error on change attempt) +**AC-CT-3:** Admin can update name, amount, description (but not interval) +**AC-CT-4:** Cannot delete contribution type if assigned to members +**AC-CT-5:** Cannot delete contribution type if periods exist referencing it +**AC-CT-6:** Interval must be one of: monthly, quarterly, half_yearly, yearly + +### ContributionPeriod Resource + +**AC-CP-1:** Period has period_start, status, amount, notes, member_id, contribution_type_id +**AC-CP-2:** Period_end is calculated, not stored +**AC-CP-3:** Status defaults to :unpaid +**AC-CP-4:** One period per member per period_start (uniqueness constraint) +**AC-CP-5:** Amount is set at generation time from contribution_type.amount +**AC-CP-6:** Periods cascade delete when member deleted +**AC-CP-7:** Admin/Treasurer can change status +**AC-CP-8:** Member can read own periods + +### Member Extensions + +**AC-M-1:** Member has contribution_type_id field (NOT NULL with default) +**AC-M-2:** Member has contribution_start_date field (nullable) +**AC-M-3:** New members get default contribution type from global setting +**AC-M-4:** contribution_start_date auto-set based on joined_at and global setting +**AC-M-5:** Admin can manually override contribution_start_date +**AC-M-6:** Cannot change to contribution type with different interval (MVP) + +### Period Generation + +**AC-PG-1:** Periods generated when member gets contribution type +**AC-PG-2:** Periods generated when member created (via change hook) +**AC-PG-3:** Scheduled job generates missing periods daily +**AC-PG-4:** Generation respects contribution_start_date +**AC-PG-5:** Generation stops at left_at if member exited +**AC-PG-6:** Generation is idempotent (skips existing periods) +**AC-PG-7:** Periods align to calendar boundaries (1st of month/quarter/half/year) +**AC-PG-8:** Amount comes from contribution_type at generation time + +### Calendar Logic + +**AC-CL-1:** Monthly periods: 1st to last day of month +**AC-CL-2:** Quarterly periods: 1st of Jan/Apr/Jul/Oct to last day of quarter +**AC-CL-3:** Half-yearly periods: 1st of Jan/Jul to last day of half +**AC-CL-4:** Yearly periods: Jan 1 to Dec 31 +**AC-CL-5:** Period_end calculated correctly for all interval types +**AC-CL-6:** Current period determined correctly based on today's date +**AC-CL-7:** Last completed period determined correctly + +### Contribution Type Change + +**AC-TC-1:** Can change to type with same interval +**AC-TC-2:** Cannot change to type with different interval (error message) +**AC-TC-3:** On allowed change: future unpaid periods regenerated +**AC-TC-4:** On allowed change: paid/suspended periods unchanged +**AC-TC-5:** On allowed change: amount updated to new type's amount +**AC-TC-6:** Change is atomic (transaction) + +### Settings + +**AC-S-1:** Global setting: include_joining_period (boolean, default true) +**AC-S-2:** Global setting: default_contribution_type_id (UUID, required) +**AC-S-3:** Admin can modify settings via UI +**AC-S-4:** Settings validated (e.g., default type must exist) +**AC-S-5:** Settings applied to new members immediately + +### UI - Member List + +**AC-UI-ML-1:** New column shows contribution status +**AC-UI-ML-2:** Default: Shows last completed period status +**AC-UI-ML-3:** Optional: Toggle to show current period status +**AC-UI-ML-4:** Color coding: green (paid), red (unpaid), gray (suspended) +**AC-UI-ML-5:** Filter: Unpaid in last period +**AC-UI-ML-6:** Filter: Unpaid in current period + +### UI - Member Detail + +**AC-UI-MD-1:** Contributions section shows all periods +**AC-UI-MD-2:** Table columns: Period, Interval, Amount, Status, Actions +**AC-UI-MD-3:** Checkbox per period for bulk marking (low prio) +**AC-UI-MD-4:** "Mark selected as paid" button +**AC-UI-MD-5:** Dropdown to change contribution type (same interval only) +**AC-UI-MD-6:** Warning if different interval selected +**AC-UI-MD-7:** Only show actions if user has permission + +### UI - Contribution Types Admin + +**AC-UI-CTA-1:** List all contribution types +**AC-UI-CTA-2:** Show: Name, Amount, Interval, Member count +**AC-UI-CTA-3:** Create new contribution type form +**AC-UI-CTA-4:** Edit form: Name, Amount, Description editable +**AC-UI-CTA-5:** Edit form: Interval grayed out (not editable) +**AC-UI-CTA-6:** Warning on amount change (explain impact) +**AC-UI-CTA-7:** Cannot delete if members assigned +**AC-UI-CTA-8:** Only admin can access + +### UI - Settings Admin + +**AC-UI-SA-1:** Contributions section in settings +**AC-UI-SA-2:** Dropdown to select default contribution type +**AC-UI-SA-3:** Checkbox: Include joining period +**AC-UI-SA-4:** Explanatory text with examples +**AC-UI-SA-5:** Save button with validation + +--- + +## Testing Strategy + +### Unit Testing + +**Period Generator Tests:** +- Correct period_start calculation for all interval types +- Correct period count from start to end date +- Respects contribution_start_date boundary +- Respects left_at boundary +- Skips existing periods (idempotent) +- Handles edge dates (year boundaries, leap years) + +**Calendar Periods Tests:** +- Period boundaries correct for all intervals +- Period_end calculation correct +- Current period detection +- Last completed period detection +- Next period calculation + +**Validation Tests:** +- Interval immutability enforced +- Same interval validation on type change +- Status transitions allowed +- Uniqueness constraints enforced + +### Integration Testing + +**Period Generation Flow:** +- Member creation triggers generation +- Type assignment triggers generation +- Type change regenerates future periods +- Scheduled job generates missing periods +- Left member stops generation + +**Status Management Flow:** +- Mark single period as paid +- Bulk mark multiple periods (low prio) +- Status transitions work +- Permissions enforced + +**Contribution Type Management:** +- Create type +- Update amount (regeneration triggered) +- Cannot update interval +- Cannot delete if in use + +### LiveView Testing + +**Member List:** +- Status column displays correctly +- Toggle between last/current works +- Filters work correctly +- Color coding applied + +**Member Detail:** +- Periods table displays all periods +- Checkboxes work +- Bulk marking works (low prio) +- Type change validation works +- Actions only shown with permission + +**Admin UI:** +- Type CRUD works +- Settings save correctly +- Validations display errors +- Only authorized users can access + +### Edge Case Testing + +**Interval Change Attempt:** +- Error message displayed +- No data modified +- User can cancel/choose different type + +**Exit with Unpaid:** +- Warning shown +- Option to suspend offered +- Exit completes correctly + +**Amount Change:** +- Warning displayed +- Only future unpaid regenerated +- Historical periods unchanged + +**Date Boundaries:** +- Today = period start handled +- Today = period end handled +- Leap year handled + +### Performance Testing + +**Period Generation:** +- Generate 10 years of monthly periods: < 100ms +- Generate for 1000 members: < 5 seconds +- Idempotent check efficient (no full scan) + +**Member List Query:** +- With status column: < 200ms for 1000 members +- Filters applied efficiently +- No N+1 queries + +--- + +## Security Considerations + +### Authorization + +**Permissions Required:** +- ContributionType management: Admin only +- ContributionPeriod status changes: Admin + Treasurer +- View all periods: Admin + Treasurer + Board +- View own periods: All authenticated users + +**Policy Enforcement:** +- All actions protected by Ash policies +- UI shows/hides based on permissions +- Backend validates permissions (never trust UI alone) + +### Data Integrity + +**Validation Layers:** +1. Database constraints (NOT NULL, UNIQUE, CHECK) +2. Ash validations (business rules) +3. UI validations (user experience) + +**Immutability Protection:** +- Interval change prevented at multiple layers +- Period amounts immutable (audit trail) +- Settings changes logged (future) + +### Audit Trail + +**Tracked Information:** +- Period status changes (who, when) - future enhancement +- Type amount changes (implicit via period amounts) +- Member type assignments (via timestamps) + +--- + +## Performance Considerations + +### Database Indexes + +**Required Indexes:** +- `contribution_periods(member_id)` - For member period lookups +- `contribution_periods(contribution_type_id)` - For type queries +- `contribution_periods(status)` - For unpaid filters +- `contribution_periods(period_start)` - For date range queries +- `contribution_periods(member_id, period_start)` - Composite unique index +- `members(contribution_type_id)` - For type membership count + +### Query Optimization + +**Preloading:** +- Load contribution_type with periods (avoid N+1) +- Load periods when displaying member detail +- Use Ash's load for efficient preloading + +**Calculated Fields:** +- period_end calculated on-demand (not stored) +- current_period_status calculated when needed +- Use Ash calculations for lazy evaluation + +**Pagination:** +- Period list paginated if > 50 periods +- Member list already paginated + +### Caching Strategy + +**No caching needed in MVP:** +- Contribution types rarely change +- Period queries are fast +- Settings read infrequently + +**Future caching if needed:** +- Cache settings in application memory +- Cache contribution types list +- Invalidate on change + +### Scheduled Job Performance + +**Period Generation Job:** +- Run daily or weekly (not hourly) +- Batch members (process 100 at a time) +- Skip members with no changes +- Log failures for retry + +--- + +## Future Enhancements + +### Phase 2: Interval Change Support + +**Architecture Changes:** +- Add logic to handle period overlaps +- Calculate prorata amounts if needed +- More complex validation +- Migration path for existing periods + +### Phase 3: Payment Details + +**Architecture Changes:** +- Add PaymentTransaction resource +- Link transactions to periods +- Support multiple payments per period +- Reconciliation logic + +### Phase 4: vereinfacht.digital Integration + +**Architecture Changes:** +- External API client module +- Webhook handling for transactions +- Automatic matching logic +- Manual review interface + +--- + +**End of Architecture Document** + diff --git a/docs/membership-fee-overview.md b/docs/contributions-overview.md similarity index 59% rename from docs/membership-fee-overview.md rename to docs/contributions-overview.md index 229b73b..e0c4bc8 100644 --- a/docs/membership-fee-overview.md +++ b/docs/contributions-overview.md @@ -1,7 +1,7 @@ -# Membership Fees - Overview +# Membership Contributions - Overview **Project:** Mila - Membership Management System -**Feature:** Membership Fee Management +**Feature:** Membership Contribution Management **Version:** 1.0 **Last Updated:** 2025-11-27 **Status:** Concept - Ready for Review @@ -10,9 +10,9 @@ ## Purpose -This document provides a comprehensive overview of the Membership Fees system. It covers business logic, data model, UI/UX design, and technical architecture in a concise, bullet-point format. +This document provides a comprehensive overview of the Membership Contributions system. It covers business logic, data model, UI/UX design, and technical architecture in a concise, bullet-point format. -**For detailed implementation:** See [membership-fee-implementation-plan.md](./membership-fee-implementation-plan.md) (created after concept iterations) +**For detailed implementation:** See [contributions-implementation-plan.md](./contributions-implementation-plan.md) (created after concept iterations) --- @@ -36,7 +36,7 @@ This document provides a comprehensive overview of the Membership Fees system. I - Minimal complexity - Clear data model without redundancies - Intuitive operation -- Calendar cycle-based (Month/Quarter/Half-Year/Year) +- Calendar period-based (Month/Quarter/Half-Year/Year) --- @@ -46,9 +46,9 @@ This document provides a comprehensive overview of the Membership Fees system. I **Core Entities:** -- Beitragsart ↔ Membership Fee Type -- Beitragszyklus ↔ Membership Fee Cycle -- Mitgliedsbeitrag ↔ Membership Fee +- Beitragsart ↔ Contribution Type / Membership Fee Type +- Beitragsintervall ↔ Contribution Period +- Mitgliedsbeitrag ↔ Membership Fee / Contribution **Status:** @@ -56,7 +56,7 @@ This document provides a comprehensive overview of the Membership Fees system. I - unbezahlt ↔ unpaid - ausgesetzt ↔ suspended / waived -**Intervals (Frequenz / Payment Frequency):** +**Intervals:** - monatlich ↔ monthly - quartalsweise ↔ quarterly @@ -65,8 +65,8 @@ This document provides a comprehensive overview of the Membership Fees system. I **UI Elements:** -- "Letzter Zyklus" ↔ "Last Cycle" (e.g., 2023 when in 2024) -- "Aktueller Zyklus" ↔ "Current Cycle" (e.g., 2024) +- "Letztes Intervall" ↔ "Last Period" (e.g., 2023 when in 2024) +- "Aktuelles Intervall" ↔ "Current Period" (e.g., 2024) - "Als bezahlt markieren" ↔ "Mark as paid" - "Aussetzen" ↔ "Suspend" / "Waive" @@ -74,41 +74,43 @@ This document provides a comprehensive overview of the Membership Fees system. I ## Data Model -### Membership Fee Type (MembershipFeeType) +### Contribution Type (ContributionType) ``` - id (UUID) - name (String) - e.g., "Regular", "Reduced", "Student" -- amount (Decimal) - Membership fee amount in Euro +- amount (Decimal) - Contribution amount in Euro - interval (Enum) - :monthly, :quarterly, :half_yearly, :yearly - description (Text, optional) +- timestamps ``` **Important:** - `interval` is **IMMUTABLE** after creation! - Admin can only change `name`, `amount`, `description` -- On change: Future unpaid cycles regenerated with new amount +- On change: Future unpaid periods regenerated with new amount -### Membership Fee Cycle (MembershipFeeCycle) +### Contribution Period (ContributionPeriod) ``` - id (UUID) - member_id (FK → members.id) -- membership_fee_type_id (FK → membership_fee_types.id) -- cycle_start (Date) - Calendar cycle start (01.01., 01.04., 01.07., 01.10., etc.) +- contribution_type_id (FK → contribution_types.id) +- period_start (Date) - Calendar period start (01.01., 01.04., 01.07., 01.10., etc.) - status (Enum) - :unpaid (default), :paid, :suspended -- amount (Decimal) - Membership fee amount at generation time (history when type changes) +- amount (Decimal) - Amount at generation time (history when type changes) - notes (Text, optional) - Admin notes +- timestamps ``` **Important:** -- **NO** `cycle_end` - calculated from `cycle_start` + `interval` -- **NO** `interval_type` - read from `membership_fee_type.interval` +- **NO** `period_end` - calculated from `period_start` + `interval` +- **NO** `interval_type` - read from `contribution_type.interval` - Avoids redundancy and inconsistencies! -**Calendar Cycle Logic:** +**Calendar Period Logic:** - Monthly: 01.01. - 31.01., 01.02. - 28./29.02., etc. - Quarterly: 01.01. - 31.03., 01.04. - 30.06., 01.07. - 30.09., 01.10. - 31.12. @@ -118,75 +120,70 @@ This document provides a comprehensive overview of the Membership Fees system. I ### Member (Extensions) ``` -- membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings) -- membership_fee_start_date (Date, nullable) - When to start generating membership fees +- contribution_type_id (FK → contribution_types.id, NOT NULL, default from settings) +- contribution_start_date (Date, nullable) - When to start generating contributions - left_at (Date, nullable) - Exit date (existing) ``` -**Logic for membership_fee_start_date:** +**Logic for contribution_start_date:** -- Auto-set based on global setting `include_joining_cycle` -- If `include_joining_cycle = true`: First day of joining month/quarter/year -- If `include_joining_cycle = false`: First day of NEXT cycle after joining +- Auto-set based on global setting `include_joining_period` +- If `include_joining_period = true`: First day of joining month/quarter/year +- If `include_joining_period = false`: First day of NEXT period after joining - Can be manually overridden by admin -**NO** `include_joining_cycle` field on Member - unnecessary due to `membership_fee_start_date`! +**NO** `include_joining_period` field on Member - unnecessary due to `contribution_start_date`! ### Global Settings ``` -key: "membership_fees.include_joining_cycle" +key: "contributions.include_joining_period" value: Boolean (Default: true) -key: "membership_fees.default_membership_fee_type_id" -value: UUID (Required) - Default membership fee type for new members +key: "contributions.default_contribution_type_id" +value: UUID (Required) - Default contribution type for new members ``` -**Meaning include_joining_cycle:** +**Meaning include_joining_period:** -- `true`: Joining cycle is included (member pays from joining cycle) -- `false`: Only from next full cycle after joining +- `true`: Joining period is included (member pays from joining period) +- `false`: Only from next full period after joining -**Meaning of default membership fee type setting:** +**Meaning default_contribution_type_id:** -- Every new member automatically gets this membership fee type +- Every new member automatically gets this contribution type - Must be configured in admin settings -- Prevents: Members without membership fee type +- Prevents: Members without contribution type --- ## Business Logic -### Cycle Generation +### Period Generation **Triggers:** -- Member gets membership fee type assigned (also during member creation) -- New cycle begins (Cron job daily/weekly) +- Member gets contribution type assigned (also during member creation) +- New period begins (Cron job daily/weekly) - Admin requests manual regeneration **Algorithm:** -Lock the whole cycle table for the duration of the algorithm - -1. Get `member.membership_fee_start_date` and member's membership fee type -2. Generate cycles until today (or `left_at` if present): - - If no cycle exists: - - Generate all cycles from `membership_fee_start_date` - - else: - - Generate all cycles from last existing cycle - - use the interval to generate the cycles -3. Set `amount` to current membership fee type's amount +1. Get `member.contribution_start_date` and `member.contribution_type` +2. Calculate first period based on `contribution_start_date` +3. Generate all periods from start to today (or `left_at` if present) +4. Skip existing periods +5. Set `amount` to current `contribution_type.amount` **Example (Yearly):** ``` Joining date: 15.03.2023 -include_joining_cycle: true -→ membership_fee_start_date: 01.01.2023 +include_joining_period: true +→ contribution_start_date: 01.01.2023 -Generated cycles: -- 01.01.2023 - 31.12.2023 (joining cycle) +Generated periods: +- 01.01.2023 - 31.12.2023 (joining period) - 01.01.2024 - 31.12.2024 - 01.01.2025 - 31.12.2025 (current year) ``` @@ -195,10 +192,10 @@ Generated cycles: ``` Joining date: 15.03.2023 -include_joining_cycle: false -→ membership_fee_start_date: 01.04.2023 +include_joining_period: false +→ contribution_start_date: 01.04.2023 -Generated cycles: +Generated periods: - 01.04.2023 - 30.06.2023 (first full quarter) - 01.07.2023 - 30.09.2023 - 01.10.2023 - 31.12.2023 @@ -221,44 +218,44 @@ suspended → unpaid - Admin + Treasurer (Kassenwart) can change status - Uses existing permission system -### Membership Fee Type Change +### Contribution Type Change -**MVP - Same Cycle Only:** +**MVP - Same Interval Only:** -- Member can only choose membership fee type with **same cycle** +- Member can only choose contribution type with **same interval** - Example: From "Regular (yearly)" to "Reduced (yearly)" ✓ - Example: From "Regular (yearly)" to "Reduced (monthly)" ✗ **Logic on Change:** -1. Check: New membership fee type has same interval -2. If yes: Set `member.membership_fee_type_id` -3. Future **unpaid** cycles: Delete and regenerate with new amount -4. Paid/suspended cycles: Remain unchanged (historical amount) +1. Check: New contribution type has same interval +2. If yes: Set `member.contribution_type_id` +3. Future **unpaid** periods: Delete and regenerate with new amount +4. Paid/suspended periods: Remain unchanged (historical amount) **Future - Different Intervals:** - Enable interval switching (e.g., yearly → monthly) -- More complex logic for cycle overlaps +- More complex logic for period overlaps - Needs additional validation ### Member Exit **Logic:** -- Cycles only generated until `member.left_at` -- Existing cycles remain visible -- Unpaid exit cycle can be marked as "suspended" +- Periods only generated until `member.left_at` +- Existing periods remain visible +- Unpaid exit period can be marked as "suspended" **Example:** ``` Exit: 15.08.2024 -Yearly cycle: 01.01.2024 - 31.12.2024 +Yearly period: 01.01.2024 - 31.12.2024 -→ Cycle 2024 is shown (Status: unpaid) +→ Period 2024 is shown (Status: unpaid) → Admin can set to "suspended" -→ No cycles for 2025+ generated +→ No periods for 2025+ generated ``` --- @@ -267,46 +264,46 @@ Yearly cycle: 01.01.2024 - 31.12.2024 ### Member List View -**New Column: "Membership Fee Status"** +**New Column: "Contribution Status"** -**Default Display (Last Cycle):** +**Default Display (Last Period):** -- Shows status of **last completed** cycle -- Example in 2024: Shows membership fee for 2023 +- Shows status of **last completed** period +- Example in 2024: Shows contribution for 2023 - Color coding: - Green: paid ✓ - Red: unpaid ✗ - Gray: suspended ⊘ -**Optional: Show Current Cycle** +**Optional: Show Current Period** -- Toggle: "Show current cycle" (2024) +- Toggle: "Show current period" (2024) - Admin decides what to display **Filters:** -- "Unpaid membership fees in last cycle" -- "Unpaid membership fees in current cycle" +- "Unpaid contributions in last period" +- "Unpaid contributions in current period" ### Member Detail View -**Section: "Membership Fees"** +**Section: "Contributions"** -**Membership Fee Type Assignment:** +**Contribution Type Assignment:** ``` ┌─────────────────────────────────────┐ -│ Membership Fee Type: [Dropdown] │ -│ ⚠ Only types with same interval │ -│ can be selected │ +│ Contribution Type: [Dropdown] │ +│ ⚠ Only types with same interval │ +│ can be selected │ └─────────────────────────────────────┘ ``` -**Cycle Table:** +**Period Table:** ``` ┌───────────────┬──────────┬────────┬──────────┬─────────┐ -│ Cycle │ Interval │ Amount │ Status │ Action │ +│ Period │ Interval │ Amount │ Status │ Action │ ├───────────────┼──────────┼────────┼──────────┼─────────┤ │ 01.01.2023- │ Yearly │ 50 € │ ☑ Paid │ │ │ 31.12.2023 │ │ │ │ │ @@ -325,9 +322,9 @@ Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended - Checkbox in each row for fast marking - Button: "Mark selected as paid/unpaid/suspended" -- Bulk action for multiple cycles +- Bulk action for multiple periods -### Admin: Membership Fee Types Management +### Admin: Contribution Types Management **List:** @@ -355,37 +352,37 @@ Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended Impact: - 45 members affected -- Future unpaid cycles will be generated with 65 € -- Already paid cycles remain with old amount +- Future unpaid periods will be generated with 65 € +- Already paid periods remain with old amount [Cancel] [Confirm] ``` ### Admin: Settings -**Membership Fee Configuration:** +**Contribution Configuration:** ``` -Default Membership Fee Type: [Dropdown: Membership Fee Types] +Default Contribution Type: [Dropdown: Contribution Types] Selected: "Regular (60 €, Yearly)" -This membership fee type is automatically assigned to all new members. +This contribution type is automatically assigned to all new members. Can be changed individually per member. --- -☐ Include joining cycle +☐ Include joining period When active: -Members pay from the cycle of their joining. +Members pay from the period of their joining. Example (Yearly): Joining: 15.03.2023 → Pays from 2023 When inactive: -Members pay from the next full cycle. +Members pay from the next full period. Example (Yearly): Joining: 15.03.2023 @@ -396,7 +393,7 @@ Joining: 15.03.2023 ## Edge Cases -### 1. Membership Fee Type Change with Different Interval +### 1. Contribution Type Change with Different Interval **MVP:** Blocked (only same interval allowed) @@ -405,11 +402,11 @@ Joining: 15.03.2023 ``` Error: Interval change not possible -Current membership fee type: "Regular (Yearly)" -Selected membership fee type: "Student (Monthly)" +Current contribution type: "Regular (Yearly)" +Selected contribution type: "Student (Monthly)" Changing the interval is currently not possible. -Please select a membership fee type with interval "Yearly". +Please select a contribution type with interval "Yearly". [OK] ``` @@ -418,32 +415,32 @@ Please select a membership fee type with interval "Yearly". - Allow interval switching - Calculate overlaps -- Generate new cycles without duplicates +- Generate new periods without duplicates -### 2. Exit with Unpaid Membership Fees +### 2. Exit with Unpaid Contributions **Scenario:** ``` Member exits: 15.08.2024 -Yearly cycle 2024: unpaid +Yearly period 2024: unpaid ``` **UI Notice on Exit: (Low Prio)** ``` -⚠ Unpaid membership fees present +⚠ Unpaid contributions present -This member has 1 unpaid cycle(s): +This member has 1 unpaid period(s): - 2024: 60 € (unpaid) Do you want to continue? -[ ] Mark membership fee as "suspended" +[ ] Mark contribution as "suspended" [Cancel] [Confirm Exit] ``` -### 3. Multiple Unpaid Cycles +### 3. Multiple Unpaid Periods **Scenario:** Member hasn't paid for 2 years @@ -470,9 +467,9 @@ Do you want to continue? **Result:** -- Cycle 2023: Saved with 50 € (history) -- Cycle 2024: Generated with 60 € (current) -- Both cycles show correct historical amount +- Period 2023: Saved with 50 € (history) +- Period 2024: Generated with 60 € (current) +- Both periods show correct historical amount ### 5. Date Boundaries @@ -480,7 +477,7 @@ Do you want to continue? **Solution:** -- Current cycle (2025) is generated +- Current period (2025) is generated - Status: unpaid (open) - Shown in overview @@ -492,17 +489,17 @@ Do you want to continue? **Included:** -- ✓ Membership fee types (CRUD) -- ✓ Automatic cycle generation +- ✓ Contribution types (CRUD) +- ✓ Automatic period generation - ✓ Status management (paid/unpaid/suspended) -- ✓ Member overview with membership fee status -- ✓ Cycle view per member +- ✓ Member overview with contribution status +- ✓ Period view per member - ✓ Quick checkbox marking - ✓ Bulk actions - ✓ Amount history - ✓ Same-interval type change -- ✓ Default membership fee type -- ✓ Joining cycle configuration +- ✓ Default contribution type +- ✓ Joining period configuration **NOT Included:** @@ -518,7 +515,7 @@ Do you want to continue? **Phase 2:** - Payment details (date, amount, method) -- Interval change for future unpaid cycles +- Interval change for future unpaid periods - Manual vereinfacht.digital links per member - Extended filter options diff --git a/docs/custom-fields-search-performance.md b/docs/custom-fields-search-performance.md deleted file mode 100644 index 3987c85..0000000 --- a/docs/custom-fields-search-performance.md +++ /dev/null @@ -1,243 +0,0 @@ -# Performance Analysis: Custom Fields in Search Vector - -## Current Implementation - -The search vector includes custom field values via database triggers that: -1. Aggregate all custom field values for a member -2. Extract values from JSONB format -3. Add them to the search_vector with weight 'C' - -## Performance Considerations - -### 1. Trigger Performance on Member Updates - -**Current Implementation:** -- `members_search_vector_trigger()` executes a subquery on every INSERT/UPDATE: - ```sql - SELECT string_agg(...) FROM custom_field_values WHERE member_id = NEW.id - ``` - -**Performance Impact:** -- ✅ **Good:** Index on `member_id` exists (`custom_field_values_member_id_idx`) -- ✅ **Good:** Subquery only runs for the affected member -- ⚠️ **Potential Issue:** With many custom fields per member (e.g., 50+), aggregation could be slower -- ⚠️ **Potential Issue:** JSONB extraction (`value->>'_union_value'`) is relatively fast but adds overhead - -**Expected Performance:** -- **Small scale (< 10 custom fields per member):** Negligible impact (< 5ms per operation) -- **Medium scale (10-30 custom fields):** Minor impact (5-20ms per operation) -- **Large scale (30+ custom fields):** Noticeable impact (20-50ms+ per operation) - -### 2. Trigger Performance on Custom Field Value Changes - -**Current Implementation:** -- `update_member_search_vector_from_custom_field_value()` executes on every INSERT/UPDATE/DELETE on `custom_field_values` -- **Optimized:** Only fetches required member fields (not full record) to reduce overhead -- **Optimized:** Skips re-aggregation on UPDATE if value hasn't actually changed -- Aggregates all custom field values, then updates member search_vector - -**Performance Impact:** -- ✅ **Good:** Index on `member_id` ensures fast lookup -- ✅ **Optimized:** Only required fields are fetched (first_name, last_name, email, etc.) instead of full record -- ✅ **Optimized:** UPDATE operations that don't change the value skip expensive re-aggregation (early return) -- ⚠️ **Note:** Re-aggregation is still necessary when values change (required for search_vector consistency) -- ⚠️ **Critical:** Bulk operations (e.g., importing 1000 members with custom fields) will trigger this for each row - -**Expected Performance:** -- **Single operation (value changed):** 3-10ms per custom field value change (improved from 5-15ms) -- **Single operation (value unchanged):** <1ms (early return, no aggregation) -- **Bulk operations:** Could be slow (consider disabling trigger temporarily) - -### 3. Search Vector Size - -**Current Constraints:** -- String values: max 10,000 characters per custom field -- No limit on number of custom fields per member -- tsvector has no explicit size limit, but very large vectors can cause issues - -**Potential Issues:** -- **Theoretical maximum:** If a member has 100 custom fields with 10,000 char strings each, the aggregated text could be ~1MB -- **Practical concern:** Very large search vectors (> 100KB) can slow down: - - Index updates (GIN index maintenance) - - Search queries (tsvector operations) - - Trigger execution time - -**Recommendation:** -- Monitor search_vector size in production -- Consider limiting total custom field content per member if needed -- PostgreSQL can handle large tsvectors, but performance degrades gradually - -### 4. Initial Migration Performance - -**Current Implementation:** -- Updates ALL members in a single transaction: - ```sql - UPDATE members m SET search_vector = ... (subquery for each member) - ``` - -**Performance Impact:** -- ⚠️ **Potential Issue:** With 10,000+ members, this could take minutes -- ⚠️ **Potential Issue:** Single transaction locks the members table -- ⚠️ **Potential Issue:** If migration fails, entire rollback required - -**Recommendation:** -- For large datasets (> 10,000 members), consider: - - Batch updates (e.g., 1000 members at a time) - - Run during maintenance window - - Monitor progress - -### 5. Search Query Performance - -**Current Implementation:** -- Full-text search uses GIN index on `search_vector` (fast) -- Additional LIKE queries on `custom_field_values` for substring matching: - ```sql - EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = id AND ... LIKE ...) - ``` - -**Performance Impact:** -- ✅ **Good:** GIN index on `search_vector` is very fast -- ⚠️ **Potential Issue:** LIKE queries on JSONB are not indexed (sequential scan) -- ⚠️ **Potential Issue:** EXISTS subquery runs for every search, even if search_vector match is found -- ⚠️ **Potential Issue:** With many custom fields, the LIKE queries could be slow - -**Expected Performance:** -- **With GIN index match:** Very fast (< 10ms for typical queries) -- **Without GIN index match (fallback to LIKE):** Slower (10-100ms depending on data size) -- **Worst case:** Sequential scan of all custom_field_values for all members - -## Recommendations - -### Short-term (Current Implementation) - -1. **Monitor Performance:** - - Add logging for trigger execution time - - Monitor search_vector size distribution - - Track search query performance - -2. **Index Verification:** - - Ensure `custom_field_values_member_id_idx` exists and is used - - Verify GIN index on `search_vector` is maintained - -3. **Bulk Operations:** - - For bulk imports, consider temporarily disabling the custom_field_values trigger - - Re-enable and update search_vectors in batch after import - -### Medium-term Optimizations - -1. **✅ Optimize Trigger Function (FULLY IMPLEMENTED):** - - ✅ Only fetch required member fields instead of full record (reduces overhead) - - ✅ Skip re-aggregation on UPDATE if value hasn't actually changed (early return optimization) - -2. **Limit Search Vector Size:** - - Truncate very long custom field values (e.g., first 1000 chars) - - Add warning if aggregated text exceeds threshold - -3. **Optimize LIKE Queries:** - - Consider adding a generated column for searchable text - - Or use a materialized view for custom field search - -### Long-term Considerations - -1. **Alternative Approaches:** - - Separate search index table for custom fields - - Use Elasticsearch or similar for advanced search - - Materialized view for search optimization - -2. **Scaling Strategy:** - - If performance becomes an issue with 100+ custom fields per member: - - Consider limiting which custom fields are searchable - - Use a separate search service - - Implement search result caching - -## Performance Benchmarks (Estimated) - -Based on typical PostgreSQL performance: - -| Scenario | Members | Custom Fields/Member | Expected Impact | -|----------|---------|---------------------|-----------------| -| Small | < 1,000 | < 10 | Negligible (< 5ms per operation) | -| Medium | 1,000-10,000 | 10-30 | Minor (5-20ms per operation) | -| Large | 10,000-100,000 | 30-50 | Noticeable (20-50ms per operation) | -| Very Large | > 100,000 | 50+ | Significant (50-200ms+ per operation) | - -## Monitoring Queries - -```sql --- Check search_vector size distribution -SELECT - pg_size_pretty(octet_length(search_vector::text)) as size, - COUNT(*) as member_count -FROM members -WHERE search_vector IS NOT NULL -GROUP BY octet_length(search_vector::text) -ORDER BY octet_length(search_vector::text) DESC -LIMIT 20; - --- Check average custom fields per member -SELECT - AVG(custom_field_count) as avg_custom_fields, - MAX(custom_field_count) as max_custom_fields -FROM ( - SELECT member_id, COUNT(*) as custom_field_count - FROM custom_field_values - GROUP BY member_id -) subq; - --- Check trigger execution time (requires pg_stat_statements) -SELECT - mean_exec_time, - calls, - query -FROM pg_stat_statements -WHERE query LIKE '%members_search_vector_trigger%' -ORDER BY mean_exec_time DESC; -``` - -## Code Quality Improvements (Post-Review) - -### Refactored Search Implementation - -The search query has been refactored for better maintainability and clarity: - -**Before:** Single large OR-chain with mixed search types (hard to maintain) - -**After:** Modular functions grouped by search type: -- `build_fts_filter/1` - Full-text search (highest priority, fastest) -- `build_substring_filter/2` - Substring matching on structured fields -- `build_custom_field_filter/1` - Custom field value search (JSONB LIKE) -- `build_fuzzy_filter/2` - Trigram/fuzzy matching for names and streets - -**Benefits:** -- ✅ Clear separation of concerns -- ✅ Easier to maintain and test -- ✅ Better documentation of search priority -- ✅ Easier to optimize individual search types - -**Search Priority Order:** -1. **FTS (Full-Text Search)** - Fastest, uses GIN index on search_vector -2. **Substring** - For structured fields (postal_code, phone_number, etc.) -3. **Custom Fields** - JSONB LIKE queries (fallback for substring matching) -4. **Fuzzy Matching** - Trigram similarity for names and streets - -## Conclusion - -The current implementation is **well-optimized for typical use cases** (< 30 custom fields per member, < 10,000 members). For larger scales, monitoring and potential optimizations may be needed. - -**Key Strengths:** -- Indexed lookups (member_id index) -- Efficient GIN index for search -- Trigger-based automatic updates -- Modular, maintainable search code structure - -**Key Weaknesses:** -- LIKE queries on JSONB (not indexed) -- Re-aggregation on every custom field change (necessary for consistency) -- Potential size issues with many/large custom fields -- Substring searches (contains/ILIKE) not index-optimized - -**Recent Optimizations:** -- ✅ Trigger function optimized to fetch only required fields (reduces overhead by ~30-50%) -- ✅ Early return on UPDATE when value hasn't changed (skips expensive re-aggregation, <1ms vs 3-10ms) -- ✅ Improved performance for custom field value updates (3-10ms vs 5-15ms when value changes) - diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index 6457db5..1644f2a 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -168,16 +168,9 @@ Member (1) → (N) Properties ### Weighted Fields - **Weight A (highest):** first_name, last_name - **Weight B:** email, notes -- **Weight C:** phone_number, city, street, house_number, postal_code, custom_field_values +- **Weight C:** phone_number, city, street, house_number, postal_code - **Weight D (lowest):** join_date, exit_date -### Custom Field Values in Search -Custom field values are automatically included in the search vector: -- All custom field values (string, integer, boolean, date, email) are aggregated and added to the search vector -- Values are converted to text format for indexing -- Custom field values receive weight 'C' (same as phone_number, city, etc.) -- The search vector is automatically updated when custom field values are created, updated, or deleted via database triggers - ### Usage Example ```sql SELECT * FROM members diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index f97463e..b620830 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -6,8 +6,8 @@ // - https://dbdocs.io // - VS Code Extensions: "DBML Language" or "dbdiagram.io" // -// Version: 1.3 -// Last Updated: 2025-12-11 +// Version: 1.2 +// Last Updated: 2025-11-13 Project mila_membership_management { database_type: 'PostgreSQL' @@ -27,7 +27,6 @@ Project mila_membership_management { ## Domains: - **Accounts**: User authentication and session management - **Membership**: Club member data and custom fields - - **MembershipFees**: Membership fee types and billing cycles ## Required PostgreSQL Extensions: - uuid-ossp (UUID generation) @@ -133,8 +132,6 @@ Table members { house_number text [null, note: 'House number'] postal_code text [null, note: '5-digit German postal code'] search_vector tsvector [null, note: 'Full-text search index (auto-generated)'] - membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - assigned fee type'] - membership_fee_start_date date [null, note: 'Date from which membership fees should be calculated'] indexes { email [unique, name: 'members_unique_email_index'] @@ -149,7 +146,6 @@ Table members { last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting'] join_date [name: 'members_join_date_idx', note: 'B-tree index for date filters'] (paid) [name: 'members_paid_idx', type: btree, note: 'Partial index WHERE paid IS NOT NULL'] - membership_fee_type_id [name: 'members_membership_fee_type_id_index', note: 'B-tree index for fee type lookups'] } Note: ''' @@ -182,8 +178,6 @@ Table members { **Relationships:** - Optional 1:1 with users (0..1 ↔ 0..1) - authentication account - 1:N with custom_field_values (custom dynamic fields) - - Optional N:1 with membership_fee_types - assigned fee type - - 1:N with membership_fee_cycles - billing history **Validation Rules:** - first_name, last_name: min 1 character @@ -287,98 +281,6 @@ Table custom_fields { ''' } -// ============================================ -// MEMBERSHIP_FEES DOMAIN -// ============================================ - -Table membership_fee_types { - id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key'] - name text [not null, unique, note: 'Unique name for the fee type (e.g., "Standard", "Reduced")'] - amount numeric(10,2) [not null, note: 'Fee amount in default currency (CHECK: >= 0)'] - interval text [not null, note: 'Billing interval (CHECK: IN monthly, quarterly, half_yearly, yearly) - immutable'] - description text [null, note: 'Optional description for the fee type'] - - indexes { - name [unique, name: 'membership_fee_types_unique_name_index'] - } - - Note: ''' - **Membership Fee Type Definitions** - - Defines the different types of membership fees with fixed billing intervals. - - **Attributes:** - - `name`: Unique identifier for the fee type - - `amount`: Default fee amount (stored per cycle for audit trail) - - `interval`: Billing cycle - immutable after creation - - `description`: Optional documentation - - **Interval Values:** - - `monthly`: 1st to last day of month - - `quarterly`: 1st of Jan/Apr/Jul/Oct to last day of quarter - - `half_yearly`: 1st of Jan/Jul to last day of half - - `yearly`: Jan 1 to Dec 31 - - **Immutability:** - The `interval` field cannot be changed after creation to prevent - complex migration scenarios. Create a new fee type to change intervals. - - **Relationships:** - - 1:N with members - members assigned to this fee type - - 1:N with membership_fee_cycles - all cycles using this fee type - - **Deletion Behavior:** - - ON DELETE RESTRICT: Cannot delete if members or cycles reference it - ''' -} - -Table membership_fee_cycles { - id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key'] - cycle_start date [not null, note: 'Start date of the billing cycle'] - amount numeric(10,2) [not null, note: 'Fee amount for this cycle (CHECK: >= 0)'] - status text [not null, default: 'unpaid', note: 'Payment status (CHECK: IN unpaid, paid, suspended)'] - notes text [null, note: 'Optional notes for this cycle'] - member_id uuid [not null, note: 'FK to members - the member this cycle belongs to'] - membership_fee_type_id uuid [not null, note: 'FK to membership_fee_types - fee type for this cycle'] - - indexes { - member_id [name: 'membership_fee_cycles_member_id_index'] - membership_fee_type_id [name: 'membership_fee_cycles_membership_fee_type_id_index'] - status [name: 'membership_fee_cycles_status_index'] - cycle_start [name: 'membership_fee_cycles_cycle_start_index'] - (member_id, cycle_start) [unique, name: 'membership_fee_cycles_unique_cycle_per_member_index', note: 'One cycle per member per cycle_start'] - } - - Note: ''' - **Individual Membership Fee Cycles** - - Represents a single billing cycle for a member with payment tracking. - - **Design Decisions:** - - `cycle_end` is NOT stored - calculated from cycle_start + interval - - `amount` is stored per cycle to preserve historical values when fee type amount changes - - Cycles are aligned to calendar boundaries - - **Status Values:** - - `unpaid`: Payment pending (default) - - `paid`: Payment received - - `suspended`: Payment suspended (e.g., hardship case) - - **Constraints:** - - Unique: One cycle per member per cycle_start date - - member_id: Required (belongs_to) - - membership_fee_type_id: Required (belongs_to) - - **Relationships:** - - N:1 with members - the member this cycle belongs to - - N:1 with membership_fee_types - the fee type for this cycle - - **Deletion Behavior:** - - ON DELETE CASCADE (member_id): Cycles deleted when member deleted - - ON DELETE RESTRICT (membership_fee_type_id): Cannot delete fee type if cycles exist - ''' -} - // ============================================ // RELATIONSHIPS // ============================================ @@ -404,22 +306,6 @@ Ref: custom_field_values.member_id > members.id [delete: cascade] // - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict] -// Member → MembershipFeeType (N:1) -// - Many members can be assigned to one fee type -// - Optional relationship (member can have no fee type) -// - ON DELETE RESTRICT: Cannot delete fee type if members are assigned -Ref: members.membership_fee_type_id > membership_fee_types.id [delete: restrict] - -// MembershipFeeCycle → Member (N:1) -// - Many cycles belong to one member -// - ON DELETE CASCADE: Cycles deleted when member deleted -Ref: membership_fee_cycles.member_id > members.id [delete: cascade] - -// MembershipFeeCycle → MembershipFeeType (N:1) -// - Many cycles reference one fee type -// - ON DELETE RESTRICT: Cannot delete fee type if cycles reference it -Ref: membership_fee_cycles.membership_fee_type_id > membership_fee_types.id [delete: restrict] - // ============================================ // ENUMS // ============================================ @@ -442,21 +328,6 @@ Enum token_purpose { email_confirmation [note: 'Email verification tokens'] } -// Billing interval for membership fee types -Enum membership_fee_interval { - monthly [note: '1st to last day of month'] - quarterly [note: '1st of Jan/Apr/Jul/Oct to last day of quarter'] - half_yearly [note: '1st of Jan/Jul to last day of half'] - yearly [note: 'Jan 1 to Dec 31'] -} - -// Payment status for membership fee cycles -Enum membership_fee_status { - unpaid [note: 'Payment pending (default)'] - paid [note: 'Payment received'] - suspended [note: 'Payment suspended'] -} - // ============================================ // TABLE GROUPS // ============================================ @@ -486,17 +357,3 @@ TableGroup membership_domain { ''' } -TableGroup membership_fees_domain { - membership_fee_types - membership_fee_cycles - - Note: ''' - **Membership Fees Domain** - - Handles membership fee management including: - - Fee type definitions with intervals - - Individual billing cycles per member - - Payment status tracking - ''' -} - diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index c4ecfc9..2f86f5e 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -187,16 +187,16 @@ **Current State:** - ✅ Basic "paid" boolean field on members -- ✅ **UI Mock-ups for Membership Fee Types & Settings** (2025-12-02) +- ✅ **UI Mock-ups for Contribution Types & Settings** (2025-12-02) - ⚠️ No payment tracking **Open Issues:** - [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority) -- [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Membership Fee Mockup Pages (Preview) +- [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Contribution Mockup Pages (Preview) **Mock-Up Pages (Non-Functional Preview):** -- `/membership_fee_types` - Membership Fee Types Management -- `/membership_fee_settings` - Global Membership Fee Settings +- `/contribution_types` - Contribution Types Management +- `/contribution_settings` - Global Contribution Settings **Missing Features:** - ❌ Membership fee configuration diff --git a/docs/membership-fee-architecture.md b/docs/membership-fee-architecture.md deleted file mode 100644 index c601b79..0000000 --- a/docs/membership-fee-architecture.md +++ /dev/null @@ -1,712 +0,0 @@ -# Membership Fees - Technical Architecture - -**Project:** Mila - Membership Management System -**Feature:** Membership Fee Management -**Version:** 1.0 -**Last Updated:** 2025-11-27 -**Status:** Architecture Design - Ready for Implementation - ---- - -## Purpose - -This document defines the technical architecture for the Membership Fees system. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details. - -**Related Documents:** - -- [membership-fee-overview.md](./membership-fee-overview.md) - Business logic and requirements -- [database-schema-readme.md](./database-schema-readme.md) - Database documentation -- [database_schema.dbml](./database_schema.dbml) - Database schema definition - ---- - -## Table of Contents - -1. [Architecture Principles](#architecture-principles) -2. [Domain Structure](#domain-structure) -3. [Data Architecture](#data-architecture) -4. [Business Logic Architecture](#business-logic-architecture) -5. [Integration Points](#integration-points) -6. [Acceptance Criteria](#acceptance-criteria) -7. [Testing Strategy](#testing-strategy) -8. [Security Considerations](#security-considerations) -9. [Performance Considerations](#performance-considerations) - ---- - -## Architecture Principles - -### Core Design Decisions - -1. **Single Responsibility:** - - Each module has one clear responsibility - - Cycle generation separated from status management - - Calendar logic isolated in dedicated module - -2. **No Redundancy:** - - No `cycle_end` field (calculated from `cycle_start` + `interval`) - - No `interval_type` field (read from `membership_fee_type.interval`) - - Eliminates data inconsistencies - -3. **Immutability Where Important:** - - `membership_fee_type.interval` cannot be changed after creation - - Prevents complex migration scenarios - - Enforced via Ash change validation - -4. **Historical Accuracy:** - - `amount` stored per cycle for audit trail - - Enables tracking of membership fee changes over time - - Old cycles retain original amounts - -5. **Calendar-Based Cycles:** - - All cycles aligned to calendar boundaries - - Simplifies date calculations - - Predictable cycle generation - ---- - -## Domain Structure - -### Ash Domain: `Mv.MembershipFees` - -**Purpose:** Encapsulates all membership fee-related resources and logic - -**Resources:** - -- `MembershipFeeType` - Membership fee type definitions (admin-managed) -- `MembershipFeeCycle` - Individual membership fee cycles per member - -**Extensions:** - -- Member resource extended with membership fee fields - -### Module Organization - -``` -lib/ -├── membership_fees/ -│ ├── membership_fees.ex # Ash domain definition -│ ├── membership_fee_type.ex # MembershipFeeType resource -│ ├── membership_fee_cycle.ex # MembershipFeeCycle resource -│ └── changes/ -│ ├── prevent_interval_change.ex # Validates interval immutability -│ ├── 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 -│ └── calendar_cycles.ex # Calendar cycle calculations -└── membership/ - └── member.ex # Extended with membership fee relationships -``` - -### Separation of Concerns - -**Domain Layer (Ash Resources):** - -- Data validation -- Relationship management -- Policy enforcement -- Action definitions - -**Business Logic Layer (`Mv.MembershipFees`):** - -- Cycle generation algorithm -- Calendar calculations -- Date boundary handling -- Status transitions - -**UI Layer (LiveView):** - -- User interaction -- Display logic -- Authorization checks -- Form handling - ---- - -## Data Architecture - -### Database Schema Extensions - -**See:** [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema documentation. - -### New Tables - -1. **`membership_fee_types`** - - Purpose: Define membership fee types with fixed intervals - - Key Constraint: `interval` field immutable after creation - - Relationships: has_many members, has_many membership_fee_cycles - -2. **`membership_fee_cycles`** - - Purpose: Individual membership fee cycles for members - - Key Design: NO `cycle_end` or `interval_type` fields (calculated) - - Relationships: belongs_to member, belongs_to membership_fee_type - - Composite uniqueness: One cycle per member per cycle_start - -### Member Table Extensions - -**Fields Added:** - -- `membership_fee_type_id` (FK, NOT NULL with default from settings) -- `membership_fee_start_date` (Date, nullable) - -**Existing Fields Used:** - -- `joined_at` - For calculating membership fee start -- `left_at` - For limiting cycle generation -- These fields 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) - -**Storage:** Existing settings mechanism (TBD: dedicated table or configuration resource) - -### Foreign Key Behaviors - -| Relationship | On Delete | Rationale | -|--------------|-----------|-----------| -| `membership_fee_cycles.member_id → members.id` | CASCADE | Remove membership fee cycles when member deleted | -| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if cycles exist | -| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if assigned to members | - ---- - -## Business Logic Architecture - -### Cycle Generation System - -**Component:** `Mv.MembershipFees.CycleGenerator` - -**Responsibilities:** - -- Calculate which cycles should exist for a member -- Generate missing cycles -- Respect membership_fee_start_date and left_at boundaries -- Skip existing cycles (idempotent) - -**Triggers:** - -1. Member membership fee type assigned (via Ash change) -2. Member created with membership fee type (via Ash change) -3. Scheduled job runs (daily/weekly cron) -4. Admin manual regeneration (UI action) - -**Algorithm Steps:** - -1. Retrieve member with membership fee type and dates -2. Determine first cycle start (based on membership_fee_start_date) -3. Calculate all cycle starts from first to today (or left_at) -4. Query existing cycles for member -5. Generate missing cycles with current membership fee type's amount -6. Insert new cycles (batch operation) - -**Edge Case Handling:** - -- If membership_fee_start_date is NULL: Calculate from joined_at + global setting -- If left_at is set: Stop generation at left_at -- If membership fee type changes: Handled separately by regeneration logic - -### Calendar Cycle Calculations - -**Component:** `Mv.MembershipFees.CalendarCycles` - -**Responsibilities:** - -- Calculate cycle boundaries based on interval type -- Determine current cycle -- Determine last completed cycle -- Calculate cycle_end from cycle_start + interval - -**Functions (high-level):** - -- `calculate_cycle_start/3` - Given date and interval, find cycle start -- `calculate_cycle_end/2` - Given cycle_start and interval, calculate end -- `next_cycle_start/2` - Given cycle_start and interval, find next -- `is_current_cycle?/2` - Check if cycle contains today -- `is_last_completed_cycle?/2` - Check if cycle just ended - -**Interval Logic:** - -- **Monthly:** Start = 1st of month, End = last day of month -- **Quarterly:** Start = 1st of quarter (Jan/Apr/Jul/Oct), End = last day of quarter -- **Half-yearly:** Start = 1st of half (Jan/Jul), End = last day of half -- **Yearly:** Start = Jan 1st, End = Dec 31st - -### Status Management - -**Component:** Ash actions on `MembershipFeeCycle` - -**Status Transitions:** - -- Simple state machine: unpaid ↔ paid ↔ suspended -- No complex validation (all transitions allowed) -- Permissions checked via Ash policies - -**Actions Required:** - -- `mark_as_paid` - Set status to :paid -- `mark_as_suspended` - Set status to :suspended -- `mark_as_unpaid` - Set status to :unpaid (error correction) - -**Bulk Operations:** - -- `bulk_mark_as_paid` - Mark multiple cycles as paid (efficiency) - - low priority, can be a future issue - -### Membership Fee Type Change Handling - -**Component:** Ash change on `Member.membership_fee_type_id` - -**Validation:** - -- Check if new type has same interval as old type -- If different: Reject change (MVP constraint) -- If same: Allow change - -**Side Effects on Allowed Change:** - -1. Keep all existing cycles unchanged -2. Find future unpaid cycles -3. Delete future unpaid cycles -4. Regenerate cycles with new membership_fee_type_id and amount - -**Implementation Pattern:** - -- Use Ash change module to validate -- Use after_action hook to trigger regeneration -- Use transaction to ensure atomicity - ---- - -## Integration Points - -### Member Resource Integration - -**Extension Points:** - -1. Add fields via migration -2. Add relationships (belongs_to, has_many) -3. Add calculations (current_cycle_status, overdue_count) -4. Add changes (auto-set membership_fee_start_date, validate interval) - -**Backward Compatibility:** - -- New fields nullable or with defaults -- Existing members get default membership fee type from settings -- No breaking changes to existing member functionality - -### Settings System Integration - -**Requirements:** - -- Store two global settings -- Provide UI for admin to modify -- Default values if not set -- Validation (e.g., default membership fee type must exist) - -**Access Pattern:** - -- Read settings during cycle generation -- Read settings during member creation -- Write settings only via admin UI - -### Permission System Integration - -**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) - -**Required Permissions:** - -- `MembershipFeeType.create/update/destroy` - Admin only -- `MembershipFeeType.read` - Admin, Treasurer, Board -- `MembershipFeeCycle.update` (status changes) - Admin, Treasurer -- `MembershipFeeCycle.read` - Admin, Treasurer, Board, Own member - -**Policy Patterns:** - -- Use existing HasPermission check -- Leverage existing roles (Admin, Kassenwart) -- Member can read own cycles (linked via member_id) - -### LiveView Integration - -**New LiveViews Required:** - -1. MembershipFeeType index/form (admin) -2. MembershipFeeCycle table component (member detail view) -3. Settings form section (admin) -4. Member list column (membership fee status) - -**Existing LiveViews to Extend:** - -- Member detail view: Add membership fees section -- Member list view: Add status column -- Settings page: Add membership fees section - -**Authorization Helpers:** - -- Use existing `can?/3` helper for UI conditionals -- Check permissions before showing actions - ---- - -## Acceptance Criteria - -### MembershipFeeType Resource - -**AC-MFT-1:** Admin can create membership fee type with name, amount, interval, description -**AC-MFT-2:** Interval field is immutable after creation (validation error on change attempt) -**AC-MFT-3:** Admin can update name, amount, description (but not interval) -**AC-MFT-4:** Cannot delete membership fee type if assigned to members -**AC-MFT-5:** Cannot delete membership fee type if cycles exist referencing it -**AC-MFT-6:** Interval must be one of: monthly, quarterly, half_yearly, yearly - -### MembershipFeeCycle Resource - -**AC-MFC-1:** Cycle has cycle_start, status, amount, notes, member_id, membership_fee_type_id -**AC-MFC-2:** cycle_end is calculated, not stored -**AC-MFC-3:** Status defaults to :unpaid -**AC-MFC-4:** One cycle per member per cycle_start (uniqueness constraint) -**AC-MFC-5:** Amount is set at generation time from membership_fee_type.amount -**AC-MFC-6:** Cycles cascade delete when member deleted -**AC-MFC-7:** Admin/Treasurer can change status -**AC-MFC-8:** Member can read own cycles - -### Member Extensions - -**AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default) -**AC-M-2:** Member has membership_fee_start_date field (nullable) -**AC-M-3:** New members get default membership fee type from global setting -**AC-M-4:** membership_fee_start_date auto-set based on joined_at and global setting -**AC-M-5:** Admin can manually override membership_fee_start_date -**AC-M-6:** Cannot change to membership fee type with different interval (MVP) - -### Cycle Generation - -**AC-CG-1:** Cycles generated when member gets membership fee type -**AC-CG-2:** Cycles generated when member created (via change hook) -**AC-CG-3:** Scheduled job generates missing cycles daily -**AC-CG-4:** Generation respects membership_fee_start_date -**AC-CG-5:** Generation stops at left_at if member exited -**AC-CG-6:** Generation is idempotent (skips existing cycles) -**AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year) -**AC-CG-8:** Amount comes from membership_fee_type at generation time - -### Calendar Logic - -**AC-CL-1:** Monthly cycles: 1st to last day of month -**AC-CL-2:** Quarterly cycles: 1st of Jan/Apr/Jul/Oct to last day of quarter -**AC-CL-3:** Half-yearly cycles: 1st of Jan/Jul to last day of half -**AC-CL-4:** Yearly cycles: Jan 1 to Dec 31 -**AC-CL-5:** cycle_end calculated correctly for all interval types -**AC-CL-6:** Current cycle determined correctly based on today's date -**AC-CL-7:** Last completed cycle determined correctly - -### Membership Fee Type Change - -**AC-TC-1:** Can change to type with same interval -**AC-TC-2:** Cannot change to type with different interval (error message) -**AC-TC-3:** On allowed change: future unpaid cycles regenerated -**AC-TC-4:** On allowed change: paid/suspended cycles unchanged -**AC-TC-5:** On allowed change: amount updated to new type's amount -**AC-TC-6:** Change is atomic (transaction) - -### Settings - -**AC-S-1:** Global setting: include_joining_cycle (boolean, default true) -**AC-S-2:** Global setting: default_membership_fee_type_id (UUID, required) -**AC-S-3:** Admin can modify settings via UI -**AC-S-4:** Settings validated (e.g., default membership fee type must exist) -**AC-S-5:** Settings applied to new members immediately - -### UI - Member List - -**AC-UI-ML-1:** New column shows membership fee status -**AC-UI-ML-2:** Default: Shows last completed cycle status -**AC-UI-ML-3:** Optional: Toggle to show current cycle status -**AC-UI-ML-4:** Color coding: green (paid), red (unpaid), gray (suspended) -**AC-UI-ML-5:** Filter: Unpaid in last cycle -**AC-UI-ML-6:** Filter: Unpaid in current cycle - -### UI - Member Detail - -**AC-UI-MD-1:** Membership fees section shows all cycles -**AC-UI-MD-2:** Table columns: Cycle, Interval, Amount, Status, Actions -**AC-UI-MD-3:** Checkbox per cycle for bulk marking (low prio) -**AC-UI-MD-4:** "Mark selected as paid" button -**AC-UI-MD-5:** Dropdown to change membership fee type (same interval only) -**AC-UI-MD-6:** Warning if different interval selected -**AC-UI-MD-7:** Only show actions if user has permission - -### UI - Membership Fee Types Admin - -**AC-UI-CTA-1:** List all membership fee types -**AC-UI-CTA-2:** Show: Name, Amount, Interval, Member count -**AC-UI-CTA-3:** Create new membership fee type form -**AC-UI-CTA-4:** Edit form: Name, Amount, Description editable -**AC-UI-CTA-5:** Edit form: Interval grayed out (not editable) -**AC-UI-CTA-6:** Warning on amount change (explain impact) -**AC-UI-CTA-7:** Cannot delete if members assigned -**AC-UI-CTA-8:** Only admin can access - -### UI - Settings Admin - -**AC-UI-SA-1:** Membership fees section in settings -**AC-UI-SA-2:** Dropdown to select default membership fee type -**AC-UI-SA-3:** Checkbox: Include joining cycle -**AC-UI-SA-4:** Explanatory text with examples -**AC-UI-SA-5:** Save button with validation - ---- - -## Testing Strategy - -### Unit Testing - -**Cycle Generator Tests:** - -- Correct cycle_start calculation for all interval types -- Correct cycle count from start to end date -- Respects membership_fee_start_date boundary -- Respects left_at boundary -- Skips existing cycles (idempotent) -- Handles edge dates (year boundaries, leap years) - -**Calendar Cycles Tests:** - -- Cycle boundaries correct for all intervals -- cycle_end calculation correct -- Current cycle detection -- Last completed cycle detection -- Next cycle calculation - -**Validation Tests:** - -- Interval immutability enforced -- Same interval validation on type change -- Status transitions allowed -- Uniqueness constraints enforced - -### Integration Testing - -**Cycle Generation Flow:** - -- Member creation triggers generation -- Type assignment triggers generation -- Type change regenerates future cycles -- Scheduled job generates missing cycles -- Left member stops generation - -**Status Management Flow:** - -- Mark single cycle as paid -- Bulk mark multiple cycles (low prio) -- Status transitions work -- Permissions enforced - -**Membership Fee Type Management:** - -- Create type -- Update amount (regeneration triggered) -- Cannot update interval -- Cannot delete if in use - -### LiveView Testing - -**Member List:** - -- Status column displays correctly -- Toggle between last/current works -- Filters work correctly -- Color coding applied - -**Member Detail:** - -- Cycles table displays all cycles -- Checkboxes work -- Bulk marking works (low prio) -- Membership fee type change validation works -- Actions only shown with permission - -**Admin UI:** - -- Type CRUD works -- Settings save correctly -- Validations display errors -- Only authorized users can access - -### Edge Case Testing - -**Interval Change Attempt:** - -- Error message displayed -- No data modified -- User can cancel/choose different type - -**Exit with Unpaid:** - -- Warning shown -- Option to suspend offered -- Exit completes correctly - -**Amount Change:** - -- Warning displayed -- Only future unpaid regenerated -- Historical cycles unchanged - -**Date Boundaries:** - -- Today = cycle start handled -- Today = cycle end handled -- Leap year handled - -### Performance Testing - -**Cycle Generation:** - -- Generate 10 years of monthly cycles: < 100ms -- Generate for 1000 members: < 5 seconds -- Idempotent check efficient (no full scan) - -**Member List Query:** - -- With status column: < 200ms for 1000 members -- Filters applied efficiently -- No N+1 queries - ---- - -## Security Considerations - -### Authorization - -**Permissions Required:** - -- Membership fee type management: Admin only -- Membership fee cycle status changes: Admin + Treasurer -- View all cycles: Admin + Treasurer + Board -- View own cycles: All authenticated users - -**Policy Enforcement:** - -- All actions protected by Ash policies -- UI shows/hides based on permissions -- Backend validates permissions (never trust UI alone) - -### Data Integrity - -**Validation Layers:** - -1. Database constraints (NOT NULL, UNIQUE, CHECK) -2. Ash validations (business rules) -3. UI validations (user experience) - -**Immutability Protection:** - -- Interval change prevented at multiple layers -- Cycle amounts immutable (audit trail) -- Settings changes logged (future) - -### Audit Trail - -**Tracked Information:** - -- Cycle status changes (who, when) - future enhancement -- Membership fee type amount changes (implicit via cycle amounts) - ---- - -## Performance Considerations - -### Database Indexes - -**Required Indexes:** - -- `membership_fee_cycles(member_id)` - For member cycle lookups -- `membership_fee_cycles(membership_fee_type_id)` - For type queries -- `membership_fee_cycles(status)` - For unpaid filters -- `membership_fee_cycles(cycle_start)` - For date range queries -- `membership_fee_cycles(member_id, cycle_start)` - Composite unique index -- `members(membership_fee_type_id)` - For type membership count - -### Query Optimization - -**Preloading:** - -- Load membership_fee_type with cycles (avoid N+1) -- Load cycles when displaying member detail -- Use Ash's load for efficient preloading - -**Calculated Fields:** - -- cycle_end calculated on-demand (not stored) -- current_cycle_status calculated when needed -- Use Ash calculations for lazy evaluation - -**Pagination:** - -- Cycle list paginated if > 50 cycles -- Member list already paginated - -### Caching Strategy - -**No caching needed in MVP:** - -- Membership fee types rarely change -- Cycle queries are fast -- Settings read infrequently - -**Future caching if needed:** - -- Cache settings in application memory -- Cache membership fee types list -- Invalidate on change - -### Scheduled Job Performance - -**Cycle Generation Job:** - -- Run daily or weekly (not hourly) -- Batch members (process 100 at a time) -- Skip members with no changes -- Log failures for retry - ---- - -## Future Enhancements - -### Phase 2: Interval Change Support - -**Architecture Changes:** - -- Add logic to handle cycle overlaps -- Calculate prorata amounts if needed -- More complex validation -- Migration path for existing cycles - -### Phase 3: Payment Details - -**Architecture Changes:** - -- Add PaymentTransaction resource -- Link transactions to cycles -- Support multiple payments per cycle -- Reconciliation logic - -### Phase 4: vereinfacht.digital Integration - -**Architecture Changes:** - -- External API client module -- Webhook handling for transactions -- Automatic matching logic -- Manual review interface - ---- - -**End of Architecture Document** diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index 18b8154..5b7514c 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -12,6 +12,7 @@ defmodule Mv.Membership.CustomField do - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`) - `description` - Optional human-readable description + - `immutable` - If true, custom field values cannot be changed after creation - `required` - If true, all members must have this custom field (future feature) - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted @@ -59,10 +60,10 @@ defmodule Mv.Membership.CustomField do actions do defaults [:read, :update] - default_accept [:name, :value_type, :description, :required, :show_in_overview] + default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] create :create do - accept [:name, :value_type, :description, :required, :show_in_overview] + accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] change Mv.Membership.CustomField.Changes.GenerateSlug validate string_length(:slug, min: 1) end @@ -112,6 +113,10 @@ defmodule Mv.Membership.CustomField do trim?: true ] + attribute :immutable, :boolean, + default: false, + allow_nil?: false + attribute :required, :boolean, default: false, allow_nil?: false diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 5816d19..b788dc9 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -29,9 +29,7 @@ defmodule Mv.Membership.Member do ## Full-Text Search Members have a `search_vector` attribute (tsvector) that is automatically - updated via database trigger. Search includes name, email, notes, contact fields, - and all custom field values. Custom field values are automatically included in - the search vector with weight 'C' (same as phone_number, city, etc.). + updated via database trigger. Search includes name, email, notes, and contact fields. """ use Ash.Resource, domain: Mv.Membership, @@ -42,21 +40,6 @@ defmodule Mv.Membership.Member do # Module constants @member_search_limit 10 - - # Similarity threshold for fuzzy name/address matching. - # Lower value = more results but less accurate (0.1-0.9) - # - # Fuzzy matching uses two complementary strategies: - # 1. % operator: Fast GIN-index-based matching using server-wide threshold (default 0.3) - # - Catches exact trigram matches quickly via index - # 2. similarity/word_similarity functions: Precise matching with this configurable threshold - # - Catches partial matches that % operator might miss - # - # Value 0.2 chosen based on testing with typical German names: - # - "Müller" vs "Mueller": similarity ~0.65 ✓ - # - "Schmidt" vs "Schmitt": similarity ~0.75 ✓ - # - "Wagner" vs "Wegner": similarity ~0.55 ✓ - # - Random unrelated names: similarity ~0.15 ✗ @default_similarity_threshold 0.2 # Use constants from Mv.Constants for member fields @@ -79,8 +62,7 @@ defmodule Mv.Membership.Member do # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true - # Accept member fields plus membership_fee_type_id (belongs_to FK) - accept @member_fields ++ [:membership_fee_type_id] + accept @member_fields change manage_relationship(:custom_field_values, type: :create) @@ -113,8 +95,7 @@ defmodule Mv.Membership.Member do # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true - # Accept member fields plus membership_fee_type_id (belongs_to FK) - accept @member_fields ++ [:membership_fee_type_id] + accept @member_fields change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) @@ -158,21 +139,30 @@ defmodule Mv.Membership.Member do if is_binary(q) and String.trim(q) != "" do q2 = String.trim(q) - # Sanitize for LIKE patterns (escape % and _), limit length to 100 chars - q2_sanitized = sanitize_search_query(q2) - pat = "%" <> q2_sanitized <> "%" - - # Build search filters grouped by search type for maintainability - # Priority: FTS > Substring > Custom Fields > Fuzzy Matching - # Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized) - fts_match = build_fts_filter(q2) - substring_match = build_substring_filter(q2_sanitized, pat) - custom_field_match = build_custom_field_filter(pat) - fuzzy_match = build_fuzzy_filter(q2, threshold) + pat = "%" <> q2 <> "%" + # FTS as main filter and fuzzy search just for first name, last name and strees query |> Ash.Query.filter( - expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match) + expr( + # Substring on numeric-like fields (best effort, supports middle substrings) + fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or + fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or + contains(postal_code, ^q2) or + contains(house_number, ^q2) or + contains(phone_number, ^q2) or + contains(email, ^q2) or + contains(city, ^q2) or ilike(city, ^pat) or + fragment("? % first_name", ^q2) or + fragment("? % last_name", ^q2) or + fragment("? % street", ^q2) or + fragment("word_similarity(?, first_name) > ?", ^q2, ^threshold) or + fragment("word_similarity(?, last_name) > ?", ^q2, ^threshold) or + fragment("word_similarity(?, street) > ?", ^q2, ^threshold) or + fragment("similarity(first_name, ?) > ?", ^q2, ^threshold) or + fragment("similarity(last_name, ?) > ?", ^q2, ^threshold) or + fragment("similarity(street, ?) > ?", ^q2, ^threshold) + ) ) else query @@ -396,15 +386,6 @@ defmodule Mv.Membership.Member do writable?: false, public?: false, select_by_default?: false - - # Membership fee fields - # membership_fee_start_date: Date from which membership fees should be calculated - # If nil, calculated from join_date + global setting - attribute :membership_fee_start_date, :date do - allow_nil? true - public? true - description "Date from which membership fees should be calculated" - end end relationships do @@ -413,16 +394,6 @@ defmodule Mv.Membership.Member do # This references the User's member_id attribute # The relationship is optional (allow_nil? true by default) has_one :user, Mv.Accounts.User - - # Membership fee relationships - # belongs_to: The fee type assigned to this member - # Optional for MVP - can be nil if no fee type assigned yet - belongs_to :membership_fee_type, Mv.MembershipFees.MembershipFeeType do - allow_nil? true - end - - # has_many: All fee cycles for this member - has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle end # Define identities for upsert operations @@ -505,6 +476,7 @@ defmodule Mv.Membership.Member do - `query` - Ash.Query.t() to apply search to - `opts` - Keyword list or map with search options: - `:query` or `"query"` - Search string + - `:fields` or `"fields"` - Optional field restrictions ## Returns - Modified Ash.Query.t() with search filters applied @@ -525,103 +497,16 @@ defmodule Mv.Membership.Member do if String.trim(q) == "" do query else - Ash.Query.for_read(query, :search, %{query: q}) + args = + case opts[:fields] || opts["fields"] do + nil -> %{query: q} + fields -> %{query: q, fields: fields} + end + + Ash.Query.for_read(query, :search, args) end end - # ============================================================================ - # Search Input Sanitization - # ============================================================================ - - # Sanitizes search input to prevent LIKE pattern injection. - # Escapes SQL LIKE wildcards (% and _) and limits query length. - # - # ## Examples - # - # iex> sanitize_search_query("test%injection") - # "test\\%injection" - # - # iex> sanitize_search_query("very_long_search") - # "very\\_long\\_search" - # - defp sanitize_search_query(query) when is_binary(query) do - query - |> String.slice(0, 100) - |> String.replace("\\", "\\\\") - |> String.replace("%", "\\%") - |> String.replace("_", "\\_") - end - - defp sanitize_search_query(_), do: "" - - # ============================================================================ - # Search Filter Builders - # ============================================================================ - # These functions build search filters grouped by search type for maintainability. - # Priority order: FTS > Substring > Custom Fields > Fuzzy Matching - - # Builds full-text search filter using tsvector (highest priority, fastest) - # Uses GIN index on search_vector for optimal performance - defp build_fts_filter(query) do - expr( - fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^query) or - fragment("search_vector @@ plainto_tsquery('simple', ?)", ^query) - ) - end - - # Builds substring search filter for structured fields - # Note: contains/2 uses ILIKE '%value%' which is not index-optimized - # Performance: Good for small datasets, may be slow on large tables - defp build_substring_filter(query, _pattern) do - expr( - contains(postal_code, ^query) or - contains(house_number, ^query) or - contains(phone_number, ^query) or - contains(email, ^query) or - contains(city, ^query) - ) - end - - # Builds search filter for custom field values using ILIKE on JSONB - # Note: ILIKE on JSONB is not index-optimized, may be slow with many custom fields - # This is a fallback for substring matching in custom fields (e.g., phone numbers) - # Uses ->> operator which always returns TEXT directly (no need for -> + ::text fallback) - # Important: `id` must be passed as parameter to correctly reference the outer members table - defp build_custom_field_filter(pattern) do - expr( - fragment( - "EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = ? AND (value->>'_union_value' ILIKE ? OR value->>'value' ILIKE ?))", - id, - ^pattern, - ^pattern - ) - ) - end - - # Builds fuzzy/trigram matching filter for name and street fields. - # Uses pg_trgm extension with GIN indexes for performance. - # - # Two-tier matching strategy: - # - % operator: Uses server-wide pg_trgm.similarity_threshold (typically 0.3) - # for fast index-based initial filtering - # - similarity/word_similarity: Uses @default_similarity_threshold (0.2) - # for more lenient matching to catch edge cases - # - # Note: Requires trigram GIN indexes on first_name, last_name, street. - defp build_fuzzy_filter(query, threshold) do - expr( - fragment("? % first_name", ^query) or - fragment("? % last_name", ^query) or - fragment("? % street", ^query) or - fragment("word_similarity(?, first_name) > ?", ^query, ^threshold) or - fragment("word_similarity(?, last_name) > ?", ^query, ^threshold) or - fragment("word_similarity(?, street) > ?", ^query, ^threshold) or - fragment("similarity(first_name, ?) > ?", ^query, ^threshold) or - fragment("similarity(last_name, ?) > ?", ^query, ^threshold) or - fragment("similarity(street, ?) > ?", ^query, ^threshold) - ) - end - # Private helper to apply filters for :available_for_linking action # user_email: may be nil/empty when creating new user, or populated when editing # search_query: optional search term for fuzzy matching @@ -630,9 +515,9 @@ defmodule Mv.Membership.Member do # - Empty user_email ("") → email == "" is always false → only fuzzy search matches # - This allows a single filter expression instead of duplicating fuzzy search logic # - # Note: Custom field search is intentionally excluded from linking to optimize - # autocomplete performance. Custom fields are still searchable via the main - # member search which uses the indexed search_vector. + # Cyclomatic complexity is unavoidable here: PostgreSQL fuzzy search requires + # multiple OR conditions for good search quality (FTS + trigram similarity + substring) + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defp apply_linking_filters(query, user_email, search_query) do has_search = search_query && String.trim(search_query) != "" # Use empty string instead of nil to simplify filter logic @@ -641,23 +526,35 @@ defmodule Mv.Membership.Member do if has_search do # Search query provided: return email-match OR fuzzy-search candidates trimmed_search = String.trim(search_query) - # Sanitize for LIKE patterns (contains uses ILIKE internally) - sanitized_search = sanitize_search_query(trimmed_search) - - # Build search filters - excluding custom_field_filter for performance - fts_match = build_fts_filter(trimmed_search) - fuzzy_match = build_fuzzy_filter(trimmed_search, @default_similarity_threshold) - email_substring_match = expr(contains(email, ^sanitized_search)) query |> Ash.Query.filter( expr( - # Email exact match has highest priority (for filter_by_email_match) - # If email is "", this is always false and search filters take over + # Email match candidate (for filter_by_email_match priority) + # If email is "", this is always false and fuzzy search takes over + # Fuzzy search candidates email == ^trimmed_email or - ^fts_match or - ^fuzzy_match or - ^email_substring_match + fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed_search) or + fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed_search) or + fragment("? % first_name", ^trimmed_search) or + fragment("? % last_name", ^trimmed_search) or + fragment("word_similarity(?, first_name) > 0.2", ^trimmed_search) or + fragment( + "word_similarity(?, last_name) > ?", + ^trimmed_search, + ^@default_similarity_threshold + ) or + fragment( + "similarity(first_name, ?) > ?", + ^trimmed_search, + ^@default_similarity_threshold + ) or + fragment( + "similarity(last_name, ?) > ?", + ^trimmed_search, + ^@default_similarity_threshold + ) or + contains(email, ^trimmed_search) ) ) else diff --git a/lib/membership_fees/membership_fee_cycle.ex b/lib/membership_fees/membership_fee_cycle.ex deleted file mode 100644 index 4c47623..0000000 --- a/lib/membership_fees/membership_fee_cycle.ex +++ /dev/null @@ -1,102 +0,0 @@ -defmodule Mv.MembershipFees.MembershipFeeCycle do - @moduledoc """ - Ash resource representing an individual membership fee cycle for a member. - - ## Overview - MembershipFeeCycle represents a single billing cycle for a member. Each cycle - tracks the payment status and amount for a specific time period. - - ## Attributes - - `cycle_start` - Start date of the billing cycle (aligned to calendar boundaries) - - `amount` - The fee amount for this cycle (stored for audit trail) - - `status` - Payment status: unpaid, paid, or suspended - - `notes` - Optional notes for this cycle - - ## Design Decisions - - **No cycle_end field**: Calculated from cycle_start + interval (from fee type) - - **Amount stored per cycle**: Preserves historical amounts when fee type changes - - **Calendar-aligned cycles**: All cycles start on calendar boundaries - - ## Relationships - - `belongs_to :member` - The member this cycle belongs to - - `belongs_to :membership_fee_type` - The fee type for this cycle - - ## Constraints - - Unique constraint on (member_id, cycle_start) - one cycle per period per member - - CASCADE delete when member is deleted - - RESTRICT delete on membership_fee_type if cycles exist - """ - use Ash.Resource, - domain: Mv.MembershipFees, - data_layer: AshPostgres.DataLayer - - postgres do - table "membership_fee_cycles" - repo Mv.Repo - end - - resource do - description "Individual membership fee cycle for a member" - end - - actions do - defaults [:read, :destroy] - - create :create do - primary? true - accept [:cycle_start, :amount, :status, :notes, :member_id, :membership_fee_type_id] - end - - update :update do - primary? true - accept [:status, :notes] - end - end - - attributes do - uuid_v7_primary_key :id - - attribute :cycle_start, :date do - allow_nil? false - public? true - description "Start date of the billing cycle" - end - - attribute :amount, :decimal do - allow_nil? false - public? true - - description "Fee amount for this cycle (stored for audit trail, non-negative, max 2 decimal places)" - - constraints min: 0, scale: 2 - end - - attribute :status, :atom do - allow_nil? false - public? true - default :unpaid - description "Payment status of this cycle" - constraints one_of: [:unpaid, :paid, :suspended] - end - - attribute :notes, :string do - allow_nil? true - public? true - description "Optional notes for this cycle" - end - end - - relationships do - belongs_to :member, Mv.Membership.Member do - allow_nil? false - end - - belongs_to :membership_fee_type, Mv.MembershipFees.MembershipFeeType do - allow_nil? false - end - end - - identities do - identity :unique_cycle_per_member, [:member_id, :cycle_start] - end -end diff --git a/lib/membership_fees/membership_fee_type.ex b/lib/membership_fees/membership_fee_type.ex deleted file mode 100644 index 877a385..0000000 --- a/lib/membership_fees/membership_fee_type.ex +++ /dev/null @@ -1,92 +0,0 @@ -defmodule Mv.MembershipFees.MembershipFeeType do - @moduledoc """ - Ash resource representing a membership fee type definition. - - ## Overview - MembershipFeeType defines the different types of membership fees that can be - assigned to members. Each type has a fixed interval (billing cycle) and a - default amount. - - ## Attributes - - `name` - Unique name for the fee type (e.g., "Standard", "Reduced", "Family") - - `amount` - The fee amount in the default currency (decimal) - - `interval` - Billing interval: monthly, quarterly, half_yearly, or yearly - - `description` - Optional description for the fee type - - ## Immutability - The `interval` field is immutable after creation. This prevents complex - migration scenarios when changing billing cycles. To change intervals, - create a new fee type and migrate members. - - ## Relationships - - `has_many :members` - Members assigned to this fee type - - `has_many :membership_fee_cycles` - All cycles using this fee type - """ - use Ash.Resource, - domain: Mv.MembershipFees, - data_layer: AshPostgres.DataLayer - - postgres do - table "membership_fee_types" - repo Mv.Repo - end - - resource do - description "Membership fee type definition with interval and amount" - end - - actions do - defaults [:read, :destroy] - - create :create do - primary? true - accept [:name, :amount, :interval, :description] - end - - update :update do - primary? true - # Note: interval is NOT in accept list - it's immutable after creation - # Immutability validation will be added in a future issue - accept [:name, :amount, :description] - end - end - - attributes do - uuid_v7_primary_key :id - - attribute :name, :string do - allow_nil? false - public? true - description "Unique name for the membership fee type" - end - - attribute :amount, :decimal do - allow_nil? false - public? true - description "Fee amount in default currency (non-negative, max 2 decimal places)" - constraints min: 0, scale: 2 - end - - attribute :interval, :atom do - allow_nil? false - public? true - description "Billing interval (immutable after creation)" - constraints one_of: [:monthly, :quarterly, :half_yearly, :yearly] - end - - attribute :description, :string do - allow_nil? true - public? true - description "Optional description for the fee type" - end - end - - relationships do - has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle - has_many :members, Mv.Membership.Member - end - - identities do - identity :unique_name, [:name] - end -end diff --git a/lib/membership_fees/membership_fees.ex b/lib/membership_fees/membership_fees.ex deleted file mode 100644 index 7a2833a..0000000 --- a/lib/membership_fees/membership_fees.ex +++ /dev/null @@ -1,42 +0,0 @@ -defmodule Mv.MembershipFees do - @moduledoc """ - Ash Domain for membership fee management. - - ## Resources - - `MembershipFeeType` - Defines membership fee types with intervals and amounts - - `MembershipFeeCycle` - Individual membership fee cycles per member - - ## Overview - This domain handles the complete membership fee lifecycle including: - - Fee type definitions (monthly, quarterly, half-yearly, yearly) - - Individual fee cycles for each member - - Payment status tracking (unpaid, paid, suspended) - - ## Architecture Decisions - - `interval` field on MembershipFeeType is immutable after creation - - `cycle_end` is calculated, not stored (from cycle_start + interval) - - `amount` is stored per cycle for audit trail when prices change - """ - use Ash.Domain, - extensions: [AshAdmin.Domain, AshPhoenix] - - admin do - show? true - end - - resources do - resource Mv.MembershipFees.MembershipFeeType do - define :create_membership_fee_type, action: :create - define :list_membership_fee_types, action: :read - define :update_membership_fee_type, action: :update - define :destroy_membership_fee_type, action: :destroy - end - - resource Mv.MembershipFees.MembershipFeeCycle do - define :create_membership_fee_cycle, action: :create - define :list_membership_fee_cycles, action: :read - define :update_membership_fee_cycle, action: :update - define :destroy_membership_fee_cycle, action: :destroy - end - end -end diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 843ad2b..7bfb07b 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -15,8 +15,7 @@ defmodule Mv.Constants do :city, :street, :house_number, - :postal_code, - :membership_fee_start_date + :postal_code ] @custom_field_prefix "custom_field_" diff --git a/lib/mv/membership_fees/calendar_cycles.ex b/lib/mv/membership_fees/calendar_cycles.ex deleted file mode 100644 index 8a4ef24..0000000 --- a/lib/mv/membership_fees/calendar_cycles.ex +++ /dev/null @@ -1,329 +0,0 @@ -defmodule Mv.MembershipFees.CalendarCycles do - @moduledoc """ - Calendar-based cycle calculation functions for membership fees. - - This module provides functions for calculating cycle boundaries - based on interval types (monthly, quarterly, half-yearly, yearly). - - The calculation functions (`calculate_cycle_start/3`, `calculate_cycle_end/2`, - `next_cycle_start/2`) are pure functions with no side effects. - - The time-dependent functions (`current_cycle?/3`, `last_completed_cycle?/3`) - depend on a date parameter for testability. Their 2-argument variants - (`current_cycle?/2`, `last_completed_cycle?/2`) use `Date.utc_today()` and - are not referentially transparent. - - ## Interval Types - - - `:monthly` - Cycles from 1st to last day of each month - - `:quarterly` - Cycles from 1st of Jan/Apr/Jul/Oct to last day of quarter - - `:half_yearly` - Cycles from 1st of Jan/Jul to last day of half-year - - `:yearly` - Cycles from Jan 1st to Dec 31st - - ## Examples - - iex> date = ~D[2024-03-15] - iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(date, :monthly) - ~D[2024-03-01] - - iex> cycle_start = ~D[2024-01-01] - iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle_start, :yearly) - ~D[2024-12-31] - - iex> cycle_start = ~D[2024-01-01] - iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(cycle_start, :yearly) - ~D[2025-01-01] - """ - - @typedoc """ - Interval type for membership fee cycles. - - - `:monthly` - Monthly cycles (1st to last day of month) - - `:quarterly` - Quarterly cycles (1st of quarter to last day of quarter) - - `:half_yearly` - Half-yearly cycles (1st of half-year to last day of half-year) - - `:yearly` - Yearly cycles (Jan 1st to Dec 31st) - """ - @type interval :: :monthly | :quarterly | :half_yearly | :yearly - - @doc """ - Calculates the start date of the cycle that contains the reference date. - - ## Parameters - - - `date` - Ignored in this 3-argument version (kept for API consistency) - - `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`) - - `reference_date` - The date used to determine which cycle to calculate - - ## Returns - - The start date of the cycle containing the reference date. - - ## Examples - - iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly, ~D[2024-05-20]) - ~D[2024-05-01] - - iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :quarterly, ~D[2024-05-20]) - ~D[2024-04-01] - """ - @spec calculate_cycle_start(Date.t(), interval(), Date.t()) :: Date.t() - def calculate_cycle_start(_date, interval, reference_date) do - case interval do - :monthly -> monthly_cycle_start(reference_date) - :quarterly -> quarterly_cycle_start(reference_date) - :half_yearly -> half_yearly_cycle_start(reference_date) - :yearly -> yearly_cycle_start(reference_date) - end - end - - @doc """ - Calculates the start date of the cycle that contains the given date. - - This is a convenience function that calls `calculate_cycle_start/3` with `date` as both - the input and reference date. - - ## Parameters - - - `date` - The date used to determine which cycle to calculate - - `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`) - - ## Returns - - The start date of the cycle containing the given date. - - ## Examples - - iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly) - ~D[2024-03-01] - - iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly) - ~D[2024-04-01] - - iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly) - ~D[2024-07-01] - - iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-12-15], :yearly) - ~D[2024-01-01] - """ - @spec calculate_cycle_start(Date.t(), interval()) :: Date.t() - def calculate_cycle_start(date, interval) do - calculate_cycle_start(date, interval, date) - end - - @doc """ - Calculates the end date of a cycle based on its start date and interval. - - ## Parameters - - - `cycle_start` - The start date of the cycle - - `interval` - The interval type - - ## Returns - - The end date of the cycle. - - ## Examples - - iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly) - ~D[2024-03-31] - - iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly) - ~D[2024-02-29] - - iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly) - ~D[2024-03-31] - - iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly) - ~D[2024-06-30] - - iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly) - ~D[2024-12-31] - """ - @spec calculate_cycle_end(Date.t(), interval()) :: Date.t() - def calculate_cycle_end(cycle_start, interval) do - case interval do - :monthly -> monthly_cycle_end(cycle_start) - :quarterly -> quarterly_cycle_end(cycle_start) - :half_yearly -> half_yearly_cycle_end(cycle_start) - :yearly -> yearly_cycle_end(cycle_start) - end - end - - @doc """ - Calculates the start date of the next cycle. - - ## Parameters - - - `cycle_start` - The start date of the current cycle - - `interval` - The interval type - - ## Returns - - The start date of the next cycle. - - ## Examples - - iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :monthly) - ~D[2024-02-01] - - iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :quarterly) - ~D[2024-04-01] - - iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :half_yearly) - ~D[2024-07-01] - - iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) - ~D[2025-01-01] - """ - @spec next_cycle_start(Date.t(), interval()) :: Date.t() - def next_cycle_start(cycle_start, interval) do - cycle_end = calculate_cycle_end(cycle_start, interval) - next_date = Date.add(cycle_end, 1) - calculate_cycle_start(next_date, interval) - end - - @doc """ - Checks if the cycle contains the given date. - - ## Parameters - - - `cycle_start` - The start date of the cycle - - `interval` - The interval type - - `today` - The date to check (defaults to today's date) - - ## Returns - - `true` if the given date is within the cycle, `false` otherwise. - - ## Examples - - iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15]) - true - - iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-02-01], :monthly, ~D[2024-03-15]) - false - - iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-01]) - true - - iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-31]) - true - """ - @spec current_cycle?(Date.t(), interval(), Date.t()) :: boolean() - def current_cycle?(cycle_start, interval, today) do - cycle_end = calculate_cycle_end(cycle_start, interval) - - Date.compare(cycle_start, today) in [:lt, :eq] and - Date.compare(today, cycle_end) in [:lt, :eq] - end - - @spec current_cycle?(Date.t(), interval()) :: boolean() - def current_cycle?(cycle_start, interval) do - current_cycle?(cycle_start, interval, Date.utc_today()) - end - - @doc """ - Checks if the cycle is the last completed cycle. - - A cycle is considered the last completed cycle if: - - The cycle has ended (cycle_end < today) - - The next cycle has not ended yet (today <= next_end) - - In other words: `cycle_end < today <= next_end` - - ## Parameters - - - `cycle_start` - The start date of the cycle - - `interval` - The interval type - - `today` - The date to check against (defaults to today's date) - - ## Returns - - `true` if the cycle is the last completed cycle, `false` otherwise. - - ## Examples - - iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-04-01]) - true - - iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15]) - false - - iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-02-01], :monthly, ~D[2024-04-15]) - false - """ - @spec last_completed_cycle?(Date.t(), interval(), Date.t()) :: boolean() - def last_completed_cycle?(cycle_start, interval, today) do - cycle_end = calculate_cycle_end(cycle_start, interval) - - # Cycle must have ended (cycle_end < today) - case Date.compare(today, cycle_end) do - :gt -> - # Check if this is the most recent completed cycle - # by verifying that the next cycle hasn't ended yet (today <= next_end) - next_start = next_cycle_start(cycle_start, interval) - next_end = calculate_cycle_end(next_start, interval) - - Date.compare(today, next_end) in [:lt, :eq] - - _ -> - false - end - end - - @spec last_completed_cycle?(Date.t(), interval()) :: boolean() - def last_completed_cycle?(cycle_start, interval) do - last_completed_cycle?(cycle_start, interval, Date.utc_today()) - end - - # Private helper functions - - defp monthly_cycle_start(date) do - Date.new!(date.year, date.month, 1) - end - - defp monthly_cycle_end(cycle_start) do - Date.end_of_month(cycle_start) - end - - defp quarterly_cycle_start(date) do - quarter_start_month = - case date.month do - m when m in [1, 2, 3] -> 1 - m when m in [4, 5, 6] -> 4 - m when m in [7, 8, 9] -> 7 - m when m in [10, 11, 12] -> 10 - end - - Date.new!(date.year, quarter_start_month, 1) - end - - defp quarterly_cycle_end(cycle_start) do - case cycle_start.month do - 1 -> Date.new!(cycle_start.year, 3, 31) - 4 -> Date.new!(cycle_start.year, 6, 30) - 7 -> Date.new!(cycle_start.year, 9, 30) - 10 -> Date.new!(cycle_start.year, 12, 31) - end - end - - defp half_yearly_cycle_start(date) do - half_start_month = if date.month in 1..6, do: 1, else: 7 - Date.new!(date.year, half_start_month, 1) - end - - defp half_yearly_cycle_end(cycle_start) do - case cycle_start.month do - 1 -> Date.new!(cycle_start.year, 6, 30) - 7 -> Date.new!(cycle_start.year, 12, 31) - end - end - - defp yearly_cycle_start(date) do - Date.new!(date.year, 1, 1) - end - - defp yearly_cycle_end(cycle_start) do - Date.new!(cycle_start.year, 12, 31) - end -end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index a1020ef..d19b1eb 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -95,11 +95,9 @@ defmodule MvWeb.CoreComponents do <.button>Send! <.button phx-click="go" variant="primary">Send! <.button navigate={~p"/"}>Home - <.button disabled={true}>Disabled """ attr :rest, :global, include: ~w(href navigate patch method) attr :variant, :string, values: ~w(primary) - attr :disabled, :boolean, default: false, doc: "Whether the button is disabled" slot :inner_block, required: true def button(%{rest: rest} = assigns) do @@ -107,37 +105,14 @@ defmodule MvWeb.CoreComponents do assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant])) if rest[:href] || rest[:navigate] || rest[:patch] do - # For links, we can't use disabled attribute, so we use btn-disabled class - # DaisyUI's btn-disabled provides the same styling as :disabled on buttons - link_class = - if assigns[:disabled], - do: ["btn", assigns.class, "btn-disabled"], - else: ["btn", assigns.class] - - # Prevent interaction when disabled - # Remove navigation attributes to prevent "Open in new tab", "Copy link" etc. - link_attrs = - if assigns[:disabled] do - rest - |> Map.drop([:href, :navigate, :patch]) - |> Map.merge(%{tabindex: "-1", "aria-disabled": "true"}) - else - rest - end - - assigns = - assigns - |> assign(:link_class, link_class) - |> assign(:link_attrs, link_attrs) - ~H""" - <.link class={@link_class} {@link_attrs}> + <.link class={["btn", @class]} {@rest}> {render_slot(@inner_block)} """ else ~H""" - """ @@ -178,7 +153,7 @@ defmodule MvWeb.CoreComponents do aria-haspopup="menu" aria-expanded={@open} aria-controls={@id} - class="btn" + class="btn btn-ghost" phx-click="toggle_dropdown" phx-target={@phx_target} data-testid="dropdown-button" @@ -261,30 +236,6 @@ defmodule MvWeb.CoreComponents do """ end - @doc """ - Renders a section in with a border similar to cards. - - - ## Examples - - <.form_section title={gettext("Personal Data")}> -

input

- - """ - attr :title, :string, required: true - slot :inner_block, required: true - - def form_section(assigns) do - ~H""" -
-

{@title}

-
- {render_slot(@inner_block)} -
-
- """ - end - @doc """ Renders an input with label and error messages. @@ -483,7 +434,7 @@ defmodule MvWeb.CoreComponents do ~H"""
-

+

{render_slot(@inner_block)}

@@ -523,7 +474,6 @@ defmodule MvWeb.CoreComponents do slot :col, required: true do attr :label, :string - attr :class, :string attr :col_click, :any, doc: "optional column-specific click handler that overrides row_click" end @@ -540,7 +490,7 @@ defmodule MvWeb.CoreComponents do - +
{col[:label]}{col[:label]} <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -564,34 +514,7 @@ defmodule MvWeb.CoreComponents do (col[:col_click] && col[:col_click].(@row_item.(row))) || (@row_click && @row_click.(row)) } - class={ - col_class = Map.get(col, :class) - has_click = col[:col_click] || @row_click - classes = ["max-w-xs"] - - classes = - if col_class == nil || (col_class && !String.contains?(col_class, "text-center")) do - ["truncate" | classes] - else - classes - end - - classes = - if has_click do - ["hover:cursor-pointer" | classes] - else - classes - end - - classes = - if col_class do - [col_class | classes] - else - classes - end - - Enum.join(classes, " ") - } + class={["max-w-xs truncate", (col[:col_click] || @row_click) && "hover:cursor-pointer"]} > {render_slot(col, @row_item.(row))} diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 86090a8..487a01f 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -36,16 +36,12 @@ defmodule MvWeb.Layouts do default: nil, doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)" - attr :club_name, :string, - default: nil, - doc: "optional club name to pass to navbar" - slot :inner_block, required: true def app(assigns) do ~H""" <%= if @current_user do %> - <.navbar current_user={@current_user} club_name={@club_name} /> + <.navbar current_user={@current_user} /> <% end %>
diff --git a/lib/mv_web/components/layouts/navbar.ex b/lib/mv_web/components/layouts/navbar.ex index 1ff589b..4246c99 100644 --- a/lib/mv_web/components/layouts/navbar.ex +++ b/lib/mv_web/components/layouts/navbar.ex @@ -12,18 +12,15 @@ defmodule MvWeb.Layouts.Navbar do required: true, doc: "The current user - navbar is only shown when user is present" - attr :club_name, :string, - default: nil, - doc: "Optional club name - if not provided, will be loaded from database" - def navbar(assigns) do - club_name = assigns[:club_name] || get_club_name() + club_name = get_club_name() + assigns = assign(assigns, :club_name, club_name) ~H"""