diff --git a/AGENTS.md b/AGENTS.md index 651792a44..18e277690 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,21 +48,21 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| -| `app/models/` | ActiveRecord models | ~80 files | -| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~66 files | -| `app/jobs/` | SolidQueue background jobs | 5 files | -| `app/models/concerns/` | Shared model modules | 16 concerns | +| `app/models/` | ActiveRecord models | ~90 files | +| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~69 files | +| `app/jobs/` | SolidQueue background jobs | 6 files | +| `app/models/concerns/` | Shared model modules | 17 concerns | ### Presentation | Directory | Purpose | Count | |---|---|---| | `app/controllers/` | Rails controllers (admin/, events/, home/) | ~91 files | -| `app/views/` | ERB templates | ~745 files | -| `app/decorators/` | Draper decorators for view logic | ~40 files | -| `app/policies/` | ActionPolicy authorization rules | ~55 files | +| `app/views/` | ERB templates | ~824 files | +| `app/decorators/` | Draper decorators for view logic | ~50 files | +| `app/policies/` | ActionPolicy authorization rules | ~63 files | | `app/presenters/` | Presentation objects | 6 files | -| `app/helpers/` | View helpers | ~31 files | +| `app/helpers/` | View helpers | ~36 files | | `app/mailers/` | ActionMailer classes | 5 files | | `app/inputs/` | Custom SimpleForm inputs | 1 file | @@ -106,7 +106,8 @@ This codebase (Rails 8.1) | `OtherResponse` | A free-text "Other" typed on a form question, captured at submission time (registration, scholarship, bulk payment). Polymorphic `owner`: a **sector** "Other" is owned by the `Person` (promotable into a `Sector`, shown on their profile/edit chip); an **organization_type** "Other" is owned by the `Organization` (stored now, not promotable until `OrganizationType` is a model). `generic` questions aren't captured — that stays searchable in the form answers. `field_identifier` records the question; `kind` is derived. Curated at `/other_responses` (grouped by kind/question): `promote` (sectors only), `keep`, `dismiss`. `dismissed` hides the chip from the profile but stays in the review queue (still promotable later); only `promoted` leaves the queue. Admins deep-link there from a person's chip. | | `Organization` | Groups with affiliations, addresses, logos via ActiveStorage | | `Grant` | Funds (polymorphic `funder`: 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` | +| `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation`. Tri-state `agreement_response_status` (pending/accepted/declined) drives the agreement; declined awards zero their allocation and drop out of all totals | +| `ScholarshipAgreementResponse` | Append-only history of a scholarship's accept ↔ decline back-and-forth (status, reason, responder, amount at the time); the scholarship's `agreement_response_status` is the denormalized latest row, and `responded_at`/reason are read from the latest response, not stored on the scholarship | | `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?` | | `TopicSubscription` | A `Person`'s standing subscription to a `TopicSubscriptionType`, optionally narrowed to a specific `interested_event` (null = the topic broadly). State is timestamp-driven (`unsubscribed_at IS NULL` = active — `active?`/`unsubscribe!`/`resubscribe` — non-bang, since reviving can collide with a newer active row, no status column); `subscribed_at` + `source` mirror the `mailing_list_consent_*` provenance pattern. Distinct from the `mailing_list_consent_*` flag (consent = "you may email me"; subscription = "what I want to hear about") and from an `EventRegistration` (an actual enrollment). One active subscription per (person, type, event) | diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 715c883da..dc301fc1a 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -54,9 +54,7 @@ def scholarship @form_responses_available = @event.registration_form&.form_submissions&.exists?(person: @event_registration.registrant) end - # Records the recipient agreeing, from their scholarship page, to complete the - # scholarship's tasks. The Agree button submits agreement=yes, which stamps - # agreement_signed_at via the model. + # The Agree button (agreement=yes) records an "accepted" response. def sign_agreement scholarship = @event_registration.scholarships.first unless scholarship @@ -65,13 +63,32 @@ def sign_agreement end if params[:agreement] == "yes" - scholarship.update!(agreement_signed: true) unless scholarship.agreement_signed? + scholarship.accept_agreement!(by: "recipient") redirect_to registration_scholarship_path(@event_registration.slug), notice: "Thanks — your agreement has been recorded." else redirect_to registration_scholarship_path(@event_registration.slug), alert: "Something went wrong recording your agreement. Please try again." end end + # Record the recipient's decline (drops the award from all totals). A repeat + # decline is a no-op. + def decline_agreement + scholarship = @event_registration.scholarships.first + unless scholarship + redirect_to registration_scholarship_path(@event_registration.slug) + return + end + + if scholarship.agreement_declined? + redirect_to registration_scholarship_path(@event_registration.slug), notice: "You've already declined this scholarship. Contact us if you'd like to reconsider." + return + end + + scholarship.decline_agreement!(params[:decline_reason].to_s.strip) + + redirect_to registration_scholarship_path(@event_registration.slug), notice: "Thanks for letting us know — the team will follow up with you." + end + # CE hours status: hours, amount owed, and license number. The heading and the # requirements copy live on the materialized ce_hours callout row now. def ce diff --git a/app/controllers/scholarships_controller.rb b/app/controllers/scholarships_controller.rb index c0cc63de2..c5d6e29d5 100644 --- a/app/controllers/scholarships_controller.rb +++ b/app/controllers/scholarships_controller.rb @@ -1,13 +1,13 @@ class ScholarshipsController < ApplicationController - before_action :set_scholarship, only: [ :show, :edit, :update, :destroy, :toggle_tasks ] + before_action :set_scholarship, only: [ :show, :edit, :update, :destroy, :toggle_tasks, :reoffer ] before_action :set_grant, only: [ :new, :create ] def index authorize! Scholarship set_report_filter_state - scholarships = filtered_scholarships - @funder_groups = ScholarshipsGrouping.new(scholarships).funder_groups - @scholarships_count = scholarships.size + grouping = ScholarshipsGrouping.new(filtered_scholarships) + @funder_groups = grouping.funder_groups + @scholarships_count = grouping.total_count @scholarship_report = EventScholarshipReport.new(report_training_events, featured_year: @selected_year, funder: @filter_funder) end @@ -108,6 +108,17 @@ def toggle_tasks end end + # Re-offer a declined award: back to pending, allocation re-funded. + def reoffer + authorize! @scholarship, to: :update? + @scholarship.reoffer_agreement!(by: "admin") + redirect_to edit_scholarship_path(@scholarship, return_to: params[:return_to].presence, participant: params[:participant].presence), + notice: "Scholarship re-offered — awaiting the recipient's response." + rescue ActiveRecord::RecordInvalid => e + redirect_to edit_scholarship_path(@scholarship, return_to: params[:return_to].presence, participant: params[:participant].presence), + alert: e.record.errors.full_messages.to_sentence.presence || "Couldn't re-offer this scholarship." + end + private # Filter state for the shared report filter partials (time period, event, diff --git a/app/decorators/grant_decorator.rb b/app/decorators/grant_decorator.rb index 6a87df127..a894ff778 100644 --- a/app/decorators/grant_decorator.rb +++ b/app/decorators/grant_decorator.rb @@ -82,11 +82,11 @@ def remaining_percentage # completed/total. .size / Enumerable count use the preloaded association # (index eager-loads :scholarships) so these add no per-row queries. def scholarships_count - object.scholarships.size + object.scholarships.reject(&:agreement_declined?).size end def completed_scholarships_count - object.scholarships.count(&:tasks_completed?) + object.scholarships.reject(&:agreement_declined?).count(&:tasks_completed?) end # Where the index "Scholarships" count links. When every event-funded diff --git a/app/decorators/scholarship_decorator.rb b/app/decorators/scholarship_decorator.rb index 258fb48a8..1ced413f4 100644 --- a/app/decorators/scholarship_decorator.rb +++ b/app/decorators/scholarship_decorator.rb @@ -56,4 +56,43 @@ def tasks_completed? def agreement_signed? object.agreement_signed? end + + def agreement_declined? + object.agreement_declined? + end + + AGREEMENT_STATUS_LABELS = { + "declined" => "Declined", + "accepted" => "Signed", + "pending" => "Pending" + }.freeze + + AGREEMENT_STATUS_CLASSES = { + "declined" => "bg-red-50 text-red-700 border-red-200", + "accepted" => "bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200", + "pending" => "bg-amber-50 text-amber-700 border-amber-200" + }.freeze + + AGREEMENT_STATUS_ICONS = { + "declined" => "fa-solid fa-circle-xmark", + "accepted" => "fa-solid fa-file-signature", + "pending" => "fa-solid fa-file-signature" + }.freeze + + def agreement_status_label = AGREEMENT_STATUS_LABELS.fetch(object.agreement_response_status) + def agreement_status_classes = AGREEMENT_STATUS_CLASSES.fetch(object.agreement_response_status) + def agreement_status_icon = AGREEMENT_STATUS_ICONS.fetch(object.agreement_response_status) + + # The agreement-status pill every surface that lists a scholarship renders, so + # the three states read the same everywhere: Declined (red), Signed (fuchsia), + # Pending (amber). Compact surfaces only need to flag the exception, so + # pending/signed render nothing unless `all_states:`. `prefix:` reads it as + # "Agreement declined" where the pill sits next to a tasks pill. + def agreement_status_badge(all_states: false, prefix: false, icon_size: "text-xs") + return unless all_states || object.agreement_declined? + + label = prefix ? "Agreement #{agreement_status_label.downcase}" : agreement_status_label + h.render "shared/badge", label: label, classes: agreement_status_classes, + icon: [ agreement_status_icon, icon_size ].compact_blank.join(" ") + end end diff --git a/app/models/concerns/registerable.rb b/app/models/concerns/registerable.rb index 5ee214511..2e4072eb6 100644 --- a/app/models/concerns/registerable.rb +++ b/app/models/concerns/registerable.rb @@ -42,6 +42,13 @@ def remaining_cost [ cost_cents.to_i - allocations_sum, 0 ].max end + # What would still be owed if one source's allocation were taken away — e.g. a + # scholarship the recipient hasn't accepted yet, which is already allocated. + def remaining_cost_without(source) + withdrawn = allocations.to_a.select { |a| a.source == source }.sum(&:amount) + [ cost_cents.to_i - (allocations_sum - withdrawn), 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 diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 956ccc5db..eda77e3ff 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -242,7 +242,7 @@ class EventRegistration < ApplicationRecord WHERE allocations.allocatable_type = 'EventRegistration' AND allocations.allocatable_id = event_registrations.id AND allocations.source_type = 'Scholarship' - AND scholarships.agreement_signed_at IS NOT NULL + AND scholarships.agreement_response_status = 'accepted' ) SQL } @@ -622,9 +622,16 @@ def registration_subject_noun scholarship_requested? ? "event scholarship registration" : "event registration" end + # A declined award carries no tasks, so it can't hold the certificate or the + # readiness checklist open. def scholarship_tasks_met? - return true if scholarships.empty? - scholarships.all?(&:tasks_completed?) + live = scholarships.reject(&:agreement_declined?) + return true if live.empty? + live.all?(&:tasks_completed?) + end + + def scholarship_declined? + scholarships.any?(&:agreement_declined?) end # Display-only: a scholarship is only *shown* as awarded once the recipient has diff --git a/app/models/grant.rb b/app/models/grant.rb index 6c8d0d048..9dd922525 100644 --- a/app/models/grant.rb +++ b/app/models/grant.rb @@ -55,7 +55,7 @@ def self.self_funded_ids # funds scopes so they stay flat WHERE clauses — no GROUP BY/HAVING, which would # break will_paginate's total_entries count on the paginated index. ALLOCATED_CENTS_SUBQUERY = - "COALESCE((SELECT SUM(scholarships.amount_cents) FROM scholarships WHERE scholarships.grant_id = grants.id), 0)".freeze + "COALESCE((SELECT SUM(scholarships.amount_cents) FROM scholarships WHERE scholarships.grant_id = grants.id AND scholarships.agreement_response_status <> 'declined'), 0)".freeze # Grants that still have unallocated funds (grant amount exceeds the sum of # scholarships drawn against them). @@ -71,11 +71,11 @@ def self.self_funded_ids # exclude grant-less scholarships (grant_id IS NULL) — a stray NULL in the # NOT IN set below would otherwise make all_tasks_completed match nothing. scope :tasks_outstanding, -> { - where(id: Scholarship.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id)) + where(id: Scholarship.not_declined.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id)) } scope :all_tasks_completed, -> { - where(id: Scholarship.where.not(grant_id: nil).select(:grant_id)) - .where.not(id: Scholarship.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id)) + where(id: Scholarship.not_declined.where.not(grant_id: nil).select(:grant_id)) + .where.not(id: Scholarship.not_declined.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id)) } # Grants offered in a scholarship's "Funded by grant" picker: every grant with @@ -126,9 +126,9 @@ def name_with_funder # association in memory when present (the index eager-loads :scholarships) to # avoid a per-row SQL SUM; otherwise issues a single aggregate query. def scholarships_total_cents - return scholarships.sum { |s| s.amount_cents.to_i } if scholarships.loaded? + return scholarships.reject(&:agreement_declined?).sum { |s| s.amount_cents.to_i } if scholarships.loaded? - scholarships.sum(:amount_cents) + scholarships.not_declined.sum(:amount_cents) end def remaining_cents diff --git a/app/models/scholarship.rb b/app/models/scholarship.rb index c7fa67c9d..2a570ff26 100644 --- a/app/models/scholarship.rb +++ b/app/models/scholarship.rb @@ -4,28 +4,40 @@ class Scholarship < ApplicationRecord has_one :allocation, as: :source, dependent: :destroy has_many :comments, -> { newest_first }, as: :commentable, dependent: :destroy has_many :notifications, as: :noticeable, dependent: :nullify + has_many :agreement_responses, -> { chronological }, class_name: "ScholarshipAgreementResponse", dependent: :destroy + + AGREEMENT_RESPONSE_STATUSES = %w[pending accepted declined].freeze accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? } accepts_nested_attributes_for :notifications, allow_destroy: true, reject_if: proc { |attrs| attrs["email_subject"].blank? } validates :amount_cents, numericality: { greater_than_or_equal_to: 0 } + validates :agreement_response_status, inclusion: { in: AGREEMENT_RESPONSE_STATUSES } validate :recipient_must_match_allocation_registrant validate :allocation_must_be_valid - validate :within_grant_budget, if: :grant + validate :within_grant_budget, if: -> { grant && !agreement_declined? } - after_update :sync_allocation_amount, if: -> { saved_change_to_amount_cents? } + # Allocation is zero while declined, else the amount — keeps allocation-based totals correct. + after_update :sync_allocation_amount, if: -> { saved_change_to_amount_cents? || saved_change_to_agreement_response_status? } + after_update :log_agreement_response, if: -> { saved_change_to_agreement_response_status? } + # An award can be created with the agreement toggle already on, which the update callback never sees. + after_create :log_agreement_response, unless: :agreement_pending? after_create_commit :flag_event_registration_scholarship_requested scope :completed, -> { where(tasks_completed: true) } - scope :agreement_signed, -> { where.not(agreement_signed_at: nil) } + scope :agreement_signed, -> { where(agreement_response_status: "accepted") } + scope :agreement_declined, -> { where(agreement_response_status: "declined") } + # Declined awards drop out of every total. + scope :not_declined, -> { where.not(agreement_response_status: "declined") } # Funding split (the app-wide convention, mirrored by EventDashboard and # EventRevenueFigures): externally funded = backed by a grant whose funder isn't # the org itself; org-subsidized = no grant, or a grant AWBW funded itself. # Callers rendering both sides can pass an already-loaded self_funded set to # avoid re-running Grant.self_funded_ids (an Organization.awbw + pluck) per scope. - scope :externally_funded, ->(self_funded = Grant.self_funded_ids) { where.not(grant_id: [ nil, *self_funded ]) } - scope :org_subsidized, ->(self_funded = Grant.self_funded_ids) { where(grant_id: [ nil, *self_funded ]) } + # Declined awards are excluded (a decline funds nothing). + scope :externally_funded, ->(self_funded = Grant.self_funded_ids) { not_declined.where.not(grant_id: [ nil, *self_funded ]) } + scope :org_subsidized, ->(self_funded = Grant.self_funded_ids) { not_declined.where(grant_id: [ nil, *self_funded ]) } # Scholarships from grants a given funder (Person/Organization) gave — the # "funder" filter. A blank funder matches nothing. @@ -50,16 +62,48 @@ def self.event_ids EventRegistration.where(id: registration_ids).distinct.pluck(:event_id) end - # The agreement is signed when a signed-at timestamp is present — a single - # source of truth. `agreement_signed` reads/writes as a virtual boolean so the - # admin form checkbox and strong params keep working, stamping or clearing the - # timestamp accordingly (and preserving an existing time across re-saves). - def agreement_signed? = agreement_signed_at.present? + # `agreement_signed` reads/writes as a virtual boolean for the admin checkbox + + # strong params: checking accepts, unchecking returns to pending. + def agreement_pending? = agreement_response_status == "pending" + def agreement_signed? = agreement_response_status == "accepted" + def agreement_declined? = agreement_response_status == "declined" alias_method :agreement_signed, :agreement_signed? def agreement_signed=(value) signed = ActiveModel::Type::Boolean.new.cast(value) - self.agreement_signed_at = signed ? (agreement_signed_at || Time.current) : nil + if signed + assign_agreement_response("accepted") unless agreement_signed? + elsif agreement_signed? + assign_agreement_response("pending") + end + end + + # Idempotent — a repeat accept is a no-op, so it logs no duplicate history row. + def accept_agreement!(by: "recipient") + return if agreement_signed? + + assign_agreement_response("accepted", by:) + save! + end + + def decline_agreement!(reason, by: "recipient") + assign_agreement_response("declined", reason:, by:) + save! + end + + # Admin re-offering a declined award: back to pending, allocation re-funded. + # Editing the amount alone no longer reactivates a decline. + def reoffer_agreement!(by: "admin") + return if agreement_pending? + + assign_agreement_response("pending", by:) + save! + end + + # Source for the responded-at date and decline reason (not stored on the + # scholarship). Nil while pending with no response yet. + def latest_agreement_response + agreement_responses.loaded? ? agreement_responses.max_by(&:responded_at) : agreement_responses.chronological.last end def amount_dollars @@ -81,7 +125,7 @@ def communications_email def within_grant_budget return unless amount_cents - others_total = grant.scholarships.where.not(id: id).sum(:amount_cents) + others_total = grant.scholarships.not_declined.where.not(id: id).sum(:amount_cents) if others_total + amount_cents > grant.amount_cents errors.add(:amount_cents, "would exceed the grant's available funds") end @@ -109,10 +153,31 @@ def recipient_must_match_allocation_registrant end end + # Set the status in memory; stash reason + responder for the history row the + # after_update callback writes (they live on the response, not the scholarship). + def assign_agreement_response(status, reason: nil, by: "admin") + self.agreement_response_status = status + @agreement_response_reason = (status == "declined" ? reason.presence : nil) + @agreement_response_by = by + end + def sync_allocation_amount return unless allocation - allocation.update!(amount: amount_cents.to_i) + desired = agreement_declined? ? 0 : amount_cents.to_i + allocation.update!(amount: desired) unless allocation.amount == desired + end + + def log_agreement_response + agreement_responses.create!( + status: agreement_response_status, + reason: @agreement_response_reason, + responded_at: Time.current, + responder: @agreement_response_by.presence || "admin", + amount_cents: amount_cents + ) + @agreement_response_reason = nil + @agreement_response_by = nil end # When a scholarship is awarded against an event registration, the registration diff --git a/app/models/scholarship_agreement_response.rb b/app/models/scholarship_agreement_response.rb new file mode 100644 index 000000000..bd160dd53 --- /dev/null +++ b/app/models/scholarship_agreement_response.rb @@ -0,0 +1,14 @@ +class ScholarshipAgreementResponse < ApplicationRecord + # One row per agreement transition (accept ↔ decline ↔ re-offer); the + # scholarship's agreement_response_status caches the latest row's status. + STATUSES = %w[pending accepted declined].freeze + RESPONDERS = %w[recipient admin system].freeze + + belongs_to :scholarship + + validates :status, inclusion: { in: STATUSES } + validates :responder, inclusion: { in: RESPONDERS }, allow_nil: true + validates :responded_at, presence: true + + scope :chronological, -> { order(:responded_at, :id) } +end diff --git a/app/presenters/scholarships_grouping.rb b/app/presenters/scholarships_grouping.rb index 3bba33257..3abe951b2 100644 --- a/app/presenters/scholarships_grouping.rb +++ b/app/presenters/scholarships_grouping.rb @@ -9,8 +9,9 @@ class ScholarshipsGrouping UNFUNDED_LABEL = "Unfunded".freeze GrantGroup = Struct.new(:grant, :scholarships, keyword_init: true) do - def total_cents = scholarships.sum { |s| s.amount_cents.to_i } - def count = scholarships.size + # Declined awards still list (badged) but never count toward the group totals. + def total_cents = scholarships.reject(&:agreement_declined?).sum { |s| s.amount_cents.to_i } + def count = scholarships.reject(&:agreement_declined?).size end FunderGroup = Struct.new(:name, :funder, :grant_groups, keyword_init: true) do @@ -29,6 +30,10 @@ def funder_groups .sort_by { |group| sort_key(group) } end + # The index header count — summed from the groups so it reconciles with the + # per-group badges rather than counting the declined rows they leave out. + def total_count = funder_groups.sum(&:count) + private def build_funder_group(scholarships) diff --git a/app/services/attendees_breakdowns.rb b/app/services/attendees_breakdowns.rb index c9e9ea061..991efb065 100644 --- a/app/services/attendees_breakdowns.rb +++ b/app/services/attendees_breakdowns.rb @@ -325,6 +325,7 @@ def city_by_organization def scholarship_recipient_ids @scholarship_recipient_ids ||= Scholarship + .not_declined .joins(:allocation) .where(allocations: { allocatable_type: "EventRegistration", allocatable_id: registration_ids }) .distinct diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index c427a1c7c..349cffb68 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -237,7 +237,8 @@ def certificate_pending_badge # Shown only when the registrant requested a scholarship. Its page surfaces the # award amount, funder, and tasks once awarded. Awarded but with tasks still # pending shows an amber "$X · Tasks outstanding" badge (action needed); fully - # met shows a fuchsia amount badge. + # met shows a fuchsia amount badge. A declined award keeps the card — the page + # behind it holds the decline confirmation — but with nothing left to act on. def scholarship_status_card return if config_gap?("scholarship") return unless registration.scholarship_requested? @@ -245,7 +246,8 @@ def scholarship_status_card # is only shown as awarded once the recipient signs the agreement. Until then # the card prompts them to accept. awarded = registration.scholarship_awarded? - needs_agreement = registration.scholarship? && !awarded + declined = registration.scholarships.any?(&:agreement_declined?) + needs_agreement = registration.scholarship? && !awarded && !declined tasks_outstanding = awarded && !registration.scholarship_tasks_met? action_needed = needs_agreement || tasks_outstanding Card.new(icon_class: "fa-solid fa-award", @@ -253,22 +255,28 @@ def scholarship_status_card # tasks), otherwise the scholarship colour. color: action_needed ? "amber" : DomainTheme.color_for(:scholarships).to_s, title: "Scholarship", - subtitle: scholarship_subtitle(awarded, needs_agreement), + subtitle: scholarship_subtitle(awarded, needs_agreement, declined), href: registration_scholarship_path(registration.slug), target: nil, trailing_icon: "fa-solid fa-arrow-right", - badge: scholarship_badge(awarded, tasks_outstanding), - badge_classes: tasks_outstanding ? nil : "bg-fuchsia-100 text-fuchsia-800 border border-fuchsia-300") + badge: declined ? "Declined" : scholarship_badge(awarded, tasks_outstanding), + badge_classes: scholarship_badge_classes(declined, tasks_outstanding)) end - def scholarship_subtitle(awarded, needs_agreement) + def scholarship_subtitle(awarded, needs_agreement, declined) + return "You declined this award" if declined return "Your award — amount, funder, and tasks" if awarded return "Review and accept your scholarship agreement" if needs_agreement "Your scholarship request status" end + def scholarship_badge_classes(declined, tasks_outstanding) + return "bg-red-100 text-red-800 border border-red-300" if declined + tasks_outstanding ? nil : "bg-fuchsia-100 text-fuchsia-800 border border-fuchsia-300" + end + def scholarship_badge(awarded, tasks_outstanding) return unless awarded - amount = MoneyFormatter.dollars_from_cents(registration.scholarships.sum(:amount_cents)) + amount = MoneyFormatter.dollars_from_cents(registration.scholarships.not_declined.sum(:amount_cents)) tasks_outstanding ? "#{amount} · Tasks outstanding" : amount end diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 896e605f9..04089aa74 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -259,9 +259,13 @@ def shoutouts # The scholarship record per recipient, keyed by Person id — lets the roster # decide whether to flag a registrant as a scholarship recipient. First - # scholarship wins if a person has several. + # scholarship wins if a person has several, preferring a live award over a + # declined one so a re-award isn't hidden behind the decline it replaced. def scholarship_by_recipient - @scholarship_by_recipient ||= scholarships.includes(grant: :funder).group_by(&:recipient_id).transform_values(&:first) + @scholarship_by_recipient ||= all_scholarships + .includes(grant: :funder) + .group_by(&:recipient_id) + .transform_values { |awards| awards.reject(&:agreement_declined?).first || awards.first } end # Active registration slug per registrant (Person id) — a stable, non-db @@ -1214,8 +1218,11 @@ def bulk_payments ) end - def scholarships - @scholarships ||= begin + # Every award on this event's active registrations, declined included — the + # display lookups still surface a declined award (badged). Money and counts run + # off #scholarships, which drops them. + def all_scholarships + @all_scholarships ||= begin scope = Scholarship .joins(:allocation) .where(allocations: { allocatable_type: "EventRegistration", allocatable_id: active_registration_ids }) @@ -1224,6 +1231,10 @@ def scholarships end end + def scholarships + @scholarships ||= all_scholarships.not_declined + end + # Ids of grants the scoped funder gave — used to narrow scholarships to one # funder. Empty (so no scholarships match) when the funder gave none. def funder_grant_ids diff --git a/app/services/event_registration_readiness.rb b/app/services/event_registration_readiness.rb index a53717f3e..4565e77ce 100644 --- a/app/services/event_registration_readiness.rb +++ b/app/services/event_registration_readiness.rb @@ -91,7 +91,10 @@ def certificate_due_reason # Each pre-event check, in priority order: [ predicate, two-word reason (shown # under a "Not ready" badge), full description (tooltip) ]. One table keeps the # short and long forms in sync. + # A decline outranks the payment gap it creates: zeroing the allocation is what + # reopens the balance, so the admin's next step is answering the decline. EVENT_READY_CHECKS = [ + [ :scholarship_declined?, "Award declined", "Scholarship declined — respond to the decline" ], [ :payment_due?, "Payment due", "Payment due" ], [ :organization_missing?, "Org validation", "No organization linked" ], [ :scholarship_uncreated?, "No scholarship", "Scholarship not created" ], @@ -149,6 +152,10 @@ def organization_missing? registration.organizations.empty? end + def scholarship_declined? + registration.scholarship_declined? + end + def scholarship_uncreated? registration.scholarship_requested? && !registration.scholarship? end diff --git a/app/services/event_revenue_figures.rb b/app/services/event_revenue_figures.rb index 59743e38e..f93695204 100644 --- a/app/services/event_revenue_figures.rb +++ b/app/services/event_revenue_figures.rb @@ -222,6 +222,7 @@ def ce_rows_by_registration # recipient id feeds the scholarship drilldowns; #build reads only the first two. def scholarship_rows_by_registration @scholarship_rows_by_registration ||= Scholarship + .not_declined .joins(:allocation) .where(allocations: { allocatable_type: "EventRegistration", allocatable_id: registration_ids }) .pluck(Arel.sql("allocations.allocatable_id"), :grant_id, :amount_cents, :recipient_id) diff --git a/app/services/event_scholarship_figures.rb b/app/services/event_scholarship_figures.rb index c4572d73a..400c31bf8 100644 --- a/app/services/event_scholarship_figures.rb +++ b/app/services/event_scholarship_figures.rb @@ -127,6 +127,7 @@ def registration_ids def scholarship_rows_by_registration @scholarship_rows_by_registration ||= begin scope = Scholarship + .not_declined .joins(:allocation) .where(allocations: { allocatable_type: "EventRegistration", allocatable_id: registration_ids }) scope = scope.where(grant_id: funder_grant_ids) if @funder diff --git a/app/views/event_registrations/_scholarship.html.erb b/app/views/event_registrations/_scholarship.html.erb index 1e6a7fce3..7cc16868c 100644 --- a/app/views/event_registrations/_scholarship.html.erb +++ b/app/views/event_registrations/_scholarship.html.erb @@ -51,17 +51,7 @@ the organizations card's "Connect organization" link. %>
| <%= link_to person.name, person_link, class: "font-bold text-gray-900 hover:underline text-base sm:text-sm" %> @@ -96,7 +96,7 @@ sortable (data-sort-sector / data-sort-age), mirroring the First/Last name cell. Sector and age-group chips share a single wrapping row. %> - | " data-sort-age="<%= person_age_groups.join(", ").downcase %>"> <%= mobile_label.("Sector / age") %> @@ -112,7 +112,7 @@ <% end %> | -"> + | ">
<%= mobile_label.("Organization") %>
<% if person_orgs.any? %>
@@ -123,7 +123,7 @@
|
<% statuses = roster.program_statuses_by_registrant[person.id] || [] %>
- "> + | ">
<%= mobile_label.("Program status") %>
<% if statuses.any? %>
@@ -139,7 +139,7 @@
|
<% if show_affiliation_status %>
<% affiliation_statuses = roster.affiliation_statuses_by_registrant[person.id] || [] %>
- "> + | ">
<%= mobile_label.("Affiliation status") %>
<% if affiliation_statuses.any? %>
@@ -154,7 +154,7 @@
|
<% end %>
- + |
<%= mobile_label.("Location") %>
<% if location.present? %>
@@ -166,19 +166,21 @@
<% end %>
|
- + | <%= mobile_label.("Scholarship") %> <% if scholarship %> <% target_event, participant_slug = roster.scholarship_link_target(person) %> + <% declined = scholarship.agreement_declined? %> <%= link_to recipients_event_path(target_event, anchor: ("participant-#{participant_slug}" if participant_slug)), - class: "inline-flex items-center gap-1 #{DomainTheme.text_class_for(:scholarships, intensity: 600)} hover:#{DomainTheme.text_class_for(:scholarships)}", - title: show_event_column ? "Scholarship from #{target_event.decorate.compact_label} — view application" : "Scholarship recipient — view application", + class: "inline-flex items-center gap-1 #{declined ? "text-gray-400 hover:text-gray-600" : "#{DomainTheme.text_class_for(:scholarships, intensity: 600)} hover:#{DomainTheme.text_class_for(:scholarships)}"}", + title: declined ? "Scholarship declined — view application" : (show_event_column ? "Scholarship from #{target_event.decorate.compact_label} — view application" : "Scholarship recipient — view application"), onclick: "event.stopPropagation()" do %> <% if show_event_column %><%= target_event.decorate.compact_label %><% end %> + <%= scholarship.decorate.agreement_status_badge %> <% end %> <% else %> - — + — <% end %> | <%# CE: the icon shows for registrants with a CE registration and @@ -202,7 +204,7 @@ status as a read-only pill that links to its edit page — the outcome is edited there, not inline. %> <% if show_attendance_status %> -+ |
<%= mobile_label.("Attendance") %>
<% if registration %>
@@ -229,7 +231,7 @@
<% registrations = roster.event_registrations_by_registrant[person.id] || [] %>
<% first_registration = registrations.first %>
<% event_link_class = "font-medium #{DomainTheme.text_class_for(:event_registrations, intensity: 700)} hover:underline" %>
- 1 %>data-controller="expandable-card" data-expandable-card-expanded-value="false" data-action="expandable-cards:expandAll@window->expandable-card#expand expandable-cards:collapseAll@window->expandable-card#collapse"<% end %>>
<%= mobile_label.("Events attended") %>
@@ -238,7 +240,7 @@
|
<%= link_to edit_event_registration_path(first_registration, return_to: "attendees"), class: event_link_class do %>
<%= first_registration.event.decorate.compact_label %>
- <%= first_registration.event.start_date&.strftime("%b %Y") %>
+ <%= first_registration.event.start_date&.strftime("%b %Y") %>
<% end %>
<% if registrations.size > 1 %>
<% if registrations.size > 1 %>
-
+ |
No registrants yet.
+No registrants yet.
<% end %> diff --git a/app/views/events/callouts/scholarship.html.erb b/app/views/events/callouts/scholarship.html.erb index ca759940f..82c1852f6 100644 --- a/app/views/events/callouts/scholarship.html.erb +++ b/app/views/events/callouts/scholarship.html.erb @@ -26,8 +26,12 @@<%= @scholarship.agreement_signed? ? "Amount awarded" : "Amount offered" %>
<%= dollars_from_cents(@scholarship.amount_cents) %>
- <%# Status chip: pending until the agreement is signed, then the tasks state. %> - <% if !@scholarship.agreement_signed? %> + <%# Status chip: declined, else pending until the agreement is signed, then the tasks state. %> + <% if @scholarship.agreement_declined? %> + + Declined + + <% elsif !@scholarship.agreement_signed? %> Pending agreement @@ -42,21 +46,63 @@ <% end %> - <%# Scholarship agreement — shown above the details so accepting comes first. - A single Agree button signs it; afterwards we confirm with the date. %> + <%# What the registrant owes. The award is allocated before it's accepted, so + while the agreement is pending this frames the balance as the choice the + buttons below are asking for rather than asserting the accepted one. %> + <% if @event.cost_cents.to_i.positive? && !@scholarship.agreement_declined? %> + <% owed = @event_registration.remaining_cost %> + <% undecided = !@scholarship.agreement_signed? %> + <% owed_if_declined = undecided ? @event_registration.remaining_cost_without(@scholarship) : owed %> +<% if undecided %>Accept and your<% else %>Your<% end %> <%= dollars_from_cents(@event.cost_cents) %> registration is fully covered — you'll owe nothing.
+ <% else %> +<% if undecided %>Accept and you'll<% else %>You'll<% end %> owe <%= dollars_from_cents(owed) %> of the <%= dollars_from_cents(@event.cost_cents) %> registration cost.
+ <% end %> + <% if owed_if_declined > owed %> +Decline and <%= dollars_from_cents(owed_if_declined) %> is due.
+ <% end %> +- Agreement signed<% if @scholarship.agreement_signed_at %> · <%= @scholarship.agreement_signed_at.strftime("%B %-d, %Y") %><% end %> + Agreement signed<% if latest_response&.responded_at %> · <%= latest_response.responded_at.strftime("%B %-d, %Y") %><% end %> +
+ <% elsif @scholarship.agreement_declined? %> ++ + You declined this scholarship<% if latest_response&.responded_at %> · <%= latest_response.responded_at.strftime("%B %-d, %Y") %><% end %>
+Thank you for letting us know. If you'd like to reconsider, please contact us.
<% else %>Agree to complete your scholarship tasks to accept this award.
- <%= form_with url: registration_scholarship_agreement_path(@event_registration.slug), method: :post, class: "mt-3" do %> - - <% end %> + <%# Native“<%= response.reason %>”
+ <% end %> +Scholarship agreement
-Signed agreement on file from the recipient
+Scholarship agreement
+Signed agreement on file from the recipient
+Declined by recipient<% if declined_response&.responded_at %> · <%= declined_response.responded_at.strftime("%B %-d, %Y") %><% end %>
+ <% if declined_response&.reason.present? %> +“<%= declined_response.reason %>”
+ <% end %> +This award isn't counted in any totals while declined.
+To re-offer at new terms, change the amount and save first — then click Re-offer.
+<%= @scholarship.recipient.full_name %>
+<%= @scholarship.recipient.full_name %>
<% end %>“<%= latest_response.reason %>”
+ <% end %> +The recipient's scholarship page spells out what each choice costs them, so + they are deciding against a real number rather than a promise. Example: a $100 + registration with a $50 award.
+| State | Copy |
|---|---|
| Pending, balance left | +"Accept and you'll owe $50 of the $100 registration cost." + "Decline and $100 is due." | +
| Pending, no balance left | +"Accept and your $100 registration is fully covered — you'll owe nothing." + "Decline and $50 is due." | +
| Signed, balance left | +"You'll owe $50 of the $100 registration cost." | +
| Signed, no balance left | +"Your $100 registration is fully covered — you'll owe nothing." | +
The decline figure is priced off that award alone, so anything already paid + stays paid. A declined award hides the box entirely.
+ - name: "Program status report for annual reporting" area: reporting display_status: admin_facing diff --git a/config/routes.rb b/config/routes.rb index eacdcbb88..bc2320c6a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -86,6 +86,7 @@ get "registration/:slug/receipt", to: "events/registrations#receipt", as: :registration_receipt get "registration/:slug/scholarship", to: "events/callouts#scholarship", as: :registration_scholarship post "registration/:slug/scholarship/agreement", to: "events/callouts#sign_agreement", as: :registration_scholarship_agreement + post "registration/:slug/scholarship/decline", to: "events/callouts#decline_agreement", as: :registration_scholarship_decline get "registration/:slug/faq", to: "events/callouts#faq", as: :registration_faq get "registration/:slug/payment", to: "events/callouts#payment", as: :registration_payment get "registration/:slug/certificate", to: "events/callouts#certificate", as: :registration_certificate @@ -148,7 +149,10 @@ resources :form_submissions, only: [ :index, :show ] resources :grants resources :scholarships, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do - member { patch :toggle_tasks } + member do + patch :toggle_tasks + post :reoffer + end resources :comments, only: [ :create, :update ] end resources :continuing_education_registrations, only: [ :index, :show, :new, :create, :edit, :update, :destroy ] do diff --git a/db/migrate/20260813121725_create_scholarship_agreement_responses.rb b/db/migrate/20260813121725_create_scholarship_agreement_responses.rb new file mode 100644 index 000000000..4ffeb810a --- /dev/null +++ b/db/migrate/20260813121725_create_scholarship_agreement_responses.rb @@ -0,0 +1,19 @@ +class CreateScholarshipAgreementResponses < ActiveRecord::Migration[8.1] + # Append-only log of each agreement transition; scholarships.agreement_response_status + # (added next) caches the latest row's status. + def up + create_table :scholarship_agreement_responses do |t| + t.references :scholarship, null: false, foreign_key: true + t.string :status, null: false + t.text :reason + t.datetime :responded_at, null: false + t.string :responder + t.integer :amount_cents + t.timestamps + end + end + + def down + drop_table :scholarship_agreement_responses, if_exists: true + end +end diff --git a/db/migrate/20260813121726_replace_scholarship_agreement_signed_at_with_response_status.rb b/db/migrate/20260813121726_replace_scholarship_agreement_signed_at_with_response_status.rb new file mode 100644 index 000000000..e612e0d9f --- /dev/null +++ b/db/migrate/20260813121726_replace_scholarship_agreement_signed_at_with_response_status.rb @@ -0,0 +1,13 @@ +class ReplaceScholarshipAgreementSignedAtWithResponseStatus < ActiveRecord::Migration[8.1] + # Replace agreement_signed_at with a tri-state status. No backfill — there are no + # signed agreements in production, so existing rows correctly default to pending. + def up + add_column :scholarships, :agreement_response_status, :string, null: false, default: "pending" + remove_column :scholarships, :agreement_signed_at + end + + def down + add_column :scholarships, :agreement_signed_at, :datetime + remove_column :scholarships, :agreement_response_status + end +end diff --git a/db/schema.rb b/db/schema.rb index 300085519..8674e05fd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1283,8 +1283,20 @@ t.index ["workshop_id"], name: "index_resources_on_workshop_id" end + create_table "scholarship_agreement_responses", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.integer "amount_cents" + t.datetime "created_at", null: false + t.text "reason" + t.datetime "responded_at", null: false + t.string "responder" + t.bigint "scholarship_id", null: false + t.string "status", null: false + t.datetime "updated_at", null: false + t.index ["scholarship_id"], name: "index_scholarship_agreement_responses_on_scholarship_id" + end + create_table "scholarships", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.datetime "agreement_signed_at" + t.string "agreement_response_status", default: "pending", null: false t.integer "amount_cents", default: 0, null: false t.datetime "created_at", null: false t.bigint "grant_id" @@ -1904,6 +1916,7 @@ add_foreign_key "resources", "users", column: "created_by_id" add_foreign_key "resources", "windows_types" add_foreign_key "resources", "workshops" + add_foreign_key "scholarship_agreement_responses", "scholarships" add_foreign_key "scholarships", "grants" add_foreign_key "scholarships", "people", column: "recipient_id" add_foreign_key "sectorable_items", "sectors" diff --git a/spec/decorators/scholarship_decorator_spec.rb b/spec/decorators/scholarship_decorator_spec.rb index 51310be52..2975df452 100644 --- a/spec/decorators/scholarship_decorator_spec.rb +++ b/spec/decorators/scholarship_decorator_spec.rb @@ -14,6 +14,33 @@ expect(create(:scholarship, recipient: recipient, agreement_signed: true).decorate.agreement_signed?).to be(true) end + describe "agreement status pill" do + it "labels and colours each of the three states" do + scholarship = create(:scholarship, recipient: recipient) + expect(scholarship.decorate).to have_attributes(agreement_status_label: "Pending", agreement_status_classes: a_string_including("amber")) + + scholarship.update!(agreement_signed: true) + expect(scholarship.decorate).to have_attributes(agreement_status_label: "Signed", agreement_status_classes: a_string_including("fuchsia")) + + scholarship.decline_agreement!("Timing no longer works") + expect(scholarship.decorate).to have_attributes(agreement_status_label: "Declined", agreement_status_classes: a_string_including("red")) + end + + it "renders the badge only for a declined award by default" do + scholarship = create(:scholarship, recipient: recipient) + expect(scholarship.decorate.agreement_status_badge).to be_nil + + scholarship.decline_agreement!("Timing no longer works") + expect(scholarship.decorate.agreement_status_badge).to include("Declined", "fa-circle-xmark") + end + + it "renders every state, prefixed, when asked" do + scholarship = create(:scholarship, recipient: recipient) + + expect(scholarship.decorate.agreement_status_badge(all_states: true, prefix: true)).to include("Agreement pending") + end + end + describe "program columns derived from the recipient's facilitator affiliation" do let(:org) { create(:organization, name: "Prevail") } diff --git a/spec/factories/scholarship_agreement_responses.rb b/spec/factories/scholarship_agreement_responses.rb new file mode 100644 index 000000000..dcbf333aa --- /dev/null +++ b/spec/factories/scholarship_agreement_responses.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :scholarship_agreement_response do + association :scholarship + status { "declined" } + reason { "Timing no longer works" } + responded_at { Time.current } + responder { "recipient" } + amount_cents { 1000 } + end +end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index da529dc15..e68cfe4d4 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -393,11 +393,11 @@ def registration_with_scholarship describe ".scholarship_status agreed" do it "matches registrations with an agreement-signed scholarship" do agreed_reg = create(:event_registration) - agreed = create(:scholarship, recipient: agreed_reg.registrant, agreement_signed_at: Time.current) + agreed = create(:scholarship, recipient: agreed_reg.registrant, agreement_signed: true) create(:allocation, source: agreed, allocatable: agreed_reg, amount: 0) pending_reg = create(:event_registration) - pending = create(:scholarship, recipient: pending_reg.registrant, agreement_signed_at: nil) + pending = create(:scholarship, recipient: pending_reg.registrant, agreement_signed: false) create(:allocation, source: pending, allocatable: pending_reg, amount: 0) results = EventRegistration.scholarship_status("agreed") @@ -763,6 +763,37 @@ def registration_with_scholarship create(:allocation, source: scholarship, allocatable: reg, amount: 1099) expect(reg.scholarship_tasks_met?).to be true end + + it "ignores a declined award, which carries no tasks" do + reg = create(:event_registration) + scholarship = create(:scholarship, recipient: reg.registrant, tasks_completed: false, amount_cents: 1099) + create(:allocation, source: scholarship, allocatable: reg, amount: 1099) + scholarship.reload.decline_agreement!("Timing no longer works") + + expect(reg.reload.scholarship_tasks_met?).to be true + expect(reg.scholarship_declined?).to be true + end + end + + describe "#remaining_cost_without" do + let(:event) { create(:event, cost_cents: 10_000) } + let(:reg) { create(:event_registration, event: event) } + let(:scholarship) { create(:scholarship, recipient: reg.registrant, amount_cents: 5_000) } + + before { create(:allocation, source: scholarship, allocatable: reg, amount: 5_000) } + + it "adds the source's allocation back onto the balance" do + expect(reg.reload.remaining_cost).to eq(5_000) + expect(reg.remaining_cost_without(scholarship)).to eq(10_000) + end + + it "leaves the other allocations in place" do + create(:allocation, source: create(:payment, amount_cents: 5_000, amount_cents_remaining: 5_000), + allocatable: reg, amount: 5_000) + + expect(reg.reload.remaining_cost).to eq(0) + expect(reg.remaining_cost_without(scholarship)).to eq(5_000) + end end describe "#scholarship_awarded?" do diff --git a/spec/models/grant_spec.rb b/spec/models/grant_spec.rb index 5c212e0c1..db9f3eee7 100644 --- a/spec/models/grant_spec.rb +++ b/spec/models/grant_spec.rb @@ -191,6 +191,23 @@ expect(grant.remaining_cents).to eq(60_000) expect(grant.remaining_dollars).to eq(600) end + + it "excludes declined scholarships from the total and frees their funds" do + create(:scholarship, grant:, amount_cents: 30_000) + declined = create(:scholarship, grant:, amount_cents: 20_000) + declined.decline_agreement!("Not this year") + + expect(grant.scholarships_total_cents).to eq(30_000) + expect(grant.remaining_cents).to eq(70_000) + end + + it "excludes declined scholarships from the preloaded (in-memory) total too" do + create(:scholarship, grant:, amount_cents: 30_000) + create(:scholarship, grant:, amount_cents: 20_000).decline_agreement!("out") + preloaded = Grant.includes(:scholarships).find(grant.id) + + expect(preloaded.scholarships_total_cents).to eq(30_000) + end end describe ".with_funds_remaining" do diff --git a/spec/models/scholarship_agreement_response_spec.rb b/spec/models/scholarship_agreement_response_spec.rb new file mode 100644 index 000000000..1bd297973 --- /dev/null +++ b/spec/models/scholarship_agreement_response_spec.rb @@ -0,0 +1,30 @@ +require "rails_helper" + +RSpec.describe ScholarshipAgreementResponse, type: :model do + it "belongs to a scholarship" do + expect(described_class.new).to respond_to(:scholarship) + end + + it "validates status is one of the known values" do + response = build(:scholarship_agreement_response, status: "nope") + expect(response).not_to be_valid + expect(response.errors[:status]).to be_present + end + + it "allows a nil responder but rejects an unknown one" do + expect(build(:scholarship_agreement_response, responder: nil)).to be_valid + expect(build(:scholarship_agreement_response, responder: "stranger")).not_to be_valid + end + + it "requires responded_at" do + expect(build(:scholarship_agreement_response, responded_at: nil)).not_to be_valid + end + + it ".chronological orders by responded_at" do + scholarship = create(:scholarship) + later = create(:scholarship_agreement_response, scholarship:, responded_at: 1.hour.ago) + earlier = create(:scholarship_agreement_response, scholarship:, responded_at: 2.hours.ago) + + expect(scholarship.agreement_responses.chronological).to eq([ earlier, later ]) + end +end diff --git a/spec/models/scholarship_spec.rb b/spec/models/scholarship_spec.rb index a6486cff4..4e4807f7e 100644 --- a/spec/models/scholarship_spec.rb +++ b/spec/models/scholarship_spec.rb @@ -108,37 +108,151 @@ end end - describe "agreement_signed (virtual, backed by agreement_signed_at)" do - it "infers the flag from the timestamp" do + describe "agreement response status (pending → accepted → declined)" do + it "starts pending" do scholarship = create(:scholarship) + expect(scholarship.agreement_pending?).to be(true) expect(scholarship.agreement_signed?).to be(false) - - scholarship.update!(agreement_signed_at: Time.current) - expect(scholarship.agreement_signed?).to be(true) + expect(scholarship.agreement_declined?).to be(false) end - it "stamps the time when the agreement is first signed" do + it "accepts via the virtual agreement_signed setter and stamps the time" do scholarship = create(:scholarship) - expect(scholarship.agreement_signed_at).to be_nil scholarship.update!(agreement_signed: true) - expect(scholarship.agreement_signed_at).to be_present + + expect(scholarship.agreement_signed?).to be(true) + expect(scholarship.latest_agreement_response.responded_at).to be_present end - it "clears the time when the agreement is unsigned" do + it "returns to pending when unsigned" do scholarship = create(:scholarship, agreement_signed: true) - expect(scholarship.agreement_signed_at).to be_present scholarship.update!(agreement_signed: false) - expect(scholarship.agreement_signed_at).to be_nil + + expect(scholarship.agreement_signed?).to be(false) + expect(scholarship.agreement_pending?).to be(true) + end + + it "#accept_agreement! is idempotent (no duplicate history row)" do + scholarship = create(:scholarship) + scholarship.accept_agreement! + + expect { scholarship.accept_agreement! }.not_to change { scholarship.agreement_responses.count } + end + + it "logs a response when the award is created already signed (the admin form's toggle)" do + scholarship = create(:scholarship, agreement_signed: true) + + expect(scholarship.agreement_responses.count).to eq(1) + expect(scholarship.latest_agreement_response).to have_attributes(status: "accepted") + expect(scholarship.latest_agreement_response.responded_at).to be_present + end + + it "logs nothing when the award is created pending" do + expect(create(:scholarship).agreement_responses.count).to eq(0) end + end + + describe "declining" do + it "#decline_agreement! records the status, time, and reason" do + scholarship = create(:scholarship) + + scholarship.decline_agreement!("Timing no longer works") - it "preserves the original time when re-saved while still signed" do + expect(scholarship.agreement_declined?).to be(true) + expect(scholarship.latest_agreement_response.responded_at).to be_present + expect(scholarship.latest_agreement_response.reason).to eq("Timing no longer works") + end + + it "#decline_agreement! clears any prior signed state (mutually exclusive)" do scholarship = create(:scholarship, agreement_signed: true) - original = scholarship.agreement_signed_at - scholarship.update!(amount_cents: 2_000) - expect(scholarship.reload.agreement_signed_at).to be_within(1.second).of(original) + scholarship.decline_agreement!("Changed my mind") + + expect(scholarship.agreement_signed?).to be(false) + expect(scholarship.agreement_declined?).to be(true) + end + + it "#decline_agreement! stores nil for a blank reason" do + scholarship = create(:scholarship) + + scholarship.decline_agreement!("") + + expect(scholarship.latest_agreement_response.reason).to be_nil + end + + it "stays declined (allocation still zero) when only the amount is edited" do + event = create(:event, cost_cents: 10_000) + registration = create(:event_registration, event:) + scholarship = create(:scholarship, recipient: registration.registrant, amount_cents: 5_000) + create(:allocation, source: scholarship, allocatable: registration, amount: 5_000) + scholarship.reload + scholarship.decline_agreement!("No longer available") + + scholarship.update!(amount_cents: 6_000) + + # Editing the amount no longer reactivates a decline — that's the explicit + # Re-offer action now. + expect(scholarship.reload.agreement_declined?).to be(true) + expect(scholarship.allocation.reload.amount).to eq(0) + end + + it "#reoffer_agreement! returns a declined award to pending and re-funds it" do + event = create(:event, cost_cents: 10_000) + registration = create(:event_registration, event:) + scholarship = create(:scholarship, recipient: registration.registrant, amount_cents: 5_000) + create(:allocation, source: scholarship, allocatable: registration, amount: 5_000) + scholarship.reload + scholarship.decline_agreement!("No longer available") + scholarship.update!(amount_cents: 6_000) # adjust terms first, still declined + + scholarship.reoffer_agreement! + + expect(scholarship.agreement_pending?).to be(true) + expect(scholarship.allocation.reload.amount).to eq(6_000) + expect(scholarship.latest_agreement_response).to have_attributes(status: "pending", responder: "admin") + end + + it "excludes declined scholarships from the .not_declined scope" do + active = create(:scholarship) + declined = create(:scholarship) + declined.decline_agreement!("out") + + expect(Scholarship.not_declined).to include(active) + expect(Scholarship.not_declined).not_to include(declined) + end + + it "accepting a declined award reinstates it and re-funds the allocation" do + event = create(:event, cost_cents: 10_000) + registration = create(:event_registration, event:) + scholarship = create(:scholarship, recipient: registration.registrant, amount_cents: 5_000) + create(:allocation, source: scholarship, allocatable: registration, amount: 5_000) + scholarship.reload + scholarship.decline_agreement!("no") + expect(scholarship.allocation.reload.amount).to eq(0) + + # The admin "Agreement signed" toggle routes through agreement_signed=. + scholarship.update!(agreement_signed: true) + + expect(scholarship.agreement_signed?).to be(true) + expect(scholarship.agreement_declined?).to be(false) + expect(scholarship.allocation.reload.amount).to eq(5_000) + end + end + + describe "agreement response history" do + it "appends a row on each transition, capturing status, reason, responder, and amount" do + scholarship = create(:scholarship, amount_cents: 5_000) + + scholarship.decline_agreement!("Not this year", by: "recipient") + scholarship.reoffer_agreement!(by: "admin") + scholarship.accept_agreement!(by: "recipient") + + history = scholarship.agreement_responses.chronological + expect(history.map(&:status)).to eq(%w[declined pending accepted]) + expect(history.first).to have_attributes(reason: "Not this year", responder: "recipient", amount_cents: 5_000) + expect(history.last).to have_attributes(status: "accepted", responder: "recipient") end end diff --git a/spec/presenters/scholarships_grouping_spec.rb b/spec/presenters/scholarships_grouping_spec.rb index e03fdbd4b..0d4499dde 100644 --- a/spec/presenters/scholarships_grouping_spec.rb +++ b/spec/presenters/scholarships_grouping_spec.rb @@ -27,6 +27,20 @@ expect(names.last).to eq("Unfunded") end + it "lists a declined award but leaves it out of the counts and totals" do + grant = create(:grant, amount_cents: 1_000_000) + create(:scholarship, grant: grant, amount_cents: 100_000) + create(:scholarship, grant: grant, amount_cents: 50_000).decline_agreement!("Timing no longer works") + + grouping = described_class.new(Scholarship.all) + grant_group = grouping.funder_groups.first.grant_groups.first + + expect(grant_group.scholarships.size).to eq(2) + expect(grant_group.count).to eq(1) + expect(grant_group.total_cents).to eq(100_000) + expect(grouping.total_count).to eq(1) + end + it "orders recipients within a grant by name" do grant = create(:grant, amount_cents: 1_000_000) create(:scholarship, grant: grant, recipient: create(:person, first_name: "Zoe", last_name: "Z")) diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index 6c85a300f..62ee5e7b1 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -569,6 +569,70 @@ expect(response.body).to include("Agreement signed") expect(response.body).not_to include("Pending agreement") end + + it "frames the balance as the accept/decline choice while unsigned" do + get registration_scholarship_path(registration.slug) + + # $100 event cost − $50 scholarship allocation = $50 owed if accepted, + # the full $100 if declined. + expect(response.body).to include("Accept and you'll owe") + expect(response.body).to include("$50") + expect(response.body).to match(/Decline and\s*\$100<\/strong> is due/) + end + + it "states the balance plainly once the agreement is signed" do + scholarship.update!(agreement_signed: true) + get registration_scholarship_path(registration.slug) + + expect(response.body).to match(/You'll owe\s*\$50<\/strong>/) + expect(response.body).not_to include("Accept and you'll owe") + expect(response.body).not_to include("Decline and") + end + + it "prices the decline off this award alone, not the whole registration" do + create(:allocation, source: create(:payment, amount_cents: 5_000, amount_cents_remaining: 5_000), + allocatable: registration, amount: 5_000) + get registration_scholarship_path(registration.slug) + + # The $50 already paid stays paid, so declining leaves $50 due, not $100. + expect(response.body).to include("registration is fully covered") + expect(response.body).to match(/Decline and\s*\$50<\/strong> is due/) + end + + it "offers a Decline option with a reason box while unsigned" do + get registration_scholarship_path(registration.slug) + + expect(response.body).to include("Decline") + expect(response.body).to match(/name="decline_reason"/) + end + + it "shows the declined state instead of the buttons once declined" do + scholarship.decline_agreement!("Timing no longer works") + get registration_scholarship_path(registration.slug) + + expect(response.body).to include("You declined this scholarship") + expect(response.body).not_to match(/name="agreement" value="yes"/) + end + + it "hides the agreement history from a registrant (public view)" do + scholarship.decline_agreement!("Timing no longer works") + get registration_scholarship_path(registration.slug) + + expect(response.body).not_to include("Agreement history") + end + + context "when an admin is viewing" do + let(:admin) { create(:user, :with_person, super_user: true) } + before { sign_in admin } + + it "shows the admin-only agreement history once there are responses" do + scholarship.decline_agreement!("Timing no longer works") + get registration_scholarship_path(registration.slug) + + expect(response.body).to include("Agreement history") + expect(response.body).to include("Admin only") + end + end end describe "POST /registration/:slug/scholarship/agreement" do @@ -579,7 +643,7 @@ expect(response).to redirect_to(registration_scholarship_path(registration.slug)) expect(scholarship.reload.agreement_signed?).to be(true) - expect(scholarship.agreement_signed_at).to be_present + expect(scholarship.latest_agreement_response.responded_at).to be_present end it "does not sign the agreement without an affirmative submission" do @@ -597,6 +661,47 @@ expect(response).to redirect_to(registration_scholarship_path(other.slug)) end end + + describe "POST /registration/:slug/scholarship/decline" do + it "records the decline with the reason and clears any signed state" do + scholarship.update!(agreement_signed: true) + + post registration_scholarship_decline_path(registration.slug), params: { decline_reason: "Timing no longer works" } + + expect(response).to redirect_to(registration_scholarship_path(registration.slug)) + scholarship.reload + expect(scholarship.agreement_declined?).to be(true) + expect(scholarship.latest_agreement_response.reason).to eq("Timing no longer works") + expect(scholarship.agreement_signed?).to be(false) + end + + it "zeroes the scholarship allocation so it stops counting toward the balance" do + expect(registration.reload.remaining_cost).to eq(5_000) + + post registration_scholarship_decline_path(registration.slug), params: { decline_reason: "No thanks" } + + expect(allocation.reload.amount).to eq(0) + expect(registration.reload.remaining_cost).to eq(10_000) + end + + it "is a no-op when already declined (no duplicate response row)" do + scholarship.decline_agreement!("first") + + expect { + post registration_scholarship_decline_path(registration.slug), params: { decline_reason: "second" } + }.not_to change { scholarship.agreement_responses.count } + + expect(response).to redirect_to(registration_scholarship_path(registration.slug)) + end + + it "redirects to the scholarship page when there is no awarded scholarship" do + other = create(:event_registration, event: event, scholarship_requested: true) + + post registration_scholarship_decline_path(other.slug), params: { decline_reason: "n/a" } + + expect(response).to redirect_to(registration_scholarship_path(other.slug)) + end + end end describe "GET /registration/:slug/certificate" do diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 51286deae..7fe9ae576 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -2883,6 +2883,17 @@ def ce_chip_text expect(response.body).to include("#{recipients_event_path(owned_event)}#participant-#{registration.slug}") end + it "badges a declined award on the roster instead of dropping the recipient" do + scholarship = create(:scholarship, recipient: person) + create(:allocation, source: scholarship, allocatable: registration) + scholarship.reload.decline_agreement!("Timing no longer works") + + get roster_event_path(owned_event) + + expect(response.body).to include("fa-graduation-cap") + expect(response.body).to include("Declined") + end + it "does not show a recipients link for registrants without a scholarship" do get roster_event_path(owned_event) @@ -3212,6 +3223,18 @@ def ce_chip_text expect(response.body).to include("It will let me reach more survivors.") end + it "keeps a declined award on the recipient's card, struck through and badged" do + registration = EventRegistration.find_by!(registrant: applicant, event: event) + scholarship = create(:scholarship, recipient: applicant, amount_cents: 1000) + create(:allocation, source: scholarship, allocatable: registration, amount: 1000) + scholarship.reload.decline_agreement!("Timing no longer works") + + get recipients_event_path(event) + + expect(response.body).to include("Declined") + expect(response.body).to match(/line-through[^>]*>\s*]*sack-dollar[^>]*><\/i>\s*\$10\b/) + end + it "shows a recipient city breakdown, grouped by the registration-linked org, in the lazy charts frame" do org = create(:organization, name: "Reach Org") create(:address, addressable: org, city: "Richmond", state: "CA", inactive: false) diff --git a/spec/requests/scholarships_spec.rb b/spec/requests/scholarships_spec.rb index 5ff95b470..be5d896b3 100644 --- a/spec/requests/scholarships_spec.rb +++ b/spec/requests/scholarships_spec.rb @@ -182,6 +182,27 @@ end end + describe "re-offering a declined scholarship" do + before { scholarship.reload.decline_agreement!("Timing no longer works") } + + it "shows the declined banner with a Re-offer button on the edit page" do + get edit_scholarship_path(scholarship) + + expect(response.body).to include("Declined by recipient") + expect(response.body).to match(%r{action="#{Regexp.escape(reoffer_scholarship_path(scholarship))}"}) + end + + it "POST reoffer returns the award to pending and re-funds the allocation" do + expect(allocation.reload.amount).to eq(0) + + post reoffer_scholarship_path(scholarship) + + expect(response).to redirect_to(edit_scholarship_path(scholarship)) + expect(scholarship.reload.agreement_pending?).to be(true) + expect(allocation.reload.amount).to eq(5_000) + end + end + describe "POST /scholarships from the registration Add link" do it "returns to the event registration edit page on create (symmetric with View)" do expect { diff --git a/spec/services/attendees_breakdowns_spec.rb b/spec/services/attendees_breakdowns_spec.rb index ec677e6dc..42016b63b 100644 --- a/spec/services/attendees_breakdowns_spec.rb +++ b/spec/services/attendees_breakdowns_spec.rb @@ -50,6 +50,16 @@ expect(breakdowns.ce_registrant_ids).to eq([ person.id ]) end + it "leaves a declined award out of the recipient counts (matching EventDashboard)" do + scholarship = create(:scholarship, recipient: person, amount_cents: 1_000) + create(:allocation, source: scholarship, allocatable: registration, amount: 1_000) + scholarship.reload.decline_agreement!("Timing no longer works") + + expect(breakdowns.scholarship_recipient_count).to eq(0) + expect(breakdowns.scholarship_registrant_ids).to be_empty + expect(breakdowns.registrant_city_breakdown.rows.sum(&:scholarship_count)).to eq(0) + end + it "classifies program status without a per-org affiliations query" do people = 3.times.map do registrant = create(:person) diff --git a/spec/services/builtin_callout_cards_spec.rb b/spec/services/builtin_callout_cards_spec.rb index edb126887..9a295a4d7 100644 --- a/spec/services/builtin_callout_cards_spec.rb +++ b/spec/services/builtin_callout_cards_spec.rb @@ -235,6 +235,20 @@ def add_scholarship_form(event) expect(scholarship_card.badge_classes).to include("fuchsia") end + it "stops prompting for the agreement once the recipient declines" do + add_scholarship_form(event) + registration.update!(scholarship_requested: true) + scholarship = create(:scholarship, recipient: registration.registrant, amount_cents: 1000) + create(:allocation, source: scholarship, allocatable: registration, amount: 1000) + scholarship.reload.decline_agreement!("Timing no longer works") + + scholarship_card = card(registration.reload, "Scholarship") + expect(scholarship_card.subtitle).to eq("You declined this award") + expect(scholarship_card.badge).to eq("Declined") + expect(scholarship_card.badge_classes).to include("red") + expect(scholarship_card.theme).to eq(DomainTheme.swatch(DomainTheme.color_for(:scholarships))) + end + it "orders the code-fallback cards from payment downward" do # Handouts/FAQ/art supplies are row-driven, so they never appear in this fallback. event.update!(facilitator_training: true, ce_hours_offered: 6, diff --git a/spec/services/event_dashboard_spec.rb b/spec/services/event_dashboard_spec.rb index 2f51485ca..aad183b5f 100644 --- a/spec/services/event_dashboard_spec.rb +++ b/spec/services/event_dashboard_spec.rb @@ -804,6 +804,28 @@ def opt_in(person, text:) end end + context "with a declined scholarship" do + let(:event) { create(:event, cost_cents: 10_000) } + let(:recipient) { create(:person) } + let!(:registration) { create(:event_registration, event: event, registrant: recipient, status: "registered", scholarship_requested: true) } + let!(:scholarship) do + create(:scholarship, recipient: recipient, amount_cents: 10_000).tap do |award| + create(:allocation, source: award, allocatable: registration, amount: 10_000) + award.reload.decline_agreement!("Timing no longer works") + end + end + + it "drops the award from the money figures and recipient counts" do + expect(dashboard.scholarship_total_cents).to eq(0) + expect(dashboard.scholarship_recipient_count).to eq(0) + expect(dashboard.scholarship_registrants).to be_empty + end + + it "still surfaces the award for display, so the roster can badge it declined" do + expect(dashboard.scholarship_by_recipient[recipient.id]).to eq(scholarship) + end + end + context "with a free event" do let(:event) { create(:event, cost_cents: 0) } diff --git a/spec/services/event_registration_readiness_spec.rb b/spec/services/event_registration_readiness_spec.rb index 213ea9515..2491df5bd 100644 --- a/spec/services/event_registration_readiness_spec.rb +++ b/spec/services/event_registration_readiness_spec.rb @@ -85,6 +85,22 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) expect(readiness.event_ready_issues).not_to include("Scholarship tasks incomplete") expect(readiness.event_ready_issues).not_to include("Scholarship not created") end + + it "flags a declined award as the next step, ahead of the payment gap it opened" do + award_scholarship(registration, tasks_completed: false, amount: 1000) + registration.scholarships.first.decline_agreement!("Timing no longer works") + + expect(readiness.event_ready?).to be(false) + expect(readiness.event_ready_issues).to include("Scholarship declined — respond to the decline") + expect(readiness.event_ready_reason).to eq("Award declined") + end + + it "stops flagging incomplete tasks on a declined award" do + award_scholarship(registration, tasks_completed: false, amount: 1000) + registration.scholarships.first.decline_agreement!("Timing no longer works") + + expect(readiness.event_ready_issues).not_to include("Scholarship tasks incomplete") + end end context "continuing education" do