diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index 75f1312..f97463e 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -294,8 +294,8 @@ Table custom_fields { 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)'] + 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 { @@ -335,8 +335,8 @@ Table membership_fee_types { 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'] + 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'] diff --git a/lib/membership_fees/membership_fee_cycle.ex b/lib/membership_fees/membership_fee_cycle.ex index 17539a1..98cd08d 100644 --- a/lib/membership_fees/membership_fee_cycle.ex +++ b/lib/membership_fees/membership_fee_cycle.ex @@ -65,7 +65,8 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do attribute :amount, :decimal do allow_nil? false public? true - description "Fee amount for this cycle (stored for audit trail)" + 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 diff --git a/lib/membership_fees/membership_fee_type.ex b/lib/membership_fees/membership_fee_type.ex index 3d1cd68..877a385 100644 --- a/lib/membership_fees/membership_fee_type.ex +++ b/lib/membership_fees/membership_fee_type.ex @@ -63,7 +63,8 @@ defmodule Mv.MembershipFees.MembershipFeeType do attribute :amount, :decimal do allow_nil? false public? true - description "Fee amount in default currency" + description "Fee amount in default currency (non-negative, max 2 decimal places)" + constraints min: 0, scale: 2 end attribute :interval, :atom do diff --git a/priv/repo/migrations/20251211151449_add_membership_fees_tables.exs b/priv/repo/migrations/20251211151449_add_membership_fees_tables.exs index eadc214..09af7c9 100644 --- a/priv/repo/migrations/20251211151449_add_membership_fees_tables.exs +++ b/priv/repo/migrations/20251211151449_add_membership_fees_tables.exs @@ -11,7 +11,8 @@ defmodule Mv.Repo.Migrations.AddMembershipFeesTables 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 + # 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 @@ -20,10 +21,21 @@ defmodule Mv.Repo.Migrations.AddMembershipFeesTables do 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 - add :amount, :decimal, 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 @@ -50,6 +62,16 @@ defmodule Mv.Repo.Migrations.AddMembershipFeesTables do 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]) @@ -102,6 +124,8 @@ defmodule Mv.Repo.Migrations.AddMembershipFeesTables do 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) @@ -109,6 +133,8 @@ defmodule Mv.Repo.Migrations.AddMembershipFeesTables do 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 diff --git a/test/membership_fees/membership_fee_cycle_test.exs b/test/membership_fees/membership_fee_cycle_test.exs index 570ed88..ca59e26 100644 --- a/test/membership_fees/membership_fee_cycle_test.exs +++ b/test/membership_fees/membership_fee_cycle_test.exs @@ -166,6 +166,30 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do 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 diff --git a/test/membership_fees/membership_fee_type_test.exs b/test/membership_fees/membership_fee_type_test.exs index 5d053cc..9ca6f0a 100644 --- a/test/membership_fees/membership_fee_type_test.exs +++ b/test/membership_fees/membership_fee_type_test.exs @@ -103,6 +103,24 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do # 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