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
12 changes: 12 additions & 0 deletions app/decorators/registration_ticket_callout_decorator.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
26 changes: 24 additions & 2 deletions app/services/builtin_callout_cards.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions app/views/events/_registration_ticket_callout_fields.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@
<% end %>

<span class="text-gray-800 font-medium truncate max-w-xs" title="<%= f.object.title %>"><%= f.object.title %></span>

<%# 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) %>
<span class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 shrink-0">
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
<%= suppression_reason %>
</span>
<% end %>
</div>

<%# Editable content β€” hidden when the Published checkbox is unchecked.
Expand Down
42 changes: 42 additions & 0 deletions spec/decorators/registration_ticket_callout_decorator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 25 additions & 3 deletions spec/services/builtin_callout_cards_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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" ])
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
Loading