diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 8dc942bd03..e7b7afbda9 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index 0443af703b..ab63e0cd64 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
@@ -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) |
@@ -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 |
diff --git a/CLAUDE.md b/CLAUDE.md
index 826f37677b..dbd052f2ff 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb
index f1eadfdf2f..dbe7bd1158 100644
--- a/app/controllers/people_controller.rb
+++ b/app/controllers/people_controller.rb
@@ -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,
diff --git a/app/models/allocation.rb b/app/models/allocation.rb
index b18137d9b2..734f31e3d0 100644
--- a/app/models/allocation.rb
+++ b/app/models/allocation.rb
@@ -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
@@ -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.")
@@ -109,6 +108,31 @@ def validate_event_registration_cost
end
end
+ def validate_ce_registration_cost
+ 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")
diff --git a/app/models/concerns/registerable.rb b/app/models/concerns/registerable.rb
new file mode 100644
index 0000000000..4c2c405f26
--- /dev/null
+++ b/app/models/concerns/registerable.rb
@@ -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
diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb
new file mode 100644
index 0000000000..16d697dee8
--- /dev/null
+++ b/app/models/continuing_education_registration.rb
@@ -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
+ 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
diff --git a/app/models/event.rb b/app/models/event.rb
index a80ac10900..87bfbd9e34 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -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
+ 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
diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb
index f2b74d1c05..b776d45898 100644
--- a/app/models/event_registration.rb
+++ b/app/models/event_registration.rb
@@ -1,5 +1,6 @@
class EventRegistration < ApplicationRecord
include RemoteSearchable
+ include Registerable
belongs_to :registrant, class_name: "Person"
belongs_to :event
@@ -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
@@ -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
@@ -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
@@ -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.
diff --git a/app/models/person.rb b/app/models/person.rb
index f9f3c3290f..99a4886830 100644
--- a/app/models/person.rb
+++ b/app/models/person.rb
@@ -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
diff --git a/app/models/professional_license.rb b/app/models/professional_license.rb
new file mode 100644
index 0000000000..0f07ff774c
--- /dev/null
+++ b/app/models/professional_license.rb
@@ -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)
+ 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
diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb
index 6d97a12f9c..14c10eb6a0 100644
--- a/app/policies/event_policy.rb
+++ b/app/policies/event_policy.rb
@@ -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,
diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb
index ce29656eb2..1a7f78b89e 100644
--- a/app/views/events/_form.html.erb
+++ b/app/views/events/_form.html.erb
@@ -471,6 +471,19 @@
title_placeholder: "CE hours",
content_help: "CE requirements, payment, sign-in rules, and the post-training evaluation — shown on its own page linked from the ticket. Accepts basic HTML — bold, italics, links, lists, headings, and line breaks.",
content_placeholder: "e.g.
AWBW is approved by CAMFT…
Before the training
Email your license number
" %>
+
+
+
+
<% when :event_details %>
<%= render "events/builtin_callout_card", f: f, card: card,
label_field: :event_details_label, content_field: :event_details,
diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb
index 4ea491e6c5..e8895ad330 100644
--- a/app/views/people/_form.html.erb
+++ b/app/views/people/_form.html.erb
@@ -326,18 +326,6 @@
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
},
hint: "Shown on an event's scholarship recipients page if designated for a shout-out" %>
-
-