diff --git a/AGENTS.md b/AGENTS.md index 7117b9b6d..5417da04a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| -| `app/models/` | ActiveRecord models | ~80 files | +| `app/models/` | ActiveRecord models | ~81 files | | `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~57 files | | `app/jobs/` | SolidQueue background jobs | 5 files | | `app/models/concerns/` | Shared model modules | 16 concerns | @@ -105,7 +105,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 64353b448..ec35e1075 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -55,8 +55,8 @@ def scholarship 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. + # scholarship's tasks. The Agree button submits agreement=yes, which records an + # "accepted" response via the model. def sign_agreement scholarship = @event_registration.scholarships.first unless scholarship @@ -65,13 +65,33 @@ 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 + # Records the recipient declining the scholarship, from their scholarship page, + # with an optional reason. Stamps the decline (which drops the award from all + # totals); the team sees it on the scholarship. Re-submitting 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 0db5478d2..b532e6ad6 100644 --- a/app/controllers/scholarships_controller.rb +++ b/app/controllers/scholarships_controller.rb @@ -1,5 +1,5 @@ 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 @@ -108,6 +108,19 @@ def toggle_tasks end end + # Re-offer a declined award: back to pending and re-fund the allocation, so the + # recipient can respond again. Explicit admin action (editing the amount alone no + # longer reactivates a decline). + 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 c6992c4d8..d77240607 100644 --- a/app/decorators/grant_decorator.rb +++ b/app/decorators/grant_decorator.rb @@ -41,11 +41,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 d9346ec10..f156e0108 100644 --- a/app/decorators/scholarship_decorator.rb +++ b/app/decorators/scholarship_decorator.rb @@ -56,4 +56,21 @@ def tasks_completed? def agreement_signed? object.agreement_signed? end + + def agreement_declined? + object.agreement_declined? + end + + # A single agreement-status pill shared by every surface that lists a + # scholarship (indexes, event/registration edit, grant show) so the declined + # state is visible everywhere: Declined (red), Signed (fuchsia), Pending (amber). + def agreement_status_label + return "Declined" if object.agreement_declined? + object.agreement_signed? ? "Signed" : "Pending" + end + + def agreement_status_classes + return "bg-red-50 text-red-700 border-red-200" if object.agreement_declined? + object.agreement_signed? ? "bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200" : "bg-amber-50 text-amber-700 border-amber-200" + end end diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 900705e7e..936f8884a 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -190,7 +190,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 } diff --git a/app/models/grant.rb b/app/models/grant.rb index 77f7f6535..4ee003270 100644 --- a/app/models/grant.rb +++ b/app/models/grant.rb @@ -25,7 +25,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 (donation amount exceeds the sum of # scholarships drawn against them). @@ -41,11 +41,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 @@ -96,9 +96,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 a21ed1b4b..182820ab2 100644 --- a/app/models/scholarship.rb +++ b/app/models/scholarship.rb @@ -4,28 +4,44 @@ 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: :destroy + 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 - - after_update :sync_allocation_amount, if: -> { saved_change_to_amount_cents? } + validate :within_grant_budget, if: -> { grant && !agreement_declined? } + + # The allocation carries the award financially: zero while declined, else the + # amount. Re-synced on any amount or status change so every allocation-based + # total (balances, dashboards, grant budgets) stays correct. + after_update :sync_allocation_amount, if: -> { saved_change_to_amount_cents? || saved_change_to_agreement_response_status? } + # Every status transition appends a history row (the audit trail of the + # accept ↔ decline back-and-forth). + after_update :log_agreement_response, if: -> { saved_change_to_agreement_response_status? } 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 scholarships are excluded from every total — the recipient turned the + # award down, so it no longer counts toward amounts, counts, or budgets. + 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 ]) } + # The funding split excludes declined awards — a declined scholarship funds + # nothing, so it counts as neither externally funded nor org-subsidized. + 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 +66,69 @@ 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 state is a single tri-state column (pending → accepted → declined), + # so the states are mutually exclusive by construction. `agreement_signed` + # reads/writes as a virtual boolean so the admin form checkbox and strong + # params keep working (checking it 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 + + # The recipient (or an admin) accepting the award. Idempotent — a repeat accept + # is a no-op, so it doesn't append a duplicate history row. + def accept_agreement!(by: "recipient") + return if agreement_signed? + + assign_agreement_response("accepted", by:) + save! + end + + # The recipient declining, with their reason. Recording it (via after_update) + # zeroes the allocation so the award stops counting in every total and appends + # a history row; the row is kept for history. + def decline_agreement!(reason, by: "recipient") + assign_agreement_response("declined", reason:, by:) + save! + end + + # Admin re-offering a declined award: back to pending (the recipient decides + # again) and the allocation is re-funded to the current amount. Explicit action — + # 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 + + # The event registration this scholarship is allocated against (nil for a + # grant-funded scholarship with no registration). + def event_registration + registration = allocation&.allocatable + registration if registration.is_a?(EventRegistration) + end + + # The event this scholarship was awarded at, via its allocation's registration + # (nil for a grant-funded scholarship with no event registration). + def event + event_registration&.event + end + + # The current agreement response — the source for the responded-at date and + # decline reason (which aren't stored on the scholarship; only the status is). + # 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 +150,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 +178,32 @@ def recipient_must_match_allocation_registrant end end + # Assign the new agreement state in memory (persisted by the caller's save). + # The reason + responder are stashed for the history row the after_update + # callback writes — they live on the response, not on 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..e7837220f --- /dev/null +++ b/app/models/scholarship_agreement_response.rb @@ -0,0 +1,16 @@ +class ScholarshipAgreementResponse < ApplicationRecord + # One row per agreement transition, so the back-and-forth between a recipient + # and the team (accept ↔ decline, and admin re-offers) is a first-class, + # queryable history. The scholarship's agreement_response_status is the + # denormalized cache of the latest row here. + 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..edd3f01dd 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 diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index 145e07d5d..0bff163a2 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -259,7 +259,7 @@ def scholarship_subtitle(awarded, needs_agreement) 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 7cfb89864..d14002ba5 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -1245,6 +1245,7 @@ def bulk_payments def scholarships @scholarships ||= begin scope = Scholarship + .not_declined .joins(:allocation) .where(allocations: { allocatable_type: "EventRegistration", allocatable_id: active_registration_ids }) scope = scope.where(grant_id: funder_grant_ids) if @scholarship_funder 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 88ce06e02..4a98c5113 100644 --- a/app/views/event_registrations/_scholarship.html.erb +++ b/app/views/event_registrations/_scholarship.html.erb @@ -51,7 +51,12 @@ the organizations card's "Connect organization" link. %>
<%# Agreement first, then tasks — the recipient agrees before completing tasks. %> - <% if scholarship.agreement_signed? %> + <% if scholarship.agreement_declined? %> + <%= render "shared/badge", + label: "Agreement declined", + classes: "bg-red-50 text-red-700 border-red-200", + icon: "fa-solid fa-circle-xmark" %> + <% elsif scholarship.agreement_signed? %> <%= render "shared/badge", label: "Agreement signed", classes: "#{DomainTheme.bg_class_for(:scholarships, intensity: 50)} #{DomainTheme.text_class_for(:scholarships, intensity: 700)} #{DomainTheme.border_class_for(:scholarships, intensity: 200)}", diff --git a/app/views/events/_recipient_card.html.erb b/app/views/events/_recipient_card.html.erb index ae63dd75b..4a8b80a3e 100644 --- a/app/views/events/_recipient_card.html.erb +++ b/app/views/events/_recipient_card.html.erb @@ -133,10 +133,13 @@ data-scholarship-status-toggle-target="status">
Scholarship - + "> <%= dollars_from_cents(scholarship.amount_cents) %> + <% if scholarship.agreement_declined? %> + <%= render "shared/badge", label: "Declined", classes: "bg-red-50 text-red-700 border-red-200", icon: "fa-solid fa-circle-xmark text-xs" %> + <% end %> <% if !grouped && scholarship.grant&.funder_name.present? %> <% if allowed_to?(:show?, scholarship.grant) %> <%= link_to grant_path(scholarship.grant), target: "_blank", rel: "noopener", diff --git a/app/views/events/callouts/scholarship.html.erb b/app/views/events/callouts/scholarship.html.erb index ca759940f..fea709a19 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,61 @@ <% end %>
+ <%# What the registrant still owes with this scholarship applied — event cost + minus every allocation (this scholarship, payments, discounts). Only shown + for a paid event; remaining_cost already nets out the scholarship. %> + <% if @event.cost_cents.to_i.positive? && !@scholarship.agreement_declined? %> + <% owed = @event_registration.remaining_cost %> +
+ <% if owed.zero? %> +

With this scholarship applied, your <%= dollars_from_cents(@event.cost_cents) %> registration is fully covered — you'll owe nothing.

+ <% else %> +

With this scholarship applied, you'll owe <%= dollars_from_cents(owed) %> toward the <%= dollars_from_cents(@event.cost_cents) %> registration cost.

+ <% end %> +
+ <% end %> + <%# Scholarship agreement — shown above the details so accepting comes first. A single Agree button signs it; afterwards we confirm with the date. %> + <% latest_response = @scholarship.latest_agreement_response %>
<% if @scholarship.agreement_signed? %>

- 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 disclosure so the reason box only appears when declining — no JS. + When it's open, :has() hides Agree so the decline form stands alone. %> +
+ <%= form_with url: registration_scholarship_agreement_path(@event_registration.slug), method: :post, class: "group-has-[[open]]:hidden" do %> + + <% end %> + +
+ + Decline + + <%= form_with url: registration_scholarship_decline_path(@event_registration.slug), method: :post, class: "mt-3 w-full max-w-md" do %> + + + + <% end %> +
+
<% end %>
@@ -71,6 +115,14 @@ <%= render "scholarships/grant_criteria_tasks", grant: %> <% end %> + + <%# Admin-only agreement history, rendered with admin styling on this + registrant-facing page — registrants never see it. %> + <% if allowed_to?(:edit?, @scholarship) && @scholarship.agreement_responses.exists? %> +
+ <%= render "scholarships/agreement_history", scholarship: @scholarship, admin: true %> +
+ <% end %> <% else %>
diff --git a/app/views/events/onboarding/_row.html.erb b/app/views/events/onboarding/_row.html.erb index 5dc987b21..1a2128fa4 100644 --- a/app/views/events/onboarding/_row.html.erb +++ b/app/views/events/onboarding/_row.html.erb @@ -135,7 +135,9 @@ " data-sort-value="<%= scholarship ? scholarship.amount_cents : -1 %>"> <% if scholarship %> <%= link_to edit_scholarship_path(scholarship, return_to: "onboarding"), class: "hover:underline", data: { turbo_frame: "_top" } do %> - <% if scholarship.amount_cents.to_i.positive? %> + <% if scholarship.agreement_declined? %> + <%= render "shared/badge", label: "Declined", classes: "bg-red-50 text-red-700 border-red-200", icon: "fa-solid fa-circle-xmark text-xs" %> + <% elsif scholarship.amount_cents.to_i.positive? %> <%= dollars_from_cents(scholarship.amount_cents) %> <% else %> <%= render "shared/badge", label: "TBD", classes: "bg-gray-50 text-gray-500 border-gray-200" %> diff --git a/app/views/grants/_scholarships.html.erb b/app/views/grants/_scholarships.html.erb index e49b2a8ee..a8a89da56 100644 --- a/app/views/grants/_scholarships.html.erb +++ b/app/views/grants/_scholarships.html.erb @@ -27,8 +27,12 @@ <%= scholarship.recipient&.full_name || "Unknown recipient" %> + <% if scholarship.agreement_declined? %> + <%= render "shared/badge", label: "Declined", classes: "bg-red-50 text-red-700 border-red-200", icon: "fa-solid fa-circle-xmark text-xs" %> + <% end %> - + <%# Declined awards don't draw down the grant, so the amount is struck through. %> + "> <%= dollars_from_cents(scholarship.amount_cents) %> diff --git a/app/views/scholarships/_agreement_history.html.erb b/app/views/scholarships/_agreement_history.html.erb new file mode 100644 index 000000000..cd87318aa --- /dev/null +++ b/app/views/scholarships/_agreement_history.html.erb @@ -0,0 +1,37 @@ +<%# Collapsible timeline of the recipient/admin accept ↔ decline back-and-forth, + newest first. Built from the scholarship's agreement_responses (the audit log + behind the denormalized status). Native
so no JS is needed. + `admin: true` gives it the sky "admin only" styling for the registrant-facing + callout page (where it renders only for admins). %> +<% admin = local_assigns.fetch(:admin, false) %> +<% responses = scholarship.agreement_responses.to_a %> +<% if responses.any? %> + <% status_classes = { "accepted" => "bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200", "declined" => "bg-red-50 text-red-700 border-red-200", "pending" => "bg-amber-50 text-amber-700 border-amber-200" } %> +
"> + + + Agreement history + <% if admin %> + Admin only + <% end %> + <%= responses.size %> + +
    + <% responses.reverse_each do |response| %> +
  1. + <%= response.status.capitalize %> + <%= response.responded_at.strftime("%B %-d, %Y at %-l:%M %p") %> + <% if response.responder.present? %> + · by <%= response.responder %> + <% end %> + <% if response.amount_cents.present? %> + · <%= dollars_from_cents(response.amount_cents) %> + <% end %> + <% if response.reason.present? %> +

    “<%= response.reason %>”

    + <% end %> +
  2. + <% end %> +
+
+<% end %> diff --git a/app/views/scholarships/_form.html.erb b/app/views/scholarships/_form.html.erb index 6d47e3636..133340d50 100644 --- a/app/views/scholarships/_form.html.erb +++ b/app/views/scholarships/_form.html.erb @@ -84,17 +84,19 @@ <%# Scholarship agreement, then tasks — the recipient agrees first, then completes the tasks. Both shown in either layout. %> -
-
-

Scholarship agreement

-

Signed agreement on file from the recipient

+
+
+
+

Scholarship agreement

+

Signed agreement on file from the recipient

+
+
-
diff --git a/app/views/scholarships/_recipient_row.html.erb b/app/views/scholarships/_recipient_row.html.erb index 8fbf9aed1..25abf7fba 100644 --- a/app/views/scholarships/_recipient_row.html.erb +++ b/app/views/scholarships/_recipient_row.html.erb @@ -5,6 +5,9 @@ <%= link_to scholarship.recipient_name, edit_scholarship_path(scholarship), class: "font-medium text-blue-700 hover:text-blue-900 hover:underline after:absolute after:inset-0 after:content-['']" %> + <% if scholarship.agreement_declined? %> + <%= render "shared/badge", label: "Declined", classes: "bg-red-50 text-red-700 border-red-200", icon: "fa-solid fa-circle-xmark text-xs" %> + <% end %> <%= scholarship.program_name %> <%= scholarship.program_location %> @@ -18,7 +21,8 @@ <% end %> <%= scholarship.training_label %> - <%= scholarship.amount %> + <%# Declined awards don't count toward totals, so the amount is struck through. %> + "><%= scholarship.amount %> <% if scholarship.tasks_completed? %> <%= render "shared/badge", diff --git a/app/views/scholarships/edit.html.erb b/app/views/scholarships/edit.html.erb index b737edcab..df7aa7486 100644 --- a/app/views/scholarships/edit.html.erb +++ b/app/views/scholarships/edit.html.erb @@ -59,6 +59,31 @@ event: (@allocatable.event if @allocatable.respond_to?(:event)), person: @scholarship.recipient %> + <%# The recipient declined. This lives outside the edit form (a button_to renders + its own form, which can't nest) and is the one explicit path back to active: + re-offering sets the award to pending and re-funds it. %> + <% if @scholarship.agreement_declined? %> + <% declined_response = @scholarship.latest_agreement_response %> +
+
+ +
+

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.

+
+
+ <%= button_to reoffer_scholarship_path(@scholarship, return_to: params[:return_to].presence, participant: params[:participant].presence), + class: "inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-700", + data: { turbo_confirm: "Re-offer this scholarship? It returns to pending and the award is re-funded until the recipient responds." } do %> + Re-offer + <% end %> +
+ <% end %> + <%# turbo: false matches the other cocoon/nested-attributes admin forms (registration, workshops). A plain full-page submit avoids the Turbo + nested-fields double/stale resubmission that surfaced as a save committing yet the request returning 422. %> @@ -72,5 +97,7 @@
<% end %> + <%= render "agreement_history", scholarship: @scholarship %> + <%= render "shared/audit_info", resource: @scholarship %>
diff --git a/app/views/scholarships/show.html.erb b/app/views/scholarships/show.html.erb index 6f8939c6b..f914bd3af 100644 --- a/app/views/scholarships/show.html.erb +++ b/app/views/scholarships/show.html.erb @@ -18,15 +18,30 @@
Tasks Completed
<%= @scholarship.tasks_completed? ? "Yes" : "No" %>
+ <% latest_response = @scholarship.latest_agreement_response %>
Agreement signed
<%= @scholarship.agreement_signed? ? "Yes" : "No" %> - <% if @scholarship.agreement_signed_at %> - · <%= @scholarship.agreement_signed_at.strftime("%B %d, %Y") %> + <% if @scholarship.agreement_signed? && latest_response&.responded_at %> + · <%= latest_response.responded_at.strftime("%B %d, %Y") %> <% end %>
+ <% if @scholarship.agreement_declined? %> +
+
Agreement declined
+
+ Declined + <% if latest_response&.responded_at %> + · <%= latest_response.responded_at.strftime("%B %d, %Y") %> + <% end %> + <% if latest_response&.reason.present? %> +

“<%= latest_response.reason %>”

+ <% end %> +
+
+ <% end %> <% if @scholarship.grant.present? %>
Grant
diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 785b764f1..c45085297 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -68,29 +68,6 @@ ], "note": "Admin-only reminder confirmation. Same as preview_reminder: the raw value is the server-rendered email HTML; the embedded custom message is sanitized via reminder_message_html (SafeListSanitizer) and the custom subject is shown escaped, separately, so no unsanitized user input reaches the page." }, - { - "warning_type": "Redirect", - "warning_code": 18, - "fingerprint": "6b0a218f30eb40af7b6cf4ec15de35003eb42e1cea35ecd6acb69139643a338b", - "check_name": "Redirect", - "message": "Possible unprotected redirect", - "file": "app/controllers/events/callouts_controller.rb", - "line": 257, - "link": "https://brakemanscanner.org/docs/warning_types/redirect/", - "code": "redirect_to(EventRegistration.find_by!(:slug => params[:slug]).registrant.payment_processor.checkout(:mode => \"payment\", :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :payment_intent_data => ({ :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :description => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :line_items => ([{ :price_data => ({ :currency => \"usd\", :product_data => ({ :name => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :unit_amount => ce_registration.remaining_cost }), :quantity => 1 }]), :success_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"success\"), :cancel_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"cancelled\")).url, :allow_other_host => true, :status => :see_other)", - "render_path": null, - "location": { - "type": "method", - "class": "Events::CalloutsController", - "method": "redirect_to_ce_stripe_checkout" - }, - "user_input": "EventRegistration.find_by!(:slug => params[:slug]).registrant.payment_processor.checkout(:mode => \"payment\", :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :payment_intent_data => ({ :metadata => ({ :ce_registration_id => ce_registration.id, :event_registration_id => EventRegistration.find_by!(:slug => params[:slug]).id, :event_id => EventRegistration.find_by!(:slug => params[:slug]).event.id }), :description => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :line_items => ([{ :price_data => ({ :currency => \"usd\", :product_data => ({ :name => (\"CE Hours: #{EventRegistration.find_by!(:slug => params[:slug]).event.title}\") }), :unit_amount => ce_registration.remaining_cost }), :quantity => 1 }]), :success_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"success\"), :cancel_url => registration_ce_url(EventRegistration.find_by!(:slug => params[:slug]).slug, :checkout => \"cancelled\")).url", - "confidence": "Weak", - "cwe_id": [ - 601 - ], - "note": "known redirect for stripe" - }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, diff --git a/config/routes.rb b/config/routes.rb index 3764344ea..93ae81401 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -82,6 +82,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 @@ -140,7 +141,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: [ :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..9b8c514fa --- /dev/null +++ b/db/migrate/20260813121725_create_scholarship_agreement_responses.rb @@ -0,0 +1,21 @@ +class CreateScholarshipAgreementResponses < ActiveRecord::Migration[8.1] + # Append-only log of each agreement transition (accept ↔ decline ↔ re-offer). + # The scholarship's agreement_response_status (added next) is the denormalized + # cache of the latest row here; responded_at + reason live here, not on the + # scholarship. + 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..245925241 --- /dev/null +++ b/db/migrate/20260813121726_replace_scholarship_agreement_signed_at_with_response_status.rb @@ -0,0 +1,15 @@ +class ReplaceScholarshipAgreementSignedAtWithResponseStatus < ActiveRecord::Migration[8.1] + # Replace the single agreement_signed_at timestamp with a tri-state status + # (pending/accepted/declined). No data backfill: there are no signed agreements + # in production, so every existing row correctly defaults to "pending". Going + # forward the date + reason live on the response rows, not on the scholarship. + 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 d86682125..bcf6230a9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1243,8 +1243,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" @@ -1862,6 +1874,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/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 17c492fd0..437d42a70 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -367,11 +367,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") diff --git a/spec/models/grant_spec.rb b/spec/models/grant_spec.rb index a07cfc691..9c9a02d1a 100644 --- a/spec/models/grant_spec.rb +++ b/spec/models/grant_spec.rb @@ -130,6 +130,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..599af03ec 100644 --- a/spec/models/scholarship_spec.rb +++ b/spec/models/scholarship_spec.rb @@ -108,37 +108,154 @@ 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 + end + + describe "declining" do + it "#decline_agreement! records the status, time, and reason" do + scholarship = create(:scholarship) + + scholarship.decline_agreement!("Timing no longer works") + + 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 "preserves the original time when re-saved while still signed" do + 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 + + describe "#event" do + it "returns the event the scholarship was awarded at via its 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) + + expect(scholarship.reload.event).to eq(event) + end + + it "returns nil when the scholarship has no event registration" do + expect(create(:scholarship).event).to be_nil end end diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index 5767af422..f99d13526 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -568,6 +568,49 @@ expect(response.body).to include("Agreement signed") expect(response.body).not_to include("Pending agreement") end + + it "shows the amount still owed after this scholarship (event cost minus allocations)" do + get registration_scholarship_path(registration.slug) + + # $100 event cost − $50 scholarship allocation = $50 still owed. + expect(response.body).to include("you'll owe") + expect(response.body).to include("$50") + 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 @@ -578,7 +621,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 @@ -596,6 +639,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/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 {