Store the cycle's end date alongside its start so the payment-status filter can be a plain date comparison in SQL instead of loading every cycle and classifying it in memory. The value is fixed when the cycle is created (the fee type's interval is immutable and the update action never changes the start date), so the denormalized column can never drift from the computed end and needs no write-time reconciliation.
163 lines
4.7 KiB
Elixir
163 lines
4.7 KiB
Elixir
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
|
|
- **Denormalized cycle_end**: Set at create from cycle_start + interval (fee type).
|
|
Because the fee type interval and the cycle_start are immutable after create,
|
|
cycle_end never drifts, so it can be filtered/sorted in SQL.
|
|
- **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,
|
|
authorizers: [Ash.Policy.Authorizer]
|
|
|
|
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]
|
|
|
|
argument :interval, :atom do
|
|
allow_nil? true
|
|
|
|
description "Optional pre-resolved fee-type interval; when given, SetCycleEnd skips the fee-type lookup"
|
|
end
|
|
|
|
change Mv.MembershipFees.MembershipFeeCycle.Changes.SetCycleEnd
|
|
end
|
|
|
|
update :update do
|
|
primary? true
|
|
accept [:status, :notes, :amount]
|
|
end
|
|
|
|
update :mark_as_paid do
|
|
description "Mark cycle as paid"
|
|
require_atomic? false
|
|
accept [:notes]
|
|
|
|
change fn changeset, _context ->
|
|
Ash.Changeset.force_change_attribute(changeset, :status, :paid)
|
|
end
|
|
end
|
|
|
|
update :mark_as_suspended do
|
|
description "Mark cycle as suspended"
|
|
require_atomic? false
|
|
accept [:notes]
|
|
|
|
change fn changeset, _context ->
|
|
Ash.Changeset.force_change_attribute(changeset, :status, :suspended)
|
|
end
|
|
end
|
|
|
|
update :mark_as_unpaid do
|
|
description "Mark cycle as unpaid (for error correction)"
|
|
require_atomic? false
|
|
accept [:notes]
|
|
|
|
change fn changeset, _context ->
|
|
Ash.Changeset.force_change_attribute(changeset, :status, :unpaid)
|
|
end
|
|
end
|
|
end
|
|
|
|
# READ: bypass for own_data (:linked) then HasPermission for :all; create/update/destroy: HasPermission only.
|
|
policies do
|
|
bypass action_type(:read) do
|
|
description "own_data: read only cycles where member_id == actor.member_id"
|
|
authorize_if {Mv.Authorization.Checks.ReadLinkedForOwnData, member_id_field: :member_id}
|
|
end
|
|
|
|
policy action_type([:read, :create, :update, :destroy]) do
|
|
description "Check permissions from role (all read; normal_user and admin create/update/destroy)"
|
|
authorize_if Mv.Authorization.Checks.HasPermission
|
|
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 :cycle_end, :date do
|
|
allow_nil? false
|
|
public? true
|
|
|
|
description "Denormalized end date of the cycle (cycle_start + interval), set at create"
|
|
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
|