Skip to content

Add ProfessionalLicense and ContinuingEducationRegistration models (PR 1: foundation)#1916

Open
maebeale wants to merge 4 commits into
mainfrom
maebeale/continuing-education-model
Open

Add ProfessionalLicense and ContinuingEducationRegistration models (PR 1: foundation)#1916
maebeale wants to merge 4 commits into
mainfrom
maebeale/continuing-education-model

Conversation

@maebeale

Copy link
Copy Markdown
Collaborator

🤖 PR, suggested 👤 review level: 🔬 Inspect — new data model + migrations (adds two tables, drops two Person columns)

What is the goal of this PR and why is this important?

CE data lives as three flat columns on EventRegistration, which can't model what AWBW needs: CE hours tracked per professional license, a person holding several licenses, and a CE record with its own payment + certificate lifecycle.

This is PR 1 of 2 — the foundation only (additive, no behavior change). PR 2 reroutes intake, the callout, and read sites, then drops the old ce_* columns.

How did you approach the change?

  • ProfessionalLicense (per Person) — nullable number is a placeholder; find_or_create_for keeps one license per (person, number) and a single placeholder. No status (derived number_known?/expired?).
  • ContinuingEducationRegistration (per EventRegistration, one per license) — allocatable so it reuses the existing Payment/Allocation machinery; string status (requestedpaidissued/unawarded, no enum); hours defaults from the event but stays editable, driving amount_cents.
  • Event gains ce_hours_eligible (the explicit "CE offered" gate) + ce_hours (fractional hours).
  • Allocation gets a CE over-payment guard and a requested→paid status sync.
  • Drops the unused Person#license_number/license_type columns (no data) and their form/controller bits.
  • Adds an "avoid Rails enums — prefer string values" project convention.

Anything else to add?

The existing ce_* columns and the CE form are deliberately left untouched here; suite stays green. Billing model: two licenses charge 2× but bill once (one payment allocated across the CE registrations).

CE data was three flat columns on EventRegistration, which can't express
that CE hours are tracked per professional license, that a person holds
several licenses, or that a CE record has its own payment and certificate
lifecycle. This lands the foundation:

- ProfessionalLicense (per Person; nullable number = placeholder;
  find_or_create_for keeps one license per number)
- ContinuingEducationRegistration (per EventRegistration, against one
  license; allocatable like a registration; string status, no enum)
- Event#ce_hours_eligible + ce_hours as the source of CE availability/hours
- Allocation over-payment guard + requested→paid status sync for CE
- Drop the now-unused Person#license_number/license_type columns

Additive: the existing ce_* columns and CE form are untouched (rerouted in
a follow-up PR). Also adds an "avoid Rails enums" project convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# 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).


# Advance requested↔paid to track real payments without clobbering a later
# admin state (issued/unawarded). Called when allocations change.
def sync_payment_status!

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: Auto-advances requestedpaid to track real payments, but only from those two states — a later admin issued/unawarded is never clobbered. Driven from Allocation after_create, so reversals (negative allocations) revert paidrequested too.

self.amount_cents = (hours.to_d * rate_cents).round
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.

Comment thread app/models/allocation.rb
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.

@maebeale maebeale marked this pull request as ready for review June 25, 2026 15:53
@maebeale maebeale requested a review from jmilljr24 June 25, 2026 15:58

@jmilljr24 jmilljr24 left a comment

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.

Just the cost calculation is the only thing that gives me pause. Thoughts?

Comment thread app/models/event.rb Outdated
Comment on lines +197 to +200
def ce_amount_owed_cents
return 0 if ce_hours.blank?

(ce_hours * ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100).round

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 think this aligns with the stakeholders new requirements for CE payments.

Prior to the last meeting, yes it was a direct correlation of ce_hour * hourly_rate but they currently the want essential a flat rate. If you want CE credits you pay $120 for this event (how they come up with that number is irrelevant). No refunds. If you only complete 6 hours, you only get credit for 6 hours.

I'm thinking a column on event for ce_cost. This avoids have a hard coded constant and no math involved.

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.

yes, i was thinking same on event ce_hour_cost, but was going to keep our math stuff, just have it all end up with the same math.

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'm not hard pressed on this, but I guess my concern is that we are adding an additional step and logic that isn't needed. On the flip side if they change their mind in the future this would open up that ability. But then again that's just guessing and they may come up with some other formula for 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.

yeah totally fair. what i'm hearing is there's a fixed cost for training events. and i've also heard about them doing future kinds of events that aren't training events. so going forward it's a toss-up. but i know in the past they captured partial amounts and payments. since this system needs to accommodate historical data too, i was thinking keep this data structure underneath and have the calculation logic just run in the background to get the current fixed cost handled.

@jmilljr24 jmilljr24 Jun 25, 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.

Good point on the filemaker data. I wasn't thinking of that.

Maybe a typo on "event"? They are dong a fixed cost for "CE Hours".

I took a look at the filemarker data. There are 167 records on CE hours. The are all a simple payment recording on an "CE hours event". 99% are $120 with a note saying 12 hours.

The handful of outliers I see in notes are:

11 hours - 120 paid
10 hours - 100 paid
30 hours - 30 paid
12 hours - waived
fee comped - no mention of hours
120 paid - did not attented

The only other odd two are a combined payment for a training event and CE hours.

With that said, there is no column for cost_per_hour. So I think as far as filemaker data goes it will be easiest to keep it the 1:1 , payments = X and CE_hours = X. It seems roundabout and brittle to take those two datapoints, divide them to get the hour rate, then take the original hour times the calculated rate to get back the payment we started with.

Comment thread app/models/professional_license.rb Outdated
expires_on.present? && expires_on.past?
end

def label

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.

Maybe in a decorator in part 2? I don't recall what pattern we've been using the most for stuff like this.

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.

We don't really have a pattern. A better name is prob name.

maebeale and others added 3 commits June 25, 2026 16:19
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CE per-hour price was hard-coded to the HOURLY_RATE_DOLLARS constant, so
every event billed the same rate. Add a nullable ce_hour_cost_cents column
(stored in cents, mirroring cost_cents) with a ce_hour_cost dollars virtual
attribute for the form. A nil means "use the standard rate": ce_hour_cost_cents
falls back to the constant, so new and existing events show the default until
an admin sets a per-event override. ce_amount_owed_cents now bills off it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-hour CE rate now lives on Event#ce_hour_cost_cents, so a CE
registration no longer needs its own rate_cents — it just snapshots the total
cost it bills. Rename amount_cents → cost_cents and drop rate_cents. The cost
is priced from the event's per-hour rate on create and re-priced only when
hours change, so editing the event rate later never silently re-bills a
registration that's already been paid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maebeale maebeale force-pushed the maebeale/continuing-education-model branch from e60fade to 5d534c1 Compare June 26, 2026 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants