Compare commits
11 commits
2fa8f3eb2c
...
dd4048669c
| Author | SHA1 | Date | |
|---|---|---|---|
| dd4048669c | |||
| e0712d47bc | |||
| 4e86351e1c | |||
| 8bfa5b7d1d | |||
| cb82c07cbf | |||
| ebbf347e42 | |||
| 4d1b33357e | |||
| e563d12be3 | |||
|
|
2abbb789b7 | ||
| 045f0dc603 | |||
|
|
f480c12bb0 |
32 changed files with 1634 additions and 107 deletions
|
|
@ -1,3 +1,3 @@
|
||||||
elixir 1.18.3-otp-27
|
elixir 1.18.3-otp-27
|
||||||
erlang 27.3.4
|
erlang 27.3.4
|
||||||
just 1.43.1
|
just 1.45.0
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ config :spark,
|
||||||
config :mv,
|
config :mv,
|
||||||
ecto_repos: [Mv.Repo],
|
ecto_repos: [Mv.Repo],
|
||||||
generators: [timestamp_type: :utc_datetime],
|
generators: [timestamp_type: :utc_datetime],
|
||||||
ash_domains: [Mv.Membership, Mv.Accounts]
|
ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees]
|
||||||
|
|
||||||
# Configures the endpoint
|
# Configures the endpoint
|
||||||
config :mv, MvWeb.Endpoint,
|
config :mv, MvWeb.Endpoint,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ services:
|
||||||
|
|
||||||
rauthy:
|
rauthy:
|
||||||
container_name: rauthy-dev
|
container_name: rauthy-dev
|
||||||
image: ghcr.io/sebadob/rauthy:0.32.0
|
image: ghcr.io/sebadob/rauthy:0.33.1
|
||||||
environment:
|
environment:
|
||||||
- LOCAL_TEST=true
|
- LOCAL_TEST=true
|
||||||
- SMTP_URL=mailcrab
|
- SMTP_URL=mailcrab
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
// - https://dbdocs.io
|
// - https://dbdocs.io
|
||||||
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
|
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
|
||||||
//
|
//
|
||||||
// Version: 1.2
|
// Version: 1.3
|
||||||
// Last Updated: 2025-11-13
|
// Last Updated: 2025-12-11
|
||||||
|
|
||||||
Project mila_membership_management {
|
Project mila_membership_management {
|
||||||
database_type: 'PostgreSQL'
|
database_type: 'PostgreSQL'
|
||||||
|
|
@ -27,6 +27,7 @@ Project mila_membership_management {
|
||||||
## Domains:
|
## Domains:
|
||||||
- **Accounts**: User authentication and session management
|
- **Accounts**: User authentication and session management
|
||||||
- **Membership**: Club member data and custom fields
|
- **Membership**: Club member data and custom fields
|
||||||
|
- **MembershipFees**: Membership fee types and billing cycles
|
||||||
|
|
||||||
## Required PostgreSQL Extensions:
|
## Required PostgreSQL Extensions:
|
||||||
- uuid-ossp (UUID generation)
|
- uuid-ossp (UUID generation)
|
||||||
|
|
@ -132,6 +133,8 @@ Table members {
|
||||||
house_number text [null, note: 'House number']
|
house_number text [null, note: 'House number']
|
||||||
postal_code text [null, note: '5-digit German postal code']
|
postal_code text [null, note: '5-digit German postal code']
|
||||||
search_vector tsvector [null, note: 'Full-text search index (auto-generated)']
|
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 {
|
indexes {
|
||||||
email [unique, name: 'members_unique_email_index']
|
email [unique, name: 'members_unique_email_index']
|
||||||
|
|
@ -146,6 +149,7 @@ Table members {
|
||||||
last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting']
|
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']
|
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']
|
(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: '''
|
Note: '''
|
||||||
|
|
@ -178,6 +182,8 @@ Table members {
|
||||||
**Relationships:**
|
**Relationships:**
|
||||||
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
|
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
|
||||||
- 1:N with custom_field_values (custom dynamic fields)
|
- 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:**
|
**Validation Rules:**
|
||||||
- first_name, last_name: min 1 character
|
- first_name, last_name: min 1 character
|
||||||
|
|
@ -281,6 +287,98 @@ 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
|
// RELATIONSHIPS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -306,6 +404,22 @@ Ref: custom_field_values.member_id > members.id [delete: cascade]
|
||||||
// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist
|
// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist
|
||||||
Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict]
|
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
|
// ENUMS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -328,6 +442,21 @@ Enum token_purpose {
|
||||||
email_confirmation [note: 'Email verification tokens']
|
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
|
// TABLE GROUPS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -357,3 +486,17 @@ 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
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ defmodule Mv.Membership.CustomField do
|
||||||
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
|
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
|
||||||
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
|
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
|
||||||
- `description` - Optional human-readable description
|
- `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)
|
- `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
|
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
|
||||||
|
|
||||||
|
|
@ -60,10 +59,10 @@ defmodule Mv.Membership.CustomField do
|
||||||
|
|
||||||
actions do
|
actions do
|
||||||
defaults [:read, :update]
|
defaults [:read, :update]
|
||||||
default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
|
default_accept [:name, :value_type, :description, :required, :show_in_overview]
|
||||||
|
|
||||||
create :create do
|
create :create do
|
||||||
accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
|
accept [:name, :value_type, :description, :required, :show_in_overview]
|
||||||
change Mv.Membership.CustomField.Changes.GenerateSlug
|
change Mv.Membership.CustomField.Changes.GenerateSlug
|
||||||
validate string_length(:slug, min: 1)
|
validate string_length(:slug, min: 1)
|
||||||
end
|
end
|
||||||
|
|
@ -113,10 +112,6 @@ defmodule Mv.Membership.CustomField do
|
||||||
trim?: true
|
trim?: true
|
||||||
]
|
]
|
||||||
|
|
||||||
attribute :immutable, :boolean,
|
|
||||||
default: false,
|
|
||||||
allow_nil?: false
|
|
||||||
|
|
||||||
attribute :required, :boolean,
|
attribute :required, :boolean,
|
||||||
default: false,
|
default: false,
|
||||||
allow_nil?: false
|
allow_nil?: false
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,8 @@ defmodule Mv.Membership.Member do
|
||||||
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
||||||
argument :user, :map, allow_nil?: true
|
argument :user, :map, allow_nil?: true
|
||||||
|
|
||||||
accept @member_fields
|
# Accept member fields plus membership_fee_type_id (belongs_to FK)
|
||||||
|
accept @member_fields ++ [:membership_fee_type_id]
|
||||||
|
|
||||||
change manage_relationship(:custom_field_values, type: :create)
|
change manage_relationship(:custom_field_values, type: :create)
|
||||||
|
|
||||||
|
|
@ -112,7 +113,8 @@ defmodule Mv.Membership.Member do
|
||||||
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
||||||
argument :user, :map, allow_nil?: true
|
argument :user, :map, allow_nil?: true
|
||||||
|
|
||||||
accept @member_fields
|
# Accept member fields plus membership_fee_type_id (belongs_to FK)
|
||||||
|
accept @member_fields ++ [:membership_fee_type_id]
|
||||||
|
|
||||||
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
||||||
|
|
||||||
|
|
@ -394,6 +396,15 @@ defmodule Mv.Membership.Member do
|
||||||
writable?: false,
|
writable?: false,
|
||||||
public?: false,
|
public?: false,
|
||||||
select_by_default?: 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
|
end
|
||||||
|
|
||||||
relationships do
|
relationships do
|
||||||
|
|
@ -402,6 +413,16 @@ defmodule Mv.Membership.Member do
|
||||||
# This references the User's member_id attribute
|
# This references the User's member_id attribute
|
||||||
# The relationship is optional (allow_nil? true by default)
|
# The relationship is optional (allow_nil? true by default)
|
||||||
has_one :user, Mv.Accounts.User
|
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
|
end
|
||||||
|
|
||||||
# Define identities for upsert operations
|
# Define identities for upsert operations
|
||||||
|
|
|
||||||
102
lib/membership_fees/membership_fee_cycle.ex
Normal file
102
lib/membership_fees/membership_fee_cycle.ex
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
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
|
||||||
92
lib/membership_fees/membership_fee_type.ex
Normal file
92
lib/membership_fees/membership_fee_type.ex
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
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
|
||||||
42
lib/membership_fees/membership_fees.ex
Normal file
42
lib/membership_fees/membership_fees.ex
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
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
|
||||||
|
|
@ -15,7 +15,8 @@ defmodule Mv.Constants do
|
||||||
:city,
|
:city,
|
||||||
:street,
|
:street,
|
||||||
:house_number,
|
:house_number,
|
||||||
:postal_code
|
:postal_code,
|
||||||
|
:membership_fee_start_date
|
||||||
]
|
]
|
||||||
|
|
||||||
@custom_field_prefix "custom_field_"
|
@custom_field_prefix "custom_field_"
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
<.button>Send!</.button>
|
<.button>Send!</.button>
|
||||||
<.button phx-click="go" variant="primary">Send!</.button>
|
<.button phx-click="go" variant="primary">Send!</.button>
|
||||||
<.button navigate={~p"/"}>Home</.button>
|
<.button navigate={~p"/"}>Home</.button>
|
||||||
|
<.button disabled={true}>Disabled</.button>
|
||||||
"""
|
"""
|
||||||
attr :rest, :global, include: ~w(href navigate patch method)
|
attr :rest, :global, include: ~w(href navigate patch method)
|
||||||
attr :variant, :string, values: ~w(primary)
|
attr :variant, :string, values: ~w(primary)
|
||||||
|
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
||||||
slot :inner_block, required: true
|
slot :inner_block, required: true
|
||||||
|
|
||||||
def button(%{rest: rest} = assigns) do
|
def button(%{rest: rest} = assigns) do
|
||||||
|
|
@ -105,14 +107,34 @@ defmodule MvWeb.CoreComponents do
|
||||||
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
|
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
|
||||||
|
|
||||||
if rest[:href] || rest[:navigate] || rest[:patch] do
|
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
|
||||||
|
link_attrs =
|
||||||
|
if assigns[:disabled] do
|
||||||
|
Map.merge(rest, %{tabindex: "-1", "aria-disabled": "true"})
|
||||||
|
else
|
||||||
|
rest
|
||||||
|
end
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:link_class, link_class)
|
||||||
|
|> assign(:link_attrs, link_attrs)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<.link class={["btn", @class]} {@rest}>
|
<.link class={@link_class} {@link_attrs}>
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
</.link>
|
</.link>
|
||||||
"""
|
"""
|
||||||
else
|
else
|
||||||
~H"""
|
~H"""
|
||||||
<button class={["btn", @class]} {@rest}>
|
<button class={["btn", @class]} disabled={@disabled} {@rest}>
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
</button>
|
</button>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,16 @@ defmodule MvWeb.Layouts do
|
||||||
default: nil,
|
default: nil,
|
||||||
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
|
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
|
slot :inner_block, required: true
|
||||||
|
|
||||||
def app(assigns) do
|
def app(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= if @current_user do %>
|
<%= if @current_user do %>
|
||||||
<.navbar current_user={@current_user} />
|
<.navbar current_user={@current_user} club_name={@club_name} />
|
||||||
<% end %>
|
<% end %>
|
||||||
<main class="px-4 py-20 sm:px-6 lg:px-16">
|
<main class="px-4 py-20 sm:px-6 lg:px-16">
|
||||||
<div class="mx-auto max-full space-y-4">
|
<div class="mx-auto max-full space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,18 @@ defmodule MvWeb.Layouts.Navbar do
|
||||||
required: true,
|
required: true,
|
||||||
doc: "The current user - navbar is only shown when user is present"
|
doc: "The current user - navbar is only shown when user is present"
|
||||||
|
|
||||||
def navbar(assigns) do
|
attr :club_name, :string,
|
||||||
club_name = get_club_name()
|
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()
|
||||||
assigns = assign(assigns, :club_name, club_name)
|
assigns = assign(assigns, :club_name, club_name)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<header class="navbar bg-base-100 shadow-sm">
|
<header class="navbar bg-base-100 shadow-sm">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<a class="btn btn-ghost text-xl">{@club_name}</a>
|
<a href="/members" class="btn btn-ghost text-xl">{@club_name}</a>
|
||||||
<ul class="menu menu-horizontal bg-base-200">
|
<ul class="menu menu-horizontal bg-base-200">
|
||||||
<li><.link navigate="/members">{gettext("Members")}</.link></li>
|
<li><.link navigate="/members">{gettext("Members")}</.link></li>
|
||||||
<li><.link navigate="/settings">{gettext("Settings")}</.link></li>
|
<li><.link navigate="/settings">{gettext("Settings")}</.link></li>
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
>
|
>
|
||||||
<.icon name="hero-users" class="h-4 w-4" />
|
<.icon name="hero-users" class="h-4 w-4" />
|
||||||
{gettext("All")}
|
{gettext("All payment statuses")}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li role="none">
|
<li role="none">
|
||||||
|
|
@ -140,7 +140,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
||||||
defp parse_filter(_), do: nil
|
defp parse_filter(_), do: nil
|
||||||
|
|
||||||
# Get display label for current filter
|
# Get display label for current filter
|
||||||
defp filter_label(nil), do: gettext("All")
|
defp filter_label(nil), do: gettext("All payment statuses")
|
||||||
defp filter_label(:paid), do: gettext("Paid")
|
defp filter_label(:paid), do: gettext("Paid")
|
||||||
defp filter_label(:not_paid), do: gettext("Not paid")
|
defp filter_label(:not_paid), do: gettext("Not paid")
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
- Create new custom field definitions
|
- Create new custom field definitions
|
||||||
- Edit existing custom fields
|
- Edit existing custom fields
|
||||||
- Select value type from supported types
|
- Select value type from supported types
|
||||||
- Set immutable and required flags
|
- Set required flag
|
||||||
- Real-time validation
|
- Real-time validation
|
||||||
|
|
||||||
## Props
|
## Props
|
||||||
|
|
@ -50,10 +50,10 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
label={gettext("Value type")}
|
label={gettext("Value type")}
|
||||||
options={
|
options={
|
||||||
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
|
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
|
||||||
|
|> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
||||||
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
|
|
||||||
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
||||||
<.input
|
<.input
|
||||||
field={@form[:show_in_overview]}
|
field={@form[:show_in_overview]}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
## Features
|
## Features
|
||||||
- List all custom fields
|
- List all custom fields
|
||||||
- Display type information (name, value type, description)
|
- Display type information (name, value type, description)
|
||||||
- Show immutable and required flags
|
- Show required flag
|
||||||
- Create new custom fields
|
- Create new custom fields
|
||||||
- Edit existing custom fields
|
- Edit existing custom fields
|
||||||
- Delete custom fields with confirmation (cascades to all custom field values)
|
- Delete custom fields with confirmation (cascades to all custom field values)
|
||||||
|
|
@ -30,7 +30,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
phx-click="new_custom_field"
|
phx-click="new_custom_field"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
>
|
>
|
||||||
<.icon name="hero-plus" /> {gettext("New Custom field")}
|
<.icon name="hero-plus" /> {gettext("New Custom Field")}
|
||||||
</.button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Settings")}
|
{gettext("Settings")}
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
|
|
@ -80,10 +80,13 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
def handle_event("save", %{"setting" => setting_params}, socket) do
|
||||||
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
|
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
|
||||||
{:ok, updated_settings} ->
|
{:ok, _updated_settings} ->
|
||||||
|
# Reload settings from database to ensure all dependent data is updated
|
||||||
|
{:ok, fresh_settings} = Membership.get_settings()
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:settings, updated_settings)
|
|> assign(:settings, fresh_settings)
|
||||||
|> put_flash(:info, gettext("Settings updated successfully"))
|
|> put_flash(:info, gettext("Settings updated successfully"))
|
||||||
|> assign_form()
|
|> assign_form()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,23 +3,29 @@
|
||||||
{gettext("Members")}
|
{gettext("Members")}
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button
|
<.button
|
||||||
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
|
class="secondary"
|
||||||
id="copy-emails-btn"
|
id="copy-emails-btn"
|
||||||
phx-hook="CopyToClipboard"
|
phx-hook="CopyToClipboard"
|
||||||
phx-click="copy_emails"
|
phx-click="copy_emails"
|
||||||
|
disabled={not Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
|
||||||
aria-label={gettext("Copy email addresses of selected members")}
|
aria-label={gettext("Copy email addresses of selected members")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-clipboard-document" />
|
<.icon name="hero-clipboard-document" />
|
||||||
{gettext("Copy emails")} ({Enum.count(@members, &MapSet.member?(@selected_members, &1.id))})
|
{gettext("Copy email addresses")} ({Enum.count(
|
||||||
|
@members,
|
||||||
|
&MapSet.member?(@selected_members, &1.id)
|
||||||
|
)})
|
||||||
</.button>
|
</.button>
|
||||||
<.button
|
<.button
|
||||||
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
|
class="secondary"
|
||||||
|
id="open-email-btn"
|
||||||
href={
|
href={
|
||||||
"mailto:?bcc=" <>
|
"mailto:?bcc=" <>
|
||||||
(MvWeb.MemberLive.Index.format_selected_member_emails(@members, @selected_members)
|
(MvWeb.MemberLive.Index.format_selected_member_emails(@members, @selected_members)
|
||||||
|> Enum.join(", ")
|
|> Enum.join(", ")
|
||||||
|> URI.encode())
|
|> URI.encode())
|
||||||
}
|
}
|
||||||
|
disabled={not Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
|
||||||
aria-label={gettext("Open email program with BCC recipients")}
|
aria-label={gettext("Open email program with BCC recipients")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-envelope" />
|
<.icon name="hero-envelope" />
|
||||||
|
|
|
||||||
8
mix.lock
8
mix.lock
|
|
@ -26,11 +26,11 @@
|
||||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||||
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
|
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
|
||||||
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
|
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
|
||||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||||
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
|
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
|
||||||
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||||
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||||
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
|
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
|
||||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
||||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||||
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
|
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
|
||||||
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
|
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
|
||||||
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
|
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
|
||||||
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
|
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
|
||||||
"live_debugger": {:hex, :live_debugger, "0.5.0", "95e0f7727d61010f7e9053923fb2a9416904a7533c2dfb36120e7684cba4c0af", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "73ebe95118d22aa402675f677abd731cb16b136d1b6ae5f4010441fb50753b14"},
|
"live_debugger": {:hex, :live_debugger, "0.5.0", "95e0f7727d61010f7e9053923fb2a9416904a7533c2dfb36120e7684cba4c0af", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "73ebe95118d22aa402675f677abd731cb16b136d1b6ae5f4010441fb50753b14"},
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||||
"thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
|
"thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
|
||||||
"tidewave": {:hex, :tidewave, "0.5.2", "f549acffe9daeed8b6b547c232c60de987770da7f827f9b3300140dfc465b102", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "34ab3ffee7e402f05cd1eae68d0e77ed0e0d1925677971ef83634247553e8afd"},
|
"tidewave": {:hex, :tidewave, "0.5.2", "f549acffe9daeed8b6b547c232c60de987770da7f827f9b3300140dfc465b102", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "34ab3ffee7e402f05cd1eae68d0e77ed0e0d1925677971ef83634247553e8afd"},
|
||||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||||
|
|
|
||||||
|
|
@ -282,11 +282,6 @@ msgstr "Benutzer*in bearbeiten"
|
||||||
msgid "Enabled"
|
msgid "Enabled"
|
||||||
msgstr "Aktiviert"
|
msgstr "Aktiviert"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Immutable"
|
|
||||||
msgstr "Unveränderlich"
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/navbar.ex
|
#: lib/mv_web/components/layouts/navbar.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Logout"
|
msgid "Logout"
|
||||||
|
|
@ -760,11 +755,6 @@ msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert"
|
||||||
msgid "Copy email addresses of selected members"
|
msgid "Copy email addresses of selected members"
|
||||||
msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
|
msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Copy emails"
|
|
||||||
msgstr "E-Mails kopieren"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
#: lib/mv_web/live/member_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No email addresses found"
|
msgid "No email addresses found"
|
||||||
|
|
@ -796,7 +786,6 @@ msgid "This field cannot be empty"
|
||||||
msgstr "Dieses Feld darf nicht leer bleiben"
|
msgstr "Dieses Feld darf nicht leer bleiben"
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: lib/mv_web/components/core_components.ex
|
||||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All"
|
msgid "All"
|
||||||
msgstr "Alle"
|
msgstr "Alle"
|
||||||
|
|
@ -1389,14 +1378,10 @@ msgid "Failed to delete custom field: %{error}"
|
||||||
msgstr "Konnte Feld nicht löschen: %{error}"
|
msgstr "Konnte Feld nicht löschen: %{error}"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "New Custom Field"
|
|
||||||
msgstr "Benutzerdefiniertes Feld speichern"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "New Custom field"
|
msgid "New Custom Field"
|
||||||
msgstr "Benutzerdefiniertes Feld speichern"
|
msgstr "Neues Benutzerdefiniertes Feld"
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -1438,6 +1423,16 @@ msgstr "Textfeld"
|
||||||
msgid "Yes/No-Selection"
|
msgid "Yes/No-Selection"
|
||||||
msgstr "Ja/Nein-Auswahl"
|
msgstr "Ja/Nein-Auswahl"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "All payment statuses"
|
||||||
|
msgstr "Jeder Zahlungs-Zustand"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Copy email addresses"
|
||||||
|
msgstr "E-Mail-Adressen kopieren"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Auto-generated identifier (immutable)"
|
#~ msgid "Auto-generated identifier (immutable)"
|
||||||
|
|
@ -1449,6 +1444,11 @@ msgstr "Ja/Nein-Auswahl"
|
||||||
#~ msgid "Birth Date"
|
#~ msgid "Birth Date"
|
||||||
#~ msgstr "Geburtsdatum"
|
#~ msgstr "Geburtsdatum"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Copy emails"
|
||||||
|
#~ msgstr "E-Mails kopieren"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_live/form.ex
|
#~ #: lib/mv_web/live/member_live/form.ex
|
||||||
#~ #: lib/mv_web/live/member_live/show.ex
|
#~ #: lib/mv_web/live/member_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
|
@ -1471,6 +1471,16 @@ msgstr "Ja/Nein-Auswahl"
|
||||||
#~ msgid "Id"
|
#~ msgid "Id"
|
||||||
#~ msgstr "ID"
|
#~ msgstr "ID"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Immutable"
|
||||||
|
#~ msgstr "Unveränderlich"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "New Custom field"
|
||||||
|
#~ msgstr "Benutzerdefiniertes Feld speichern"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/user_live/form.ex
|
#~ #: lib/mv_web/live/user_live/form.ex
|
||||||
#~ #: lib/mv_web/live/user_live/show.ex
|
#~ #: lib/mv_web/live/user_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
|
|
||||||
|
|
@ -283,11 +283,6 @@ msgstr ""
|
||||||
msgid "Enabled"
|
msgid "Enabled"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Immutable"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/navbar.ex
|
#: lib/mv_web/components/layouts/navbar.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Logout"
|
msgid "Logout"
|
||||||
|
|
@ -761,11 +756,6 @@ msgstr[1] ""
|
||||||
msgid "Copy email addresses of selected members"
|
msgid "Copy email addresses of selected members"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Copy emails"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
#: lib/mv_web/live/member_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No email addresses found"
|
msgid "No email addresses found"
|
||||||
|
|
@ -797,7 +787,6 @@ msgid "This field cannot be empty"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: lib/mv_web/components/core_components.ex
|
||||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All"
|
msgid "All"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1390,13 +1379,9 @@ msgid "Failed to delete custom field: %{error}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "New Custom Field"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "New Custom field"
|
msgid "New Custom Field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
|
@ -1438,3 +1423,13 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Yes/No-Selection"
|
msgid "Yes/No-Selection"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "All payment statuses"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Copy email addresses"
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -283,11 +283,6 @@ msgstr ""
|
||||||
msgid "Enabled"
|
msgid "Enabled"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Immutable"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/navbar.ex
|
#: lib/mv_web/components/layouts/navbar.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Logout"
|
msgid "Logout"
|
||||||
|
|
@ -761,11 +756,6 @@ msgstr[1] ""
|
||||||
msgid "Copy email addresses of selected members"
|
msgid "Copy email addresses of selected members"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Copy emails"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
#: lib/mv_web/live/member_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No email addresses found"
|
msgid "No email addresses found"
|
||||||
|
|
@ -797,7 +787,6 @@ msgid "This field cannot be empty"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: lib/mv_web/components/core_components.ex
|
||||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All"
|
msgid "All"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1390,13 +1379,9 @@ msgid "Failed to delete custom field: %{error}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "New Custom Field"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "New Custom field"
|
msgid "New Custom Field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
|
@ -1439,6 +1424,16 @@ msgstr ""
|
||||||
msgid "Yes/No-Selection"
|
msgid "Yes/No-Selection"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "All payment statuses"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Copy email addresses"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "Auto-generated identifier (immutable)"
|
#~ msgid "Auto-generated identifier (immutable)"
|
||||||
|
|
@ -1450,6 +1445,11 @@ msgstr ""
|
||||||
#~ msgid "Birth Date"
|
#~ msgid "Birth Date"
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Copy emails"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_live/form.ex
|
#~ #: lib/mv_web/live/member_live/form.ex
|
||||||
#~ #: lib/mv_web/live/member_live/show.ex
|
#~ #: lib/mv_web/live/member_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
|
@ -1471,6 +1471,16 @@ msgstr ""
|
||||||
#~ msgid "Id"
|
#~ msgid "Id"
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Immutable"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "New Custom field"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/user_live/show.ex
|
#~ #: lib/mv_web/live/user_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "Not set"
|
#~ msgid "Not set"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
defmodule Mv.Repo.Migrations.AddMembershipFeesTables do
|
||||||
|
@moduledoc """
|
||||||
|
Updates resources based on their most recent snapshots.
|
||||||
|
|
||||||
|
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
create table(:membership_fee_types, primary_key: false) do
|
||||||
|
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
|
||||||
|
add :name, :text, null: false
|
||||||
|
# Precision: 10 digits total, 2 decimal places (max 99,999,999.99)
|
||||||
|
add :amount, :numeric, null: false, precision: 10, scale: 2
|
||||||
|
add :interval, :text, null: false
|
||||||
|
add :description, :text
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:membership_fee_types, [:name],
|
||||||
|
name: "membership_fee_types_unique_name_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CHECK constraint for interval values (enforced at DB level)
|
||||||
|
create constraint(:membership_fee_types, :membership_fee_types_interval_check,
|
||||||
|
check: "interval IN ('monthly', 'quarterly', 'half_yearly', 'yearly')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CHECK constraint for non-negative amount
|
||||||
|
create constraint(:membership_fee_types, :membership_fee_types_amount_check,
|
||||||
|
check: "amount >= 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
create table(:membership_fee_cycles, primary_key: false) do
|
||||||
|
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
|
||||||
|
add :cycle_start, :date, null: false
|
||||||
|
# Precision: 10 digits total, 2 decimal places (max 99,999,999.99)
|
||||||
|
add :amount, :numeric, null: false, precision: 10, scale: 2
|
||||||
|
add :status, :text, null: false, default: "unpaid"
|
||||||
|
add :notes, :text
|
||||||
|
|
||||||
|
# CASCADE: Delete cycles when member is deleted
|
||||||
|
add :member_id,
|
||||||
|
references(:members,
|
||||||
|
column: :id,
|
||||||
|
name: "membership_fee_cycles_member_id_fkey",
|
||||||
|
type: :uuid,
|
||||||
|
prefix: "public",
|
||||||
|
on_delete: :delete_all
|
||||||
|
),
|
||||||
|
null: false
|
||||||
|
|
||||||
|
# RESTRICT: Cannot delete fee type if cycles reference it
|
||||||
|
add :membership_fee_type_id,
|
||||||
|
references(:membership_fee_types,
|
||||||
|
column: :id,
|
||||||
|
name: "membership_fee_cycles_membership_fee_type_id_fkey",
|
||||||
|
type: :uuid,
|
||||||
|
prefix: "public",
|
||||||
|
on_delete: :restrict
|
||||||
|
),
|
||||||
|
null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
# CHECK constraint for status values (enforced at DB level)
|
||||||
|
create constraint(:membership_fee_cycles, :membership_fee_cycles_status_check,
|
||||||
|
check: "status IN ('unpaid', 'paid', 'suspended')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CHECK constraint for non-negative amount
|
||||||
|
create constraint(:membership_fee_cycles, :membership_fee_cycles_amount_check,
|
||||||
|
check: "amount >= 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes as specified in architecture document
|
||||||
|
create index(:membership_fee_cycles, [:member_id])
|
||||||
|
create index(:membership_fee_cycles, [:membership_fee_type_id])
|
||||||
|
create index(:membership_fee_cycles, [:status])
|
||||||
|
create index(:membership_fee_cycles, [:cycle_start])
|
||||||
|
|
||||||
|
# Composite unique index: one cycle per member per cycle_start
|
||||||
|
create unique_index(:membership_fee_cycles, [:member_id, :cycle_start],
|
||||||
|
name: "membership_fee_cycles_unique_cycle_per_member_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extend members table with membership fee fields
|
||||||
|
alter table(:members) do
|
||||||
|
add :membership_fee_start_date, :date
|
||||||
|
|
||||||
|
# RESTRICT: Cannot delete fee type if members are assigned to it
|
||||||
|
add :membership_fee_type_id,
|
||||||
|
references(:membership_fee_types,
|
||||||
|
column: :id,
|
||||||
|
name: "members_membership_fee_type_id_fkey",
|
||||||
|
type: :uuid,
|
||||||
|
prefix: "public",
|
||||||
|
on_delete: :restrict
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Index for efficient lookup of members by fee type
|
||||||
|
create index(:members, [:membership_fee_type_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
# First: Remove members extension (depends on membership_fee_types)
|
||||||
|
drop_if_exists index(:members, [:membership_fee_type_id])
|
||||||
|
drop constraint(:members, "members_membership_fee_type_id_fkey")
|
||||||
|
|
||||||
|
alter table(:members) do
|
||||||
|
remove :membership_fee_type_id
|
||||||
|
remove :membership_fee_start_date
|
||||||
|
end
|
||||||
|
|
||||||
|
# Second: Drop cycles table (depends on membership_fee_types)
|
||||||
|
drop_if_exists unique_index(:membership_fee_cycles, [:member_id, :cycle_start],
|
||||||
|
name: "membership_fee_cycles_unique_cycle_per_member_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
drop_if_exists index(:membership_fee_cycles, [:cycle_start])
|
||||||
|
drop_if_exists index(:membership_fee_cycles, [:status])
|
||||||
|
drop_if_exists index(:membership_fee_cycles, [:membership_fee_type_id])
|
||||||
|
drop_if_exists index(:membership_fee_cycles, [:member_id])
|
||||||
|
|
||||||
|
drop constraint(:membership_fee_cycles, "membership_fee_cycles_member_id_fkey")
|
||||||
|
drop constraint(:membership_fee_cycles, "membership_fee_cycles_membership_fee_type_id_fkey")
|
||||||
|
drop_if_exists constraint(:membership_fee_cycles, :membership_fee_cycles_status_check)
|
||||||
|
drop_if_exists constraint(:membership_fee_cycles, :membership_fee_cycles_amount_check)
|
||||||
|
|
||||||
|
drop table(:membership_fee_cycles)
|
||||||
|
|
||||||
|
# Third: Drop fee types table
|
||||||
|
drop_if_exists unique_index(:membership_fee_types, [:name],
|
||||||
|
name: "membership_fee_types_unique_name_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
drop_if_exists constraint(:membership_fee_types, :membership_fee_types_interval_check)
|
||||||
|
drop_if_exists constraint(:membership_fee_types, :membership_fee_types_amount_check)
|
||||||
|
|
||||||
|
drop table(:membership_fee_types)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
defmodule Mv.Repo.Migrations.RemoveImmutableFromCustomFields do
|
||||||
|
@moduledoc """
|
||||||
|
Removes the immutable column from custom_fields table.
|
||||||
|
|
||||||
|
The immutable field is no longer needed in the custom field definition.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
alter table(:custom_fields) do
|
||||||
|
remove :immutable
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
alter table(:custom_fields) do
|
||||||
|
add :immutable, :boolean, null: false, default: false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -12,28 +12,24 @@ for attrs <- [
|
||||||
name: "String Field",
|
name: "String Field",
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
description: "Example for a field of type string",
|
description: "Example for a field of type string",
|
||||||
immutable: true,
|
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Date Field",
|
name: "Date Field",
|
||||||
value_type: :date,
|
value_type: :date,
|
||||||
description: "Example for a field of type date",
|
description: "Example for a field of type date",
|
||||||
immutable: true,
|
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Boolean Field",
|
name: "Boolean Field",
|
||||||
value_type: :boolean,
|
value_type: :boolean,
|
||||||
description: "Example for a field of type boolean",
|
description: "Example for a field of type boolean",
|
||||||
immutable: true,
|
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Email Field",
|
name: "Email Field",
|
||||||
value_type: :email,
|
value_type: :email,
|
||||||
description: "Example for a field of type email",
|
description: "Example for a field of type email",
|
||||||
immutable: true,
|
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
# Realistic custom fields
|
# Realistic custom fields
|
||||||
|
|
@ -41,56 +37,48 @@ for attrs <- [
|
||||||
name: "Membership Number",
|
name: "Membership Number",
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
description: "Unique membership identification number",
|
description: "Unique membership identification number",
|
||||||
immutable: false,
|
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Emergency Contact",
|
name: "Emergency Contact",
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
description: "Emergency contact person name and phone",
|
description: "Emergency contact person name and phone",
|
||||||
immutable: false,
|
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "T-Shirt Size",
|
name: "T-Shirt Size",
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
description: "T-Shirt size for events (XS, S, M, L, XL, XXL)",
|
description: "T-Shirt size for events (XS, S, M, L, XL, XXL)",
|
||||||
immutable: false,
|
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Newsletter Subscription",
|
name: "Newsletter Subscription",
|
||||||
value_type: :boolean,
|
value_type: :boolean,
|
||||||
description: "Whether member wants to receive newsletter",
|
description: "Whether member wants to receive newsletter",
|
||||||
immutable: false,
|
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Date of Last Medical Check",
|
name: "Date of Last Medical Check",
|
||||||
value_type: :date,
|
value_type: :date,
|
||||||
description: "Date of last medical examination",
|
description: "Date of last medical examination",
|
||||||
immutable: false,
|
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Secondary Email",
|
name: "Secondary Email",
|
||||||
value_type: :email,
|
value_type: :email,
|
||||||
description: "Alternative email address",
|
description: "Alternative email address",
|
||||||
immutable: false,
|
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Membership Type",
|
name: "Membership Type",
|
||||||
value_type: :string,
|
value_type: :string,
|
||||||
description: "Type of membership (e.g., Regular, Student, Senior)",
|
description: "Type of membership (e.g., Regular, Student, Senior)",
|
||||||
immutable: false,
|
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Parking Permit",
|
name: "Parking Permit",
|
||||||
value_type: :boolean,
|
value_type: :boolean,
|
||||||
description: "Whether member has parking permit",
|
description: "Whether member has parking permit",
|
||||||
immutable: false,
|
|
||||||
required: false
|
required: false
|
||||||
}
|
}
|
||||||
] do
|
] do
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"uuid_generate_v7()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "cycle_start",
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "amount",
|
||||||
|
"type": "decimal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "\"unpaid\"",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "status",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "notes",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": {
|
||||||
|
"deferrable": false,
|
||||||
|
"destination_attribute": "id",
|
||||||
|
"destination_attribute_default": null,
|
||||||
|
"destination_attribute_generated": null,
|
||||||
|
"index?": false,
|
||||||
|
"match_type": null,
|
||||||
|
"match_with": null,
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"name": "membership_fee_cycles_member_id_fkey",
|
||||||
|
"on_delete": null,
|
||||||
|
"on_update": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"schema": "public",
|
||||||
|
"table": "members"
|
||||||
|
},
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "member_id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": {
|
||||||
|
"deferrable": false,
|
||||||
|
"destination_attribute": "id",
|
||||||
|
"destination_attribute_default": null,
|
||||||
|
"destination_attribute_generated": null,
|
||||||
|
"index?": false,
|
||||||
|
"match_type": null,
|
||||||
|
"match_with": null,
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"name": "membership_fee_cycles_membership_fee_type_id_fkey",
|
||||||
|
"on_delete": null,
|
||||||
|
"on_update": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"schema": "public",
|
||||||
|
"table": "membership_fee_types"
|
||||||
|
},
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "membership_fee_type_id",
|
||||||
|
"type": "uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "43EA9EA365C09D423249AC4B6757A9AC07788C6C1E4BC7C50F8EF2CE01DE5684",
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "membership_fee_cycles_unique_cycle_per_member_index",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "member_id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "cycle_start"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "unique_cycle_per_member",
|
||||||
|
"nils_distinct?": true,
|
||||||
|
"where": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mv.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "membership_fee_cycles"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"uuid_generate_v7()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "name",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "amount",
|
||||||
|
"type": "decimal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "interval",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "description",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "E93A7A1EE90E5CEAC98CEA57C99C6330465716248642D5E2949EF578DE514E99",
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "membership_fee_types_unique_name_index",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "name"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "unique_name",
|
||||||
|
"nils_distinct?": true,
|
||||||
|
"where": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mv.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "membership_fee_types"
|
||||||
|
}
|
||||||
220
test/membership_fees/foreign_key_test.exs
Normal file
220
test/membership_fees/foreign_key_test.exs
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
defmodule Mv.MembershipFees.ForeignKeyTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for foreign key behaviors (CASCADE and RESTRICT).
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
|
||||||
|
describe "CASCADE behavior" do
|
||||||
|
test "deleting member deletes associated membership_fee_cycles" do
|
||||||
|
# Create member
|
||||||
|
{:ok, member} =
|
||||||
|
Ash.create(Member, %{
|
||||||
|
first_name: "Cascade",
|
||||||
|
last_name: "Test",
|
||||||
|
email: "cascade.test.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create fee type
|
||||||
|
{:ok, fee_type} =
|
||||||
|
Ash.create(MembershipFeeType, %{
|
||||||
|
name: "Cascade Test Fee #{System.unique_integer([:positive])}",
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :monthly
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create multiple cycles for this member
|
||||||
|
{:ok, cycle1} =
|
||||||
|
Ash.create(MembershipFeeCycle, %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, cycle2} =
|
||||||
|
Ash.create(MembershipFeeCycle, %{
|
||||||
|
cycle_start: ~D[2025-02-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
})
|
||||||
|
|
||||||
|
# Verify cycles exist
|
||||||
|
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle1.id)
|
||||||
|
assert {:ok, _} = Ash.get(MembershipFeeCycle, cycle2.id)
|
||||||
|
|
||||||
|
# Delete member
|
||||||
|
assert :ok = Ash.destroy(member)
|
||||||
|
|
||||||
|
# Verify cycles are also deleted (CASCADE)
|
||||||
|
# NotFound is wrapped in Ash.Error.Invalid
|
||||||
|
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeCycle, cycle1.id)
|
||||||
|
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeCycle, cycle2.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "RESTRICT behavior" do
|
||||||
|
test "cannot delete membership_fee_type if cycles reference it" do
|
||||||
|
# Create member
|
||||||
|
{:ok, member} =
|
||||||
|
Ash.create(Member, %{
|
||||||
|
first_name: "Restrict",
|
||||||
|
last_name: "Test",
|
||||||
|
email: "restrict.test.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create fee type
|
||||||
|
{:ok, fee_type} =
|
||||||
|
Ash.create(MembershipFeeType, %{
|
||||||
|
name: "Restrict Test Fee #{System.unique_integer([:positive])}",
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :monthly
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create a cycle referencing this fee type
|
||||||
|
{:ok, _cycle} =
|
||||||
|
Ash.create(MembershipFeeCycle, %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
})
|
||||||
|
|
||||||
|
# Try to delete fee type - should fail due to RESTRICT
|
||||||
|
assert {:error, error} = Ash.destroy(fee_type)
|
||||||
|
|
||||||
|
# Check that it's a foreign key violation error
|
||||||
|
assert is_struct(error, Ash.Error.Invalid) or is_struct(error, Ash.Error.Unknown)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can delete membership_fee_type if no cycles reference it" do
|
||||||
|
# Create fee type without any cycles
|
||||||
|
{:ok, fee_type} =
|
||||||
|
Ash.create(MembershipFeeType, %{
|
||||||
|
name: "Deletable Fee #{System.unique_integer([:positive])}",
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :monthly
|
||||||
|
})
|
||||||
|
|
||||||
|
# Should be able to delete
|
||||||
|
assert :ok = Ash.destroy(fee_type)
|
||||||
|
|
||||||
|
# Verify it's gone (NotFound is wrapped in Ash.Error.Invalid)
|
||||||
|
assert {:error, %Ash.Error.Invalid{}} = Ash.get(MembershipFeeType, fee_type.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot delete membership_fee_type if members reference it" do
|
||||||
|
# Create fee type
|
||||||
|
{:ok, fee_type} =
|
||||||
|
Ash.create(MembershipFeeType, %{
|
||||||
|
name: "Member Ref Fee #{System.unique_integer([:positive])}",
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :monthly
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create member with this fee type
|
||||||
|
{:ok, _member} =
|
||||||
|
Ash.create(Member, %{
|
||||||
|
first_name: "FeeType",
|
||||||
|
last_name: "Reference",
|
||||||
|
email: "feetype.ref.#{System.unique_integer([:positive])}@example.com",
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
})
|
||||||
|
|
||||||
|
# Try to delete fee type - should fail due to RESTRICT
|
||||||
|
assert {:error, error} = Ash.destroy(fee_type)
|
||||||
|
assert is_struct(error, Ash.Error.Invalid) or is_struct(error, Ash.Error.Unknown)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "member extensions" do
|
||||||
|
test "member can be created with membership_fee_type_id" do
|
||||||
|
# Create fee type first
|
||||||
|
{:ok, fee_type} =
|
||||||
|
Ash.create(MembershipFeeType, %{
|
||||||
|
name: "Create Test Fee #{System.unique_integer([:positive])}",
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :yearly
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create member with fee type
|
||||||
|
{:ok, member} =
|
||||||
|
Ash.create(Member, %{
|
||||||
|
first_name: "With",
|
||||||
|
last_name: "FeeType",
|
||||||
|
email: "with.feetype.#{System.unique_integer([:positive])}@example.com",
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
})
|
||||||
|
|
||||||
|
assert member.membership_fee_type_id == fee_type.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "member can be created with membership_fee_start_date" do
|
||||||
|
{:ok, member} =
|
||||||
|
Ash.create(Member, %{
|
||||||
|
first_name: "With",
|
||||||
|
last_name: "StartDate",
|
||||||
|
email: "with.startdate.#{System.unique_integer([:positive])}@example.com",
|
||||||
|
membership_fee_start_date: ~D[2025-01-01]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert member.membership_fee_start_date == ~D[2025-01-01]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "member can be created without membership fee fields" do
|
||||||
|
{:ok, member} =
|
||||||
|
Ash.create(Member, %{
|
||||||
|
first_name: "No",
|
||||||
|
last_name: "FeeFields",
|
||||||
|
email: "no.feefields.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert member.membership_fee_type_id == nil
|
||||||
|
assert member.membership_fee_start_date == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "member can be updated with membership_fee_type_id" do
|
||||||
|
# Create fee type
|
||||||
|
{:ok, fee_type} =
|
||||||
|
Ash.create(MembershipFeeType, %{
|
||||||
|
name: "Update Test Fee #{System.unique_integer([:positive])}",
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :yearly
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create member without fee type
|
||||||
|
{:ok, member} =
|
||||||
|
Ash.create(Member, %{
|
||||||
|
first_name: "Update",
|
||||||
|
last_name: "Test",
|
||||||
|
email: "update.test.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert member.membership_fee_type_id == nil
|
||||||
|
|
||||||
|
# Update member with fee type
|
||||||
|
{:ok, updated_member} = Ash.update(member, %{membership_fee_type_id: fee_type.id})
|
||||||
|
|
||||||
|
assert updated_member.membership_fee_type_id == fee_type.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "member can be updated with membership_fee_start_date" do
|
||||||
|
{:ok, member} =
|
||||||
|
Ash.create(Member, %{
|
||||||
|
first_name: "Start",
|
||||||
|
last_name: "Date",
|
||||||
|
email: "start.date.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert member.membership_fee_start_date == nil
|
||||||
|
|
||||||
|
{:ok, updated_member} = Ash.update(member, %{membership_fee_start_date: ~D[2025-06-01]})
|
||||||
|
|
||||||
|
assert updated_member.membership_fee_start_date == ~D[2025-06-01]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
282
test/membership_fees/membership_fee_cycle_test.exs
Normal file
282
test/membership_fees/membership_fee_cycle_test.exs
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for MembershipFeeCycle resource.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
|
||||||
|
setup do
|
||||||
|
# Create a member for testing
|
||||||
|
{:ok, member} =
|
||||||
|
Ash.create(Member, %{
|
||||||
|
first_name: "Test",
|
||||||
|
last_name: "Member",
|
||||||
|
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create a fee type for testing
|
||||||
|
{:ok, fee_type} =
|
||||||
|
Ash.create(MembershipFeeType, %{
|
||||||
|
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :monthly
|
||||||
|
})
|
||||||
|
|
||||||
|
%{member: member, fee_type: fee_type}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "create MembershipFeeCycle" do
|
||||||
|
test "can create cycle with valid attributes", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, %MembershipFeeCycle{} = cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert cycle.cycle_start == ~D[2025-01-01]
|
||||||
|
assert Decimal.equal?(cycle.amount, Decimal.new("100.00"))
|
||||||
|
assert cycle.member_id == member.id
|
||||||
|
assert cycle.membership_fee_type_id == fee_type.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create cycle with notes", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id,
|
||||||
|
notes: "First payment cycle"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert cycle.notes == "First payment cycle"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires cycle_start", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert error_on_field?(error, :cycle_start)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires amount", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert error_on_field?(error, :amount)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires member_id", %{fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert error_on_field?(error, :member_id) or error_on_field?(error, :member)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires membership_fee_type_id", %{member: member} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
|
||||||
|
assert error_on_field?(error, :membership_fee_type_id) or
|
||||||
|
error_on_field?(error, :membership_fee_type)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "status defaults to :unpaid", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert cycle.status == :unpaid
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates status enum values - unpaid", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id,
|
||||||
|
status: :unpaid
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert cycle.status == :unpaid
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates status enum values - paid", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-02-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id,
|
||||||
|
status: :paid
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert cycle.status == :paid
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates status enum values - suspended", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-03-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id,
|
||||||
|
status: :suspended
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert cycle.status == :suspended
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid status values", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id,
|
||||||
|
status: :cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert error_on_field?(error, :status)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects negative amount", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-04-01],
|
||||||
|
amount: Decimal.new("-50.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert error_on_field?(error, :amount)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts zero amount", %{member: member, fee_type: fee_type} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-05-01],
|
||||||
|
amount: Decimal.new("0.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, cycle} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert Decimal.equal?(cycle.amount, Decimal.new("0.00"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "uniqueness constraint" do
|
||||||
|
test "cannot create duplicate cycle for same member and cycle_start", %{
|
||||||
|
member: member,
|
||||||
|
fee_type: fee_type
|
||||||
|
} do
|
||||||
|
attrs = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeCycle, attrs)
|
||||||
|
|
||||||
|
# Should fail due to uniqueness constraint
|
||||||
|
assert is_struct(error, Ash.Error.Invalid)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create cycles for same member with different cycle_start", %{
|
||||||
|
member: member,
|
||||||
|
fee_type: fee_type
|
||||||
|
} do
|
||||||
|
attrs1 = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs2 = %{
|
||||||
|
cycle_start: ~D[2025-02-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs1)
|
||||||
|
assert {:ok, _cycle2} = Ash.create(MembershipFeeCycle, attrs2)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create cycles for different members with same cycle_start", %{fee_type: fee_type} do
|
||||||
|
{:ok, member1} =
|
||||||
|
Ash.create(Member, %{
|
||||||
|
first_name: "Member",
|
||||||
|
last_name: "One",
|
||||||
|
email: "member.one.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, member2} =
|
||||||
|
Ash.create(Member, %{
|
||||||
|
first_name: "Member",
|
||||||
|
last_name: "Two",
|
||||||
|
email: "member.two.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
attrs1 = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member1.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs2 = %{
|
||||||
|
cycle_start: ~D[2025-01-01],
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
member_id: member2.id,
|
||||||
|
membership_fee_type_id: fee_type.id
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, _cycle1} = Ash.create(MembershipFeeCycle, attrs1)
|
||||||
|
assert {:ok, _cycle2} = Ash.create(MembershipFeeCycle, attrs2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to check if an error occurred on a specific field
|
||||||
|
defp error_on_field?(%Ash.Error.Invalid{} = error, field) do
|
||||||
|
Enum.any?(error.errors, fn e ->
|
||||||
|
case e do
|
||||||
|
%{field: ^field} -> true
|
||||||
|
%{fields: fields} when is_list(fields) -> field in fields
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp error_on_field?(_, _), do: false
|
||||||
|
end
|
||||||
172
test/membership_fees/membership_fee_type_test.exs
Normal file
172
test/membership_fees/membership_fee_type_test.exs
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for MembershipFeeType resource.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
|
||||||
|
describe "create MembershipFeeType" do
|
||||||
|
test "can create membership fee type with valid attributes" do
|
||||||
|
attrs = %{
|
||||||
|
name: "Standard Membership",
|
||||||
|
amount: Decimal.new("120.00"),
|
||||||
|
interval: :yearly,
|
||||||
|
description: "Standard yearly membership fee"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, %MembershipFeeType{} = fee_type} =
|
||||||
|
Ash.create(MembershipFeeType, attrs)
|
||||||
|
|
||||||
|
assert fee_type.name == "Standard Membership"
|
||||||
|
assert Decimal.equal?(fee_type.amount, Decimal.new("120.00"))
|
||||||
|
assert fee_type.interval == :yearly
|
||||||
|
assert fee_type.description == "Standard yearly membership fee"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create membership fee type without description" do
|
||||||
|
attrs = %{
|
||||||
|
name: "Basic",
|
||||||
|
amount: Decimal.new("60.00"),
|
||||||
|
interval: :monthly
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, %MembershipFeeType{}} = Ash.create(MembershipFeeType, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires name" do
|
||||||
|
attrs = %{
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :yearly
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
||||||
|
assert error_on_field?(error, :name)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires amount" do
|
||||||
|
attrs = %{
|
||||||
|
name: "Test Fee",
|
||||||
|
interval: :yearly
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
||||||
|
assert error_on_field?(error, :amount)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires interval" do
|
||||||
|
attrs = %{
|
||||||
|
name: "Test Fee",
|
||||||
|
amount: Decimal.new("100.00")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
||||||
|
assert error_on_field?(error, :interval)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates interval enum values - monthly" do
|
||||||
|
attrs = %{name: "Monthly", amount: Decimal.new("10.00"), interval: :monthly}
|
||||||
|
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
|
||||||
|
assert fee_type.interval == :monthly
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates interval enum values - quarterly" do
|
||||||
|
attrs = %{name: "Quarterly", amount: Decimal.new("30.00"), interval: :quarterly}
|
||||||
|
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
|
||||||
|
assert fee_type.interval == :quarterly
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates interval enum values - half_yearly" do
|
||||||
|
attrs = %{name: "Half Yearly", amount: Decimal.new("60.00"), interval: :half_yearly}
|
||||||
|
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
|
||||||
|
assert fee_type.interval == :half_yearly
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates interval enum values - yearly" do
|
||||||
|
attrs = %{name: "Yearly", amount: Decimal.new("120.00"), interval: :yearly}
|
||||||
|
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
|
||||||
|
assert fee_type.interval == :yearly
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid interval values" do
|
||||||
|
attrs = %{name: "Invalid", amount: Decimal.new("100.00"), interval: :weekly}
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
||||||
|
assert error_on_field?(error, :interval)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "name must be unique" do
|
||||||
|
attrs = %{name: "Unique Name", amount: Decimal.new("100.00"), interval: :yearly}
|
||||||
|
|
||||||
|
assert {:ok, _} = Ash.create(MembershipFeeType, attrs)
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
||||||
|
|
||||||
|
# Check for uniqueness error
|
||||||
|
assert error_on_field?(error, :name)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects negative amount" do
|
||||||
|
attrs = %{name: "Negative Test", amount: Decimal.new("-10.00"), interval: :yearly}
|
||||||
|
assert {:error, error} = Ash.create(MembershipFeeType, attrs)
|
||||||
|
assert error_on_field?(error, :amount)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts zero amount" do
|
||||||
|
attrs = %{name: "Zero Amount", amount: Decimal.new("0.00"), interval: :yearly}
|
||||||
|
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
|
||||||
|
assert Decimal.equal?(fee_type.amount, Decimal.new("0.00"))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "amount respects scale of 2 decimal places" do
|
||||||
|
attrs = %{name: "Scale Test", amount: Decimal.new("100.50"), interval: :yearly}
|
||||||
|
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs)
|
||||||
|
assert Decimal.equal?(fee_type.amount, Decimal.new("100.50"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "update MembershipFeeType" do
|
||||||
|
setup do
|
||||||
|
{:ok, fee_type} =
|
||||||
|
Ash.create(MembershipFeeType, %{
|
||||||
|
name: "Original Name",
|
||||||
|
amount: Decimal.new("100.00"),
|
||||||
|
interval: :yearly,
|
||||||
|
description: "Original description"
|
||||||
|
})
|
||||||
|
|
||||||
|
%{fee_type: fee_type}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update name", %{fee_type: fee_type} do
|
||||||
|
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"})
|
||||||
|
assert updated.name == "Updated Name"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update amount", %{fee_type: fee_type} do
|
||||||
|
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")})
|
||||||
|
assert Decimal.equal?(updated.amount, Decimal.new("150.00"))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update description", %{fee_type: fee_type} do
|
||||||
|
assert {:ok, updated} = Ash.update(fee_type, %{description: "Updated description"})
|
||||||
|
assert updated.description == "Updated description"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can clear description", %{fee_type: fee_type} do
|
||||||
|
assert {:ok, updated} = Ash.update(fee_type, %{description: nil})
|
||||||
|
assert updated.description == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to check if an error occurred on a specific field
|
||||||
|
defp error_on_field?(%Ash.Error.Invalid{} = error, field) do
|
||||||
|
Enum.any?(error.errors, fn e ->
|
||||||
|
case e do
|
||||||
|
%{field: ^field} -> true
|
||||||
|
%{fields: fields} when is_list(fields) -> field in fields
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp error_on_field?(_, _), do: false
|
||||||
|
end
|
||||||
|
|
@ -99,8 +99,15 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
|
||||||
# Check that the sort button is a button element (keyboard accessible)
|
# Check that the sort button is a button element (keyboard accessible)
|
||||||
assert html =~ ~r/<button[^>]*data-testid=["']custom_field_#{field.id}["']/
|
assert html =~ ~r/<button[^>]*data-testid=["']custom_field_#{field.id}["']/
|
||||||
|
|
||||||
|
# Extract the button element for the custom field and check it doesn't have tabindex="-1"
|
||||||
|
button_match =
|
||||||
|
Regex.run(~r/<button[^>]*data-testid=["']custom_field_#{field.id}["'][^>]*>/, html)
|
||||||
|
|
||||||
|
assert button_match != nil, "Button with data-testid='custom_field_#{field.id}' not found"
|
||||||
|
|
||||||
|
button_html = List.first(button_match)
|
||||||
# Button should not have tabindex="-1" (which would remove from tab order)
|
# Button should not have tabindex="-1" (which would remove from tab order)
|
||||||
refute html =~ ~r/tabindex=["']-1["']/
|
refute button_html =~ ~r/tabindex=["']-1["']/
|
||||||
end
|
end
|
||||||
|
|
||||||
test "custom field column header has proper semantic structure", %{conn: conn, field: field} do
|
test "custom field column header has proper semantic structure", %{conn: conn, field: field} do
|
||||||
|
|
|
||||||
|
|
@ -410,14 +410,6 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
assert render(view) =~ "1"
|
assert render(view) =~ "1"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "copy button is not visible when no members are selected", %{conn: conn} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
|
||||||
|
|
||||||
# Ensure no members are selected (default state)
|
|
||||||
refute has_element?(view, "#copy-emails-btn")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "copy button is visible when members are selected", %{
|
test "copy button is visible when members are selected", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
member1: member1
|
member1: member1
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue