diff --git a/app/decorators/registration_ticket_callout_decorator.rb b/app/decorators/registration_ticket_callout_decorator.rb index 026dd6ea9..9c751cf02 100644 --- a/app/decorators/registration_ticket_callout_decorator.rb +++ b/app/decorators/registration_ticket_callout_decorator.rb @@ -1,4 +1,16 @@ class RegistrationTicketCalloutDecorator < ApplicationDecorator + # For the editor's amber warning badge: when this published built-in callout can + # never reach the ticket because the event isn't configured for it (free event → + # no payment card, no scholarship form → no scholarship card, no CE hours → no CE + # card, no videoconference link → no videoconference card), the reason to show; + # nil when it will show (or it's hidden/custom). Shares BuiltinCalloutCards.config_gap + # so the badge and the ticket guard can't drift. + def ticket_suppression_reason + return unless published? && builtin? + gap = BuiltinCalloutCards.config_gap(event, builtin_key) + "Won't show on the ticket — #{gap}" if gap + end + # This callout's linked resources as cards, in display order, each reading its # subtitle from the materialized join row and linking to the resource's own # page. Shared by every surface that lists a callout's resources (the handouts diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index 88346c126..284ba9f32 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -74,6 +74,26 @@ def self.editor_cards(event) "videoconference" => :videoconference_card }.freeze + # Why a built-in card with this builtin_key can never appear on the given event's + # ticket because the event lacks the config the card depends on (a free event + # has no payment card, an event with no scholarship form has no scholarship + # card, an event that offers no CE hours has no CE card) — or nil when the event + # is configured for it. The card builders below enforce these same gaps at + # render time; the editor surfaces the phrase as an amber "won't show" badge so + # admins know a published callout still won't reach the ticket. + def self.config_gap(event, builtin_key) + case builtin_key + when "payment" + "this event is free" if event.cost_cents.to_i <= 0 + when "scholarship" + "this event has no scholarship form" if event.scholarship_form.blank? + when "ce_hours" + "this event offers no CE hours" unless event.ce_eligible? + when "videoconference" + "this event has no videoconference link" if event.videoconference_url.blank? + end + end + def initialize(event_registration) @registration = event_registration @event = event_registration.event @@ -131,7 +151,7 @@ def skip_in_fallback?(builtin_key) # in full. Its page lists every allocation with the running balance, plus the # linked documents (the W-9, and the invoice/receipt) for paid events. def payment_card - return if event.cost_cents.to_i <= 0 + return if self.class.config_gap(event, "payment") due = registration.remaining_cost.to_i.positive? Card.new(icon_class: "fa-solid fa-credit-card", color: due ? "orange" : "blue", title: due ? "Make your payment" : "Payment", @@ -158,6 +178,7 @@ def certificate_card # pending shows an amber "$X · Tasks outstanding" badge (action needed); fully # met shows a fuchsia amount badge. def scholarship_status_card + return if self.class.config_gap(event, "scholarship") return unless registration.scholarship_requested? # Awarded is display-only: the scholarship record exists earlier, but the award # is only shown as awarded once the recipient signs the agreement. Until then @@ -194,6 +215,7 @@ def scholarship_badge(awarded, tasks_outstanding) # they have, becoming a reference card once requested with hours and a license # number on file. Shown when the event offers CE or the registrant asked for it. def ce_hours_card + return if self.class.config_gap(event, "ce_hours") return unless registration.ce_registered? complete = registration.ce_license_provided? # An outstanding CE balance turns the card orange (an action card), matching @@ -264,7 +286,7 @@ def event_details_card # Shown only when the event has a videoconference URL set. def videoconference_card - return if event.videoconference_url.blank? + return if self.class.config_gap(event, "videoconference") Card.new(icon_class: "fa-solid fa-video", color: "blue", title: "Videoconference", subtitle: "Join link and how to add it to your calendar", diff --git a/app/views/events/_registration_ticket_callout_fields.html.erb b/app/views/events/_registration_ticket_callout_fields.html.erb index 7da76d907..9733f0a88 100644 --- a/app/views/events/_registration_ticket_callout_fields.html.erb +++ b/app/views/events/_registration_ticket_callout_fields.html.erb @@ -51,6 +51,16 @@ <% end %> <%= f.object.title %> + + <%# Warns when this published built-in is set up but the event isn't + configured for it, so it still won't reach the ticket (free event → no + payment card, no scholarship form / CE hours → no scholarship / CE card). %> + <% if (suppression_reason = f.object.decorate.ticket_suppression_reason) %> + + + <%= suppression_reason %> + + <% end %> <%# Editable content — hidden when the Published checkbox is unchecked. diff --git a/spec/decorators/registration_ticket_callout_decorator_spec.rb b/spec/decorators/registration_ticket_callout_decorator_spec.rb index c795b2f32..daea66853 100644 --- a/spec/decorators/registration_ticket_callout_decorator_spec.rb +++ b/spec/decorators/registration_ticket_callout_decorator_spec.rb @@ -25,4 +25,46 @@ expect(handouts.href).not_to include("callout_id") end end + + describe "#ticket_suppression_reason" do + def builtin_callout(builtin_key, event:, hidden: false) + create(:registration_ticket_callout, event:, builtin_key:, hidden:, + title: builtin_key.humanize) + end + + it "warns when a published payment callout is on a free event" do + callout = builtin_callout("payment", event: create(:event, cost_cents: 0)) + expect(callout.decorate.ticket_suppression_reason).to eq("Won't show on the ticket — this event is free") + end + + it "warns when a published scholarship callout's event has no scholarship form" do + callout = builtin_callout("scholarship", event: create(:event)) + expect(callout.decorate.ticket_suppression_reason).to include("no scholarship form") + end + + it "warns when a published CE callout's event offers no CE hours" do + callout = builtin_callout("ce_hours", event: create(:event, ce_hours_offered: nil)) + expect(callout.decorate.ticket_suppression_reason).to include("offers no CE hours") + end + + it "warns when a published videoconference callout's event has no join link" do + callout = builtin_callout("videoconference", event: create(:event, videoconference_url: nil)) + expect(callout.decorate.ticket_suppression_reason).to include("no videoconference link") + end + + it "is nil when the event is configured for the callout" do + callout = builtin_callout("payment", event: create(:event, cost_cents: 5_000)) + expect(callout.decorate.ticket_suppression_reason).to be_nil + end + + it "is nil when the callout is unpublished" do + callout = builtin_callout("payment", event: create(:event, cost_cents: 0), hidden: true) + expect(callout.decorate.ticket_suppression_reason).to be_nil + end + + it "is nil for a custom (non-built-in) callout" do + callout = create(:registration_ticket_callout, builtin_key: nil, hidden: false) + expect(callout.decorate.ticket_suppression_reason).to be_nil + end + end end diff --git a/spec/services/builtin_callout_cards_spec.rb b/spec/services/builtin_callout_cards_spec.rb index 8a7ff97ae..0dbb5c9b8 100644 --- a/spec/services/builtin_callout_cards_spec.rb +++ b/spec/services/builtin_callout_cards_spec.rb @@ -12,6 +12,10 @@ def card(reg, title) described_class.new(reg).cards.find { |c| c.title == title } end + def add_scholarship_form(event) + create(:event_form, :scholarship, event:) + end + describe "#cards" do it "shows the payment card for a bare paid, non-training registration" do expect(card_titles(registration)).to eq([ "Make your payment" ]) @@ -73,12 +77,24 @@ def card(reg, title) end it "shows the CE card only when the registrant requested CE credit" do + event.update!(ce_hours_offered: 6) expect(card_titles(registration)).not_to include(event.ce_hours_details_label) license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license) expect(card_titles(registration.reload)).to include(event.ce_hours_details_label) end + it "omits the CE card when the event offers no CE hours, even with a CE registration" do + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + expect(card_titles(registration.reload)).not_to include(event.ce_hours_details_label) + end + + it "omits the scholarship card when the event has no scholarship form, even when requested" do + registration.update!(scholarship_requested: true) + expect(card_titles(registration)).not_to include("Scholarship") + end + it "turns the CE card orange while a balance is due, teal once paid" do event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) license = create(:professional_license, :placeholder, person: registration.registrant) @@ -106,14 +122,14 @@ def card(reg, title) end it "names the CE request deadline on the license-needed badge" do - event.update!(ce_hours_cost_cents: 15_000, ce_hours_request_deadline: Date.new(2026, 7, 1)) + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000, ce_hours_request_deadline: Date.new(2026, 7, 1)) license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license) expect(card(registration.reload, event.ce_hours_details_label).badge).to eq("$150 · License number needed by Jul 1") end it "appends the CE payment deadline to the amount-due badge, dropping it once paid" do - event.update!(ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15)) + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15)) license = create(:professional_license, person: registration.registrant, number: "LIC123") ce_reg = create(:continuing_education_registration, event_registration: registration, professional_license: license) expect(card(registration.reload, event.ce_hours_details_label).badge).to eq("$150 due by Aug 15") @@ -123,6 +139,7 @@ def card(reg, title) end it "shows the scholarship card only when requested, without an amount chip until awarded" do + add_scholarship_form(event) expect(card_titles(registration)).not_to include("Scholarship") registration.update!(scholarship_requested: true) scholarship_card = card(registration, "Scholarship") @@ -132,6 +149,7 @@ def card(reg, title) end it "prompts to accept the agreement, without an amount chip, until it is signed" do + add_scholarship_form(event) registration.update!(scholarship_requested: true) scholarship = create(:scholarship, amount_cents: 25_000, tasks_completed: true, agreement_signed: false) create(:allocation, source: scholarship, allocatable: registration, amount: 1000) @@ -142,6 +160,7 @@ def card(reg, title) end it "turns the scholarship card amber while award tasks are outstanding" do + add_scholarship_form(event) registration.update!(scholarship_requested: true) scholarship = create(:scholarship, recipient: registration.registrant, tasks_completed: false, agreement_signed: true) create(:allocation, source: scholarship, allocatable: registration, amount: 1000) @@ -150,6 +169,7 @@ def card(reg, title) end it "flags an awarded scholarship with outstanding tasks in an amber chip" do + add_scholarship_form(event) registration.update!(scholarship_requested: true) scholarship = create(:scholarship, amount_cents: 25_000, tasks_completed: false, agreement_signed: true) create(:allocation, source: scholarship, allocatable: registration, amount: 1000) @@ -159,6 +179,7 @@ def card(reg, title) end it "shows a fuchsia amount chip once the agreement is signed and tasks are complete" do + add_scholarship_form(event) registration.update!(scholarship_requested: true) scholarship = create(:scholarship, amount_cents: 25_000, tasks_completed: true, agreement_signed: true) create(:allocation, source: scholarship, allocatable: registration, amount: 1000) @@ -173,6 +194,7 @@ def card(reg, title) ce_hours_details: "6 hours", ce_hours_offered: 6, videoconference_url: "https://example.zoom.us/j/123", start_date: 3.days.ago, end_date: 2.days.ago) + add_scholarship_form(event) registration.update!(status: "attended", scholarship_requested: true) license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license) @@ -220,7 +242,7 @@ def card(reg, title) end it "keeps the live CE deadline badge on a materialized CE row" do - event.update!(ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15)) + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15)) license = create(:professional_license, person: registration.registrant, number: "LIC123") create(:continuing_education_registration, event_registration: registration, professional_license: license) callout = create(:registration_ticket_callout, event:, builtin_key: "ce_hours", title: "CE credit")