173 lines
5.2 KiB
Elixir
173 lines
5.2 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
|
|
|
|
custom_indexes do
|
|
# Serves the period-scoped unpaid-cycle count on the member overview:
|
|
# count of a member's cycles with status = :unpaid and cycle_end in the
|
|
# active period. The composite (member_id, status, cycle_end) lets the
|
|
# aggregate seek the per-member unpaid slice and range-scan cycle_end
|
|
# without touching paid/suspended rows.
|
|
index [:member_id, :status, :cycle_end],
|
|
name: "membership_fee_cycles_member_status_end_index"
|
|
end
|
|
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
|