mitgliederverwaltung/lib/membership_fees/membership_fee_cycle.ex
Simon b745b13ca5 feat(membership-fees): denormalize cycle_end so fee status filters run in the database
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.
2026-07-03 11:04:43 +02:00

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