Add ProfessionalLicense and ContinuingEducationRegistration models (PR 1: foundation)#1916
Add ProfessionalLicense and ContinuingEducationRegistration models (PR 1: foundation)#1916maebeale wants to merge 4 commits into
Conversation
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) |
There was a problem hiding this comment.
🤖 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! |
There was a problem hiding this comment.
🤖 From Claude: Auto-advances requested↔paid 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 paid→requested too.
| self.amount_cents = (hours.to_d * rate_cents).round | ||
| end | ||
|
|
||
| def license_belongs_to_registrant |
There was a problem hiding this comment.
🤖 From Claude: Integrity guard: the license must belong to the registrant, otherwise one person's CE could be billed against another's license.
| end | ||
| end | ||
|
|
||
| def validate_ce_registration_cost |
There was a problem hiding this comment.
🤖 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.
jmilljr24
left a comment
There was a problem hiding this comment.
Just the cost calculation is the only thing that gives me pause. Thoughts?
| def ce_amount_owed_cents | ||
| return 0 if ce_hours.blank? | ||
|
|
||
| (ce_hours * ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100).round |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| expires_on.present? && expires_on.past? | ||
| end | ||
|
|
||
| def label |
There was a problem hiding this comment.
Maybe in a decorator in part 2? I don't recall what pattern we've been using the most for stuff like this.
There was a problem hiding this comment.
We don't really have a pattern. A better name is prob name.
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>
e60fade to
5d534c1
Compare
🤖 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(perPerson) — nullablenumberis a placeholder;find_or_create_forkeeps one license per(person, number)and a single placeholder. Nostatus(derivednumber_known?/expired?).ContinuingEducationRegistration(perEventRegistration, one per license) —allocatableso it reuses the existing Payment/Allocation machinery; stringstatus(requested→paid→issued/unawarded, no enum);hoursdefaults from the event but stays editable, drivingamount_cents.Eventgainsce_hours_eligible(the explicit "CE offered" gate) +ce_hours(fractional hours).Allocationgets a CE over-payment guard and arequested→paidstatus sync.Person#license_number/license_typecolumns (no data) and their form/controller bits.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).