Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ When changing a model or controller, check whether these related files need upda
- Prefer early returns and guard clauses
- Avoid unnecessary and/or complex conditionals
- Prefer constants and scopes over magic strings
- Avoid Rails `enum` β€” prefer plain string columns constrained by a constant + `validates inclusion`
- Use safe navigation (`&.`) where appropriate
- Use `presence` over blank checks
- Use `Arel.sql` for raw SQL in order clauses
Expand Down
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ This codebase (Rails 8.1)
| `app/models/` | ActiveRecord models | ~78 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~29 files |
| `app/jobs/` | SolidQueue background jobs | 3 files |
| `app/models/concerns/` | Shared model modules | 15 concerns |
| `app/models/concerns/` | Shared model modules | 16 concerns |

### Presentation

Expand Down Expand Up @@ -104,6 +104,8 @@ This codebase (Rails 8.1)
| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage |
| `Grant` | Donated funds (polymorphic `donor`: Organization or Person) with eligibility criteria, tasks, deadlines; parent of `Scholarship`. Scholarship totals cannot exceed the grant amount |
| `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation` |
| `ProfessionalLicense` | A license a `Person` holds (`number`, `kind`, `issuing_state`, `expires_on`); a null `number` is a placeholder. `find_or_create_for` keeps one license per (person, number) |
| `ContinuingEducationRegistration` | A registrant's CE for one event against one `ProfessionalLicense`; billable `allocatable` (`Registerable`) with stored `hours` + `cost_cents` (default from the event). Payment is computed (no stored status); the certificate is delivered via `certificate_sent_at` and gated by its own `certificate_available?` |
| `Report` | STI base class for MonthlyReport |
| `WorkshopLog` | Standalone model for workshop log submissions (attendance, form fields) |

Expand Down Expand Up @@ -133,6 +135,7 @@ This codebase (Rails 8.1)
| `NameFilterable` | Name-based filtering |
| `Publishable` | `published`, `publicly_visible` scopes |
| `PunctuationStrippable` | Strips punctuation from strings |
| `Registerable` | Shared payment (`allocations_sum`/`paid?`/`remaining_cost`/…) + certificate (`certificate_sent?`, `mark_certificate_sent!`) interface for `EventRegistration` and `ContinuingEducationRegistration`; includers supply `cost_cents` + their own `certificate_available?` |
| `RemoteSearchable` | AJAX remote search by column |
| `RichTextSearchable` | Full-text search on ActionText rich_text fields |
| `SectorsTaggable` | Enforces a single primary sector for sector-tagged owners |
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ When changing a model or controller, check whether these related files need upda
- Prefer early returns and guard clauses
- Avoid unnecessary and/or complex conditionals
- Prefer constants and scopes over magic strings
- Avoid Rails `enum` β€” prefer plain string columns constrained by a constant + `validates inclusion`
- Use safe navigation (`&.`) where appropriate
- Use `presence` over blank checks
- Use `Arel.sql` for raw SQL in order clauses
Expand Down
2 changes: 0 additions & 2 deletions app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,6 @@ def person_params
:street_address, :city, :state, :zip, :country, :mailing_address_type,
:best_time_to_call,
:date_of_birth,
:license_number,
:license_type,
:credentials,
:racial_ethnic_identity,
:filemaker_code,
Expand Down
28 changes: 26 additions & 2 deletions app/models/allocation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class Allocation < ApplicationRecord

validate :reverted_requires_positive_amount, :negative_cannot_be_reverted
validate :validate_event_registration_cost, if: -> { allocatable_type == "EventRegistration" }
validate :validate_ce_registration_cost, if: -> { allocatable_type == "ContinuingEducationRegistration" }

after_create :adjust_source_remaining

Expand Down Expand Up @@ -88,8 +89,6 @@ def self.search_by_params(params)

def validate_event_registration_cost
event_reg = allocatable
return unless event_reg.is_a?(EventRegistration)

cost_cents = event_reg.event.cost_cents
if cost_cents.blank?
errors.add(:base, "Cannot allocate to a free event.")
Expand All @@ -109,6 +108,31 @@ def validate_event_registration_cost
end
end

def validate_ce_registration_cost

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Mirrors validate_event_registration_cost, but caps allocations at the CE registration's own amount_cents (= hours Γ— rate). With two licenses billed once, a single Payment allocates across both CE registrations and each is capped independently here.

ce_reg = allocatable
# cost_cents is a non-null, default-0 column, so `<= 0` (not `.blank?`, as in
# the event variant whose cost is nullable) is the right "no cost" test. A
# zero-cost CE accepts no allocations even though CE#paid_in_full? reports it
# as paid β€” that asymmetry is deliberate, see CE#paid_in_full?.
cost_cents = ce_reg.cost_cents.to_i
if cost_cents <= 0
errors.add(:base, "Cannot allocate to a CE registration with no cost.")
return
end

other_total = ce_reg.allocations_sum
other_total -= amount_was if persisted?

if amount.to_i > 0
if other_total >= cost_cents
errors.add(:base, "CE registration is already fully paid.")
elsif other_total + amount > cost_cents
remaining = cost_cents - other_total
errors.add(:base, "Cannot allocate more than remaining CE cost. Remaining: #{MoneyFormatter.dollars_from_cents(remaining)}")
end
end
end

def reverted_requires_positive_amount
if reverted_id.present? && amount.to_i < 0
errors.add(:reverted_id, "must be on a positive amount allocation")
Expand Down
65 changes: 65 additions & 0 deletions app/models/concerns/registerable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
module Registerable
extend ActiveSupport::Concern

# Shared behaviour for the two registration records that money is allocated to
# and that grant a certificate: EventRegistration and
# ContinuingEducationRegistration. Keeps their payment + certificate interfaces
# identical so they can't drift.
#
# Includers must:
# - have an `allocations` association (as: :allocatable)
# - respond to `cost_cents` (EventRegistration delegates to event.cost_cents;
# ContinuingEducationRegistration has its own column)
# - define their own `certificate_available?` (the eligibility rules differ)
#
# The payment reads use the loaded `allocations` association when present, so
# callers that preload it (rosters, onboarding matrix) pay no per-row queries.

# Total covered by every allocation β€” payments, discounts, scholarships.
def allocations_sum
return allocations.to_a.sum(&:amount) if allocations.loaded?
allocations.sum(:amount)
end

# Cash only (excludes scholarships and discounts).
def payments_sum
return allocations.to_a.select { |a| a.source_type == Payment.polymorphic_name }.sum(&:amount) if allocations.loaded?
allocations.where(source_type: Payment.polymorphic_name).sum(:amount)
end

# Comp/discount coverage only (excludes payments and scholarships).
def discount_sum
return allocations.to_a.select { |a| a.source_type == "Discount" }.sum(&:amount) if allocations.loaded?
allocations.where(source_type: "Discount").sum(:amount)
end

def discounted?
return allocations.to_a.any? { |a| a.source_type == "Discount" } if allocations.loaded?
allocations.where(source_type: "Discount").exists?
end

def remaining_cost
[ cost_cents.to_i - allocations_sum, 0 ].max
end

# A free (or zero-cost) registration is paid by definition.
def paid_in_full?
return true if cost_cents.to_i <= 0
allocations_sum >= cost_cents.to_i
end

def partially_paid?
!paid_in_full? && payments_sum.to_i.positive?
end

# Certificate delivery. Sending the certificate email is how it's issued, so
# certificate_sent_at doubles as the "issued" marker. Each includer defines its
# own #certificate_available? eligibility.
def certificate_sent?
certificate_sent_at.present?
end

def mark_certificate_sent!(at: Time.current)
update!(certificate_sent_at: at)
end
end
48 changes: 48 additions & 0 deletions app/models/continuing_education_registration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
class ContinuingEducationRegistration < ApplicationRecord
include Registerable

has_paper_trail

belongs_to :event_registration
belongs_to :professional_license
belongs_to :created_by, class_name: "User", optional: true
belongs_to :updated_by, class_name: "User", optional: true

has_many :allocations, as: :allocatable, dependent: :destroy
has_many :payments, through: :allocations, source: :source, source_type: "Payment"

before_validation :default_from_event, on: :create

validates :hours, numericality: { greater_than_or_equal_to: 0 }
validates :cost_cents, numericality: { greater_than_or_equal_to: 0 }
validate :license_belongs_to_registrant

# Payment interface (allocations_sum / paid_in_full? / remaining_cost / …) comes from
# Registerable, driven by this record's own cost_cents column.

# CE certificate eligibility β€” its own rule (not shared): the event grants CE,
# the registrant attended, the training has ended, and the CE balance is paid.
def certificate_available?
event = event_registration&.event
return false unless event&.ce_eligible?

event.end_date&.past? && event_registration.attended? && paid_in_full?
end

private

# Snapshot the hours offered and total cost from the event when they aren't set
# explicitly.
def default_from_event
event = event_registration&.event
self.hours = event.ce_hours_offered if event&.ce_hours_offered && (hours.blank? || hours.zero?)
self.cost_cents = event.ce_hours_cost_cents if event&.ce_hours_cost_cents && (cost_cents.blank? || cost_cents.zero?)
end

def license_belongs_to_registrant

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Integrity guard: the license must belong to the registrant, otherwise one person's CE could be billed against another's license.

return if professional_license.blank? || event_registration.blank?
return if professional_license.person_id == event_registration.registrant_id

errors.add(:professional_license, "must belong to the registrant")
end
end
23 changes: 23 additions & 0 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,29 @@ def cost=(dollar_amount)
end
end

# Virtual attribute for the total CE cost in dollars (converts to/from
# ce_hours_cost_cents), mirroring #cost.
def ce_hours_cost

@jmilljr24 jmilljr24 Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know the naming convention we've been using but I feel like this might bite us at some point. cost_cents is explicit. Does hours_cost mean dollars or cents. I can't tell unless I go to this method.

Then in registerable.rb we have

def remaining_cost

which returns cents.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like event "cost" is also passed into the controller as dollars and saved as cents so this consistent with that. Just makes me think it could be easy to mix up.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah we have a little pattern going that cost is dollars and user-facing cost_cents is what is stored. i've tried a couple options at various times/models and this seems best split of the difference πŸ€·β€β™€οΈ

return nil if ce_hours_cost_cents.nil?
ce_hours_cost_cents / 100.0
end

def ce_hours_cost=(dollar_amount)
if dollar_amount.blank?
self.ce_hours_cost_cents = nil
else
dollar_amount = dollar_amount.to_s.gsub(/[^\d.]/, "").to_f
self.ce_hours_cost_cents = (dollar_amount * 100).round
end
end

# An event grants CE credit when it offers a positive number of hours. Derived
# from ce_hours_offered rather than a separate stored flag, so there's a single
# source of truth.
def ce_eligible?
ce_hours_offered.to_f.positive?
end

def attachable_content_type
"application/vnd.active_record.event"
end
Expand Down
50 changes: 8 additions & 42 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class EventRegistration < ApplicationRecord
include RemoteSearchable
include Registerable

belongs_to :registrant, class_name: "Person"
belongs_to :event
Expand All @@ -8,6 +9,7 @@ class EventRegistration < ApplicationRecord
has_many :notifications, as: :noticeable, dependent: :destroy
has_many :organizations, through: :event_registration_organizations
has_many :allocations, as: :allocatable
has_many :continuing_education_registrations, dependent: :destroy
has_many :scholarships, -> { distinct },
through: :allocations, source: :source, source_type: "Scholarship"
has_many :checklist_completions, class_name: "EventRegistrationChecklistCompletion", dependent: :destroy
Expand Down Expand Up @@ -198,10 +200,6 @@ def checked_in?
# checked_in_at.present?
end

def paid?
paid_in_full?
end

# True when the registrant should be granted access to ticket materials
# (training links, etc.) even though they haven't paid in full yet. Admins
# flip the `intends_to_pay` flag when someone commits to paying after the
Expand All @@ -210,11 +208,11 @@ def paid?
#
# This is the single seam for "may this registrant reach paid content?":
# any payment-gated resource (the videoconference join link today, recordings
# or downloads in the future) should gate on this, NOT on `paid?`. Reporting
# surfaces (rosters, CSV exports, dashboard metrics) must keep using `paid?` /
# or downloads in the future) should gate on this, NOT on `paid_in_full?`.
# Reporting surfaces (rosters, CSV exports, dashboard metrics) must keep using
# `paid_in_full?` so they still reflect the real balance owed.
def payment_access_granted?
paid? || intends_to_pay?
paid_in_full? || intends_to_pay?
end

# Human-readable payment status for rosters and CSV exports. Assumes the event
Expand Down Expand Up @@ -254,41 +252,9 @@ def certificate_available?
event.end_date.present? && event.end_date.past? && attended? && scholarship_tasks_met?
end

# These read from the loaded `allocations` association so callers that preload
# it (e.g. the registrants roster and onboarding matrix) pay no per-row queries;
# callers that don't load the association once and reuse it across these methods.
def allocations_sum
return allocations.to_a.sum(&:amount) if allocations.loaded?
allocations.sum(:amount)
end

def remaining_cost
[ event.cost_cents - allocations_sum, 0 ].max
end

def paid_in_full?
return true if event.cost_cents.to_i <= 0
allocations_sum >= event.cost_cents.to_i
end

def payments_sum
return allocations.to_a.select { |a| a.source_type == Payment.polymorphic_name }.sum(&:amount) if allocations.loaded?
allocations.where(source_type: Payment.polymorphic_name).sum(:amount)
end

def partially_paid?
!paid_in_full? && payments_sum.to_i.positive?
end

def discounted?
return allocations.to_a.any? { |a| a.source_type == "Discount" } if allocations.loaded?
allocations.where(source_type: "Discount").exists?
end

# Total comp/discount coverage (excludes payments and scholarships).
def discount_sum
return allocations.to_a.select { |a| a.source_type == "Discount" }.sum(&:amount) if allocations.loaded?
allocations.where(source_type: "Discount").sum(:amount)
# Cost source for the Registerable payment interface: the event's price.
def cost_cents
event.cost_cents
end

# True when the registrant has supplied a CE license number.
Expand Down
1 change: 1 addition & 0 deletions app/models/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class Person < ApplicationRecord
has_one :user, inverse_of: :person, dependent: :nullify
has_many :affiliations, dependent: :destroy
has_many :organizations, through: :affiliations
has_many :professional_licenses, dependent: :destroy
has_many :communal_reports, through: :organizations, source: :reports
has_many :windows_types, through: :organizations

Expand Down
33 changes: 33 additions & 0 deletions app/models/professional_license.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
class ProfessionalLicense < ApplicationRecord
has_paper_trail

belongs_to :person
belongs_to :created_by, class_name: "User", optional: true
belongs_to :updated_by, class_name: "User", optional: true

has_many :continuing_education_registrations, dependent: :destroy

validates :number, uniqueness: { scope: :person_id }, allow_nil: true

# Find the person's license for this number, or create it. A blank number
# resolves to the person's single placeholder license (number nil) so a CE
# opt-in without a number on file never spawns duplicate placeholders.
def self.find_or_create_for(person:, number: nil)
find_or_create_by(person: person, number: number.presence)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: number.presence collapses ""β†’nil, so a CE opt-in with no number on file resolves to the person's single placeholder license rather than spawning a new blank row each time. The DB unique index on (person_id, number) enforces the numbered case (MySQL treats NULLs as distinct, so it intentionally does not block multiple placeholders β€” the model does).

end

# Completeness: have we recorded the actual license number yet?
def number_known?
number.present?
end

# Validity: a license with a past expiration is expired. Unknown when no
# expiration is on file.
def expired?
expires_on.present? && expires_on.past?
end

def name
[ kind, number ].compact_blank.join(" ").presence || "License (number pending)"
end
end
2 changes: 2 additions & 0 deletions app/policies/event_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ def google_analytics?
:event_details_label,
:ce_hours_details,
:ce_hours_details_label,
:ce_hours_offered,
:ce_hours_cost,
:autoshow_cost,
:autoshow_date,
:autoshow_location,
Expand Down
Loading