Membership Fee - Database Schema & Ash Domain Foundation closes #275 #283
14 changed files with 1405 additions and 7 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 decimal [not null, note: 'Fee amount in default currency']
|
||||||
|
interval text [not null, note: 'Billing interval: 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 decimal [not null, note: 'Fee amount for this cycle (historical record)']
|
||||||
|
status text [not null, default: 'unpaid', note: 'Payment status: 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
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
99
lib/membership_fees/membership_fee_cycle.ex
Normal file
99
lib/membership_fees/membership_fee_cycle.ex
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
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)"
|
||||||
|
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
|
||||||
91
lib/membership_fees/membership_fee_type.ex
Normal file
91
lib/membership_fees/membership_fee_type.ex
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
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"
|
||||||
|
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
|
||||||
|
carla
commented
I do not get why we need that as constant / member field? Isn't it always the join date + next cycle? I do not get why we need that as constant / member field? Isn't it always the join date + next cycle?
|
|||||||
]
|
]
|
||||||
|
|
||||||
@custom_field_prefix "custom_field_"
|
@custom_field_prefix "custom_field_"
|
||||||
|
|
|
||||||
2
mix.lock
2
mix.lock
|
|
@ -30,7 +30,7 @@
|
||||||
"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"},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
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
|
||||||
|
add :amount, :decimal, null: false
|
||||||
|
add :interval, :text, null: false
|
||||||
|
add :description, :text
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:membership_fee_types, [:name],
|
||||||
|
name: "membership_fee_types_unique_name_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
add :amount, :decimal, null: false
|
||||||
|
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
|
||||||
|
|
||||||
|
# Indexes as specified in architecture document
|
||||||
|
carla
commented
What if members paid cycles in the past for a specific type and now this type changes? What if members paid cycles in the past for a specific type and now this type changes?
|
|||||||
|
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 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 table(:membership_fee_types)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -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
|
||||||
258
test/membership_fees/membership_fee_cycle_test.exs
Normal file
258
test/membership_fees/membership_fee_cycle_test.exs
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
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
|
||||||
|
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
|
||||||
154
test/membership_fees/membership_fee_type_test.exs
Normal file
154
test/membership_fees/membership_fee_type_test.exs
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
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
|
||||||
|
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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue
One member of the pilot clubs said they have the case that many members have individual fees. That would be possible that we define an amount on member level let's say?