diff --git a/.tool-versions b/.tool-versions index 98239f3..489262a 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ elixir 1.18.3-otp-27 erlang 27.3.4 -just 1.43.1 +just 1.45.0 diff --git a/config/config.exs b/config/config.exs index 17891e0..053fc19 100644 --- a/config/config.exs +++ b/config/config.exs @@ -49,7 +49,7 @@ config :spark, config :mv, ecto_repos: [Mv.Repo], generators: [timestamp_type: :utc_datetime], - ash_domains: [Mv.Membership, Mv.Accounts] + ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees] # Configures the endpoint config :mv, MvWeb.Endpoint, diff --git a/docker-compose.yml b/docker-compose.yml index b10ab22..feff34c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,7 @@ services: rauthy: container_name: rauthy-dev - image: ghcr.io/sebadob/rauthy:0.32.0 + image: ghcr.io/sebadob/rauthy:0.33.1 environment: - LOCAL_TEST=true - SMTP_URL=mailcrab diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index b620830..f97463e 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -6,8 +6,8 @@ // - https://dbdocs.io // - VS Code Extensions: "DBML Language" or "dbdiagram.io" // -// Version: 1.2 -// Last Updated: 2025-11-13 +// Version: 1.3 +// Last Updated: 2025-12-11 Project mila_membership_management { database_type: 'PostgreSQL' @@ -27,6 +27,7 @@ Project mila_membership_management { ## Domains: - **Accounts**: User authentication and session management - **Membership**: Club member data and custom fields + - **MembershipFees**: Membership fee types and billing cycles ## Required PostgreSQL Extensions: - uuid-ossp (UUID generation) @@ -132,6 +133,8 @@ Table members { house_number text [null, note: 'House number'] postal_code text [null, note: '5-digit German postal code'] search_vector tsvector [null, note: 'Full-text search index (auto-generated)'] + membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - assigned fee type'] + membership_fee_start_date date [null, note: 'Date from which membership fees should be calculated'] indexes { email [unique, name: 'members_unique_email_index'] @@ -146,6 +149,7 @@ Table members { last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting'] join_date [name: 'members_join_date_idx', note: 'B-tree index for date filters'] (paid) [name: 'members_paid_idx', type: btree, note: 'Partial index WHERE paid IS NOT NULL'] + membership_fee_type_id [name: 'members_membership_fee_type_id_index', note: 'B-tree index for fee type lookups'] } Note: ''' @@ -178,6 +182,8 @@ Table members { **Relationships:** - Optional 1:1 with users (0..1 ↔ 0..1) - authentication account - 1:N with custom_field_values (custom dynamic fields) + - Optional N:1 with membership_fee_types - assigned fee type + - 1:N with membership_fee_cycles - billing history **Validation Rules:** - first_name, last_name: min 1 character @@ -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 // ============================================ @@ -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 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 // ============================================ @@ -328,6 +442,21 @@ Enum token_purpose { email_confirmation [note: 'Email verification tokens'] } +// Billing interval for membership fee types +Enum membership_fee_interval { + monthly [note: '1st to last day of month'] + quarterly [note: '1st of Jan/Apr/Jul/Oct to last day of quarter'] + half_yearly [note: '1st of Jan/Jul to last day of half'] + yearly [note: 'Jan 1 to Dec 31'] +} + +// Payment status for membership fee cycles +Enum membership_fee_status { + unpaid [note: 'Payment pending (default)'] + paid [note: 'Payment received'] + suspended [note: 'Payment suspended'] +} + // ============================================ // TABLE GROUPS // ============================================ @@ -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 + ''' +} + diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index 5b7514c..18b8154 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -12,7 +12,6 @@ defmodule Mv.Membership.CustomField do - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`) - `description` - Optional human-readable description - - `immutable` - If true, custom field values cannot be changed after creation - `required` - If true, all members must have this custom field (future feature) - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted @@ -60,10 +59,10 @@ defmodule Mv.Membership.CustomField do actions do 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 - 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 validate string_length(:slug, min: 1) end @@ -113,10 +112,6 @@ defmodule Mv.Membership.CustomField do trim?: true ] - attribute :immutable, :boolean, - default: false, - allow_nil?: false - attribute :required, :boolean, default: false, allow_nil?: false diff --git a/lib/membership/member.ex b/lib/membership/member.ex index d29a759..5816d19 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -79,7 +79,8 @@ defmodule Mv.Membership.Member do # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true - accept @member_fields + # 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) @@ -112,7 +113,8 @@ defmodule Mv.Membership.Member do # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true - accept @member_fields + # 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) @@ -394,6 +396,15 @@ defmodule Mv.Membership.Member do writable?: false, public?: false, select_by_default?: false + + # Membership fee fields + # membership_fee_start_date: Date from which membership fees should be calculated + # If nil, calculated from join_date + global setting + attribute :membership_fee_start_date, :date do + allow_nil? true + public? true + description "Date from which membership fees should be calculated" + end end relationships do @@ -402,6 +413,16 @@ defmodule Mv.Membership.Member do # This references the User's member_id attribute # The relationship is optional (allow_nil? true by default) has_one :user, Mv.Accounts.User + + # Membership fee relationships + # belongs_to: The fee type assigned to this member + # Optional for MVP - can be nil if no fee type assigned yet + belongs_to :membership_fee_type, Mv.MembershipFees.MembershipFeeType do + allow_nil? true + end + + # has_many: All fee cycles for this member + has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle end # Define identities for upsert operations diff --git a/lib/membership_fees/membership_fee_cycle.ex b/lib/membership_fees/membership_fee_cycle.ex new file mode 100644 index 0000000..4c47623 --- /dev/null +++ b/lib/membership_fees/membership_fee_cycle.ex @@ -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 diff --git a/lib/membership_fees/membership_fee_type.ex b/lib/membership_fees/membership_fee_type.ex new file mode 100644 index 0000000..877a385 --- /dev/null +++ b/lib/membership_fees/membership_fee_type.ex @@ -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 diff --git a/lib/membership_fees/membership_fees.ex b/lib/membership_fees/membership_fees.ex new file mode 100644 index 0000000..7a2833a --- /dev/null +++ b/lib/membership_fees/membership_fees.ex @@ -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 diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 7bfb07b..843ad2b 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -15,7 +15,8 @@ defmodule Mv.Constants do :city, :street, :house_number, - :postal_code + :postal_code, + :membership_fee_start_date ] @custom_field_prefix "custom_field_" diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index a23381d..f0a9fdb 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -95,9 +95,11 @@ defmodule MvWeb.CoreComponents do <.button>Send! <.button phx-click="go" variant="primary">Send! <.button navigate={~p"/"}>Home + <.button disabled={true}>Disabled """ attr :rest, :global, include: ~w(href navigate patch method) attr :variant, :string, values: ~w(primary) + attr :disabled, :boolean, default: false, doc: "Whether the button is disabled" slot :inner_block, required: true def button(%{rest: rest} = assigns) do @@ -105,14 +107,34 @@ defmodule MvWeb.CoreComponents do assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant])) if rest[:href] || rest[:navigate] || rest[:patch] do + # For links, we can't use disabled attribute, so we use btn-disabled class + # DaisyUI's btn-disabled provides the same styling as :disabled on buttons + link_class = + if assigns[:disabled], + do: ["btn", assigns.class, "btn-disabled"], + else: ["btn", assigns.class] + + # Prevent interaction when disabled + 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""" - <.link class={["btn", @class]} {@rest}> + <.link class={@link_class} {@link_attrs}> {render_slot(@inner_block)} """ else ~H""" - """ diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 487a01f..86090a8 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -36,12 +36,16 @@ defmodule MvWeb.Layouts do default: nil, doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)" + attr :club_name, :string, + default: nil, + doc: "optional club name to pass to navbar" + slot :inner_block, required: true def app(assigns) do ~H""" <%= if @current_user do %> - <.navbar current_user={@current_user} /> + <.navbar current_user={@current_user} club_name={@club_name} /> <% end %>
diff --git a/lib/mv_web/components/layouts/navbar.ex b/lib/mv_web/components/layouts/navbar.ex index 4246c99..1ff589b 100644 --- a/lib/mv_web/components/layouts/navbar.ex +++ b/lib/mv_web/components/layouts/navbar.ex @@ -12,15 +12,18 @@ defmodule MvWeb.Layouts.Navbar do required: true, doc: "The current user - navbar is only shown when user is present" - def navbar(assigns) do - club_name = get_club_name() + attr :club_name, :string, + default: nil, + doc: "Optional club name - if not provided, will be loaded from database" + def navbar(assigns) do + club_name = assigns[:club_name] || get_club_name() assigns = assign(assigns, :club_name, club_name) ~H"""
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 0b3ec1c..9bce04b 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -37,7 +37,7 @@ defmodule MvWeb.GlobalSettingsLive do @impl true def render(assigns) do ~H""" - + <.header> {gettext("Settings")} <:subtitle> @@ -80,10 +80,13 @@ defmodule MvWeb.GlobalSettingsLive do @impl true def handle_event("save", %{"setting" => setting_params}, socket) 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 - |> assign(:settings, updated_settings) + |> assign(:settings, fresh_settings) |> put_flash(:info, gettext("Settings updated successfully")) |> assign_form() diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index fbeb416..8e8d18b 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -3,23 +3,29 @@ {gettext("Members")} <:actions> <.button - :if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))} + class="secondary" id="copy-emails-btn" phx-hook="CopyToClipboard" phx-click="copy_emails" + disabled={not Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))} aria-label={gettext("Copy email addresses of selected members")} > <.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 - :if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))} + class="secondary" + id="open-email-btn" href={ "mailto:?bcc=" <> (MvWeb.MemberLive.Index.format_selected_member_emails(@members, @selected_members) |> Enum.join(", ") |> URI.encode()) } + disabled={not Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))} aria-label={gettext("Open email program with BCC recipients")} > <.icon name="hero-envelope" /> diff --git a/mix.lock b/mix.lock index a1c7505..1dd3d48 100644 --- a/mix.lock +++ b/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"}, "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"}, - "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"}, "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"}, - "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"}, "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"}, @@ -39,7 +39,7 @@ "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"}, "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"}, "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"}, @@ -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_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"}, - "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"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 25f685d..81653a4 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -282,11 +282,6 @@ msgstr "Benutzer*in bearbeiten" msgid "Enabled" 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 #, elixir-autogen, elixir-format msgid "Logout" @@ -760,11 +755,6 @@ msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert" msgid "Copy email addresses of selected members" 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 #, elixir-autogen, elixir-format msgid "No email addresses found" @@ -796,7 +786,6 @@ msgid "This field cannot be empty" msgstr "Dieses Feld darf nicht leer bleiben" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/payment_filter_component.ex #, elixir-autogen, elixir-format msgid "All" msgstr "Alle" @@ -1389,14 +1378,10 @@ msgid "Failed to delete custom field: %{error}" msgstr "Konnte Feld nicht löschen: %{error}" #: 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 #, elixir-autogen, elixir-format, fuzzy -msgid "New Custom field" -msgstr "Benutzerdefiniertes Feld speichern" +msgid "New Custom Field" +msgstr "Neues Benutzerdefiniertes Feld" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format @@ -1438,6 +1423,16 @@ msgstr "Textfeld" msgid "Yes/No-Selection" 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 #~ #, elixir-autogen, elixir-format #~ msgid "Auto-generated identifier (immutable)" @@ -1449,6 +1444,11 @@ msgstr "Ja/Nein-Auswahl" #~ msgid "Birth Date" #~ 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/show.ex #~ #, elixir-autogen, elixir-format @@ -1471,6 +1471,16 @@ msgstr "Ja/Nein-Auswahl" #~ msgid "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/show.ex #~ #, elixir-autogen, elixir-format diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a7ab36b..451e2b5 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -283,11 +283,6 @@ msgstr "" msgid "Enabled" 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 #, elixir-autogen, elixir-format msgid "Logout" @@ -761,11 +756,6 @@ msgstr[1] "" msgid "Copy email addresses of selected members" 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 #, elixir-autogen, elixir-format msgid "No email addresses found" @@ -797,7 +787,6 @@ msgid "This field cannot be empty" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/payment_filter_component.ex #, elixir-autogen, elixir-format msgid "All" msgstr "" @@ -1390,13 +1379,9 @@ msgid "Failed to delete custom field: %{error}" msgstr "" #: 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 #, elixir-autogen, elixir-format -msgid "New Custom field" +msgid "New Custom Field" msgstr "" #: lib/mv_web/live/global_settings_live.ex @@ -1438,3 +1423,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Yes/No-Selection" 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 "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index e2a1876..5995656 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -283,11 +283,6 @@ msgstr "" msgid "Enabled" 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 #, elixir-autogen, elixir-format msgid "Logout" @@ -761,11 +756,6 @@ msgstr[1] "" msgid "Copy email addresses of selected members" 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 #, elixir-autogen, elixir-format msgid "No email addresses found" @@ -797,7 +787,6 @@ msgid "This field cannot be empty" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/payment_filter_component.ex #, elixir-autogen, elixir-format msgid "All" msgstr "" @@ -1390,13 +1379,9 @@ msgid "Failed to delete custom field: %{error}" msgstr "" #: 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 #, elixir-autogen, elixir-format, fuzzy -msgid "New Custom field" +msgid "New Custom Field" msgstr "" #: lib/mv_web/live/global_settings_live.ex @@ -1439,6 +1424,16 @@ msgstr "" msgid "Yes/No-Selection" 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 #~ #, elixir-autogen, elixir-format #~ msgid "Auto-generated identifier (immutable)" @@ -1450,6 +1445,11 @@ msgstr "" #~ msgid "Birth Date" #~ 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/show.ex #~ #, elixir-autogen, elixir-format @@ -1471,6 +1471,16 @@ msgstr "" #~ msgid "Id" #~ 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 #~ #, elixir-autogen, elixir-format, fuzzy #~ msgid "Not set" diff --git a/priv/repo/migrations/20251211151449_add_membership_fees_tables.exs b/priv/repo/migrations/20251211151449_add_membership_fees_tables.exs new file mode 100644 index 0000000..e050521 --- /dev/null +++ b/priv/repo/migrations/20251211151449_add_membership_fees_tables.exs @@ -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 diff --git a/priv/repo/migrations/20251211172549_remove_immutable_from_custom_fields.exs b/priv/repo/migrations/20251211172549_remove_immutable_from_custom_fields.exs new file mode 100644 index 0000000..9d25d49 --- /dev/null +++ b/priv/repo/migrations/20251211172549_remove_immutable_from_custom_fields.exs @@ -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 diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index bec9006..10af66b 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -12,28 +12,24 @@ for attrs <- [ name: "String Field", value_type: :string, description: "Example for a field of type string", - immutable: true, required: false }, %{ name: "Date Field", value_type: :date, description: "Example for a field of type date", - immutable: true, required: false }, %{ name: "Boolean Field", value_type: :boolean, description: "Example for a field of type boolean", - immutable: true, required: false }, %{ name: "Email Field", value_type: :email, description: "Example for a field of type email", - immutable: true, required: false }, # Realistic custom fields @@ -41,56 +37,48 @@ for attrs <- [ name: "Membership Number", value_type: :string, description: "Unique membership identification number", - immutable: false, required: false }, %{ name: "Emergency Contact", value_type: :string, description: "Emergency contact person name and phone", - immutable: false, required: false }, %{ name: "T-Shirt Size", value_type: :string, description: "T-Shirt size for events (XS, S, M, L, XL, XXL)", - immutable: false, required: false }, %{ name: "Newsletter Subscription", value_type: :boolean, description: "Whether member wants to receive newsletter", - immutable: false, required: false }, %{ name: "Date of Last Medical Check", value_type: :date, description: "Date of last medical examination", - immutable: false, required: false }, %{ name: "Secondary Email", value_type: :email, description: "Alternative email address", - immutable: false, required: false }, %{ name: "Membership Type", value_type: :string, description: "Type of membership (e.g., Regular, Student, Senior)", - immutable: false, required: false }, %{ name: "Parking Permit", value_type: :boolean, description: "Whether member has parking permit", - immutable: false, required: false } ] do diff --git a/priv/resource_snapshots/repo/membership_fee_cycles/20251211151449.json b/priv/resource_snapshots/repo/membership_fee_cycles/20251211151449.json new file mode 100644 index 0000000..d0dd8fa --- /dev/null +++ b/priv/resource_snapshots/repo/membership_fee_cycles/20251211151449.json @@ -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" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/membership_fee_types/20251211151449.json b/priv/resource_snapshots/repo/membership_fee_types/20251211151449.json new file mode 100644 index 0000000..037b02d --- /dev/null +++ b/priv/resource_snapshots/repo/membership_fee_types/20251211151449.json @@ -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" +} \ No newline at end of file diff --git a/test/membership_fees/foreign_key_test.exs b/test/membership_fees/foreign_key_test.exs new file mode 100644 index 0000000..dd164a7 --- /dev/null +++ b/test/membership_fees/foreign_key_test.exs @@ -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 diff --git a/test/membership_fees/membership_fee_cycle_test.exs b/test/membership_fees/membership_fee_cycle_test.exs new file mode 100644 index 0000000..ca59e26 --- /dev/null +++ b/test/membership_fees/membership_fee_cycle_test.exs @@ -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 diff --git a/test/membership_fees/membership_fee_type_test.exs b/test/membership_fees/membership_fee_type_test.exs new file mode 100644 index 0000000..9ca6f0a --- /dev/null +++ b/test/membership_fees/membership_fee_type_test.exs @@ -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 diff --git a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs index cfe3145..252de17 100644 --- a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs +++ b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs @@ -99,8 +99,15 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do # Check that the sort button is a button element (keyboard accessible) assert html =~ ~r/]*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/]*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) - refute html =~ ~r/tabindex=["']-1["']/ + refute button_html =~ ~r/tabindex=["']-1["']/ end test "custom field column header has proper semantic structure", %{conn: conn, field: field} do diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 30b61c7..5b826bd 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -410,14 +410,6 @@ defmodule MvWeb.MemberLive.IndexTest do assert render(view) =~ "1" 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", %{ conn: conn, member1: member1