From a70141fffc3093034f633a167be02ed040549db8 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 12 Aug 2026 12:10:44 -0400 Subject: [PATCH 01/10] Add scholarship decline flow with admin email and totals exclusion Recipients can decline a scholarship agreement with a reason from their scholarship page. Declining emails the admin team an FYI, records the reason, zeroes the allocation so the award drops out of every total, and shows a Declined badge everywhere scholarships appear. Editing the award amount re-offers it and clears the decline. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/events/callouts_controller.rb | 33 +++++++- app/decorators/grant_decorator.rb | 4 +- app/decorators/scholarship_decorator.rb | 17 ++++ app/jobs/notification_mailer_job.rb | 3 +- app/mailers/notification_mailer.rb | 12 +++ app/models/grant.rb | 12 +-- app/models/notification.rb | 4 + app/models/scholarship.rb | 43 +++++++++- app/presenters/scholarships_grouping.rb | 5 +- app/services/builtin_callout_cards.rb | 2 +- app/services/event_dashboard.rb | 1 + app/services/event_revenue_figures.rb | 1 + app/services/event_scholarship_figures.rb | 1 + .../event_registrations/_scholarship.html.erb | 7 +- app/views/events/_recipient_card.html.erb | 5 +- .../events/callouts/scholarship.html.erb | 56 +++++++++++-- app/views/events/onboarding/_row.html.erb | 4 +- app/views/grants/_scholarships.html.erb | 6 +- ...cholarship_agreement_declined_fyi.html.erb | 47 +++++++++++ ...cholarship_agreement_declined_fyi.text.erb | 23 ++++++ app/views/scholarships/_form.html.erb | 35 ++++++--- .../scholarships/_recipient_row.html.erb | 6 +- app/views/scholarships/show.html.erb | 12 +++ config/routes.rb | 1 + ...4_add_agreement_decline_to_scholarships.rb | 11 +++ db/schema.rb | 6 +- spec/mailers/notification_mailer_spec.rb | 27 +++++++ spec/models/grant_spec.rb | 17 ++++ spec/models/scholarship_spec.rb | 78 +++++++++++++++++++ spec/requests/events/callouts_spec.rb | 75 ++++++++++++++++++ .../previews/notification_mailer_preview.rb | 18 +++++ 31 files changed, 530 insertions(+), 42 deletions(-) create mode 100644 app/views/notification_mailer/scholarship_agreement_declined_fyi.html.erb create mode 100644 app/views/notification_mailer/scholarship_agreement_declined_fyi.text.erb create mode 100644 db/migrate/20260812155344_add_agreement_decline_to_scholarships.rb diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 64353b4487..94900e1996 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -65,13 +65,44 @@ def sign_agreement end if params[:agreement] == "yes" - scholarship.update!(agreement_signed: true) unless scholarship.agreement_signed? + # Agreeing clears any prior decline — the two states are mutually exclusive. + scholarship.update!(agreement_signed: true, agreement_declined_at: nil, agreement_declined_reason: nil) unless scholarship.agreement_signed? 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 and emails the admin team an FYI + # so they can follow up. Only the first decline emails — 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 + + reason = params[:decline_reason].to_s.strip + scholarship.decline_agreement!(reason) + + NotificationServices::CreateNotification.call( + noticeable: scholarship, + kind: :scholarship_agreement_declined_fyi, + recipient_role: :admin, + recipient_email: ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org"), + notification_type: 0, + custom_message: reason.presence + ) + + redirect_to registration_scholarship_path(@event_registration.slug), notice: "Thanks for letting us know — we've told the team and they'll 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/decorators/grant_decorator.rb b/app/decorators/grant_decorator.rb index c6992c4d89..d772406077 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 d9346ec10d..f156e01084 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/jobs/notification_mailer_job.rb b/app/jobs/notification_mailer_job.rb index 298afb5c2d..1b739a6dcd 100644 --- a/app/jobs/notification_mailer_job.rb +++ b/app/jobs/notification_mailer_job.rb @@ -17,7 +17,8 @@ def perform(notification_id, persist_delivered_email: true) "event_registration_cancelled_fyi" => ->(n) { NotificationMailer.event_registration_cancelled_fyi(n) }, "event_registration_reminder" => ->(n) { EventMailer.event_registration_reminder(n.noticeable, custom_message: n.custom_message, custom_subject: n.custom_subject) }, "bulk_payment_confirmation" => ->(n) { EventMailer.bulk_payment_confirmation(n.noticeable) }, - "bulk_payment_confirmation_fyi" => ->(n) { NotificationMailer.bulk_payment_confirmation_fyi(n) } + "bulk_payment_confirmation_fyi" => ->(n) { NotificationMailer.bulk_payment_confirmation_fyi(n) }, + "scholarship_agreement_declined_fyi" => ->(n) { NotificationMailer.scholarship_agreement_declined_fyi(n) } } mailer = mailer_map[notification.kind]&.call(notification) diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 4a50c43b19..0a2bdf3a72 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -146,6 +146,18 @@ def workshop_log_submitted_fyi(notification) ) end + def scholarship_agreement_declined_fyi(notification) + @scholarship = notification.noticeable + @person = @scholarship.recipient + @event = @scholarship.event&.decorate + @reason = notification.custom_message + @notification_type = "Scholarship declined" + + mail( + subject: "#{FYI_PREFIX} Scholarship declined by #{@person&.full_name}" + ) + end + private def extract_attachments(noticeable) diff --git a/app/models/grant.rb b/app/models/grant.rb index 77f7f65352..4d641c2114 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_declined_at IS NULL), 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/notification.rb b/app/models/notification.rb index 1eae8911d6..2a8fe725c4 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -36,6 +36,8 @@ class Notification < ApplicationRecord workshop_log_submitted workshop_log_submitted_fyi + scholarship_agreement_declined_fyi + manual_log ].freeze @@ -62,6 +64,7 @@ class Notification < ApplicationRecord FormSubmission Person Report + Scholarship StoryIdea User WorkshopLog @@ -80,6 +83,7 @@ class Notification < ApplicationRecord [ "Admin FYI: idea submitted", "submission by" ], [ "Admin FYI: password reset", "[FYI] New password reset" ], [ "Admin FYI: workshop log submission", "New WorkshopLog submission" ], + [ "Admin FYI: scholarship declined", "Scholarship declined" ], [ "Admin FYI: contact form submission", "contact form submission" ], [ "Contact: form confirmation", "We received your message" ], [ "Event registration cancelled", "Event registration cancelled" ], diff --git a/app/models/scholarship.rb b/app/models/scholarship.rb index a21ed1b4b9..b26ea4a8b5 100644 --- a/app/models/scholarship.rb +++ b/app/models/scholarship.rb @@ -11,21 +11,31 @@ class Scholarship < ApplicationRecord validates :amount_cents, numericality: { greater_than_or_equal_to: 0 } 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? } + # Changing the award amount re-offers the scholarship, so a prior decline is + # cleared — the recipient decides afresh on the new amount (and sync_allocation_amount + # above re-funds the allocation the decline had zeroed). + after_update :reset_decline_on_amount_change, if: -> { saved_change_to_amount_cents? && agreement_declined? } 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_declined, -> { where.not(agreement_declined_at: nil) } + # 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(agreement_declined_at: nil) } # 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. @@ -62,6 +72,26 @@ def agreement_signed=(value) self.agreement_signed_at = signed ? (agreement_signed_at || Time.current) : nil end + def agreement_declined? = agreement_declined_at.present? + + # The recipient declining stamps the time + their reason and clears any signed + # state (signed and declined are mutually exclusive), and zeroes the allocation + # so the award stops counting in every allocation-based total (registration + # balances, dashboards, grant budgets). The row is kept for history. + def decline_agreement!(reason) + transaction do + update!(agreement_declined_at: Time.current, agreement_declined_reason: reason.presence, agreement_signed_at: nil) + allocation&.update!(amount: 0) + end + 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 + registration = allocation&.allocatable + registration.event if registration.respond_to?(:event) + end + def amount_dollars amount_cents.to_d / 100 if amount_cents end @@ -81,7 +111,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 @@ -115,6 +145,11 @@ def sync_allocation_amount allocation.update!(amount: amount_cents.to_i) end + # update_columns so this second write doesn't re-enter the after_update chain. + def reset_decline_on_amount_change + update_columns(agreement_declined_at: nil, agreement_declined_reason: nil) + end + # When a scholarship is awarded against an event registration, the registration # should reflect that a scholarship was requested. We never clear this on delete: # removing an awarded scholarship doesn't undo the fact that one was requested. diff --git a/app/presenters/scholarships_grouping.rb b/app/presenters/scholarships_grouping.rb index 3bba332578..edd3f01dde 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 145e07d5dc..0bff163a2b 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 7cfb89864f..d14002ba5f 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 59743e38ed..f936952045 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 c4572d73ae..400c31bf88 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 88ce06e026..4a98c51138 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 ae63dd75b1..4a8b80a3e1 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 ca759940f7..ade63eaef4 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,6 +46,20 @@ <% 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. %>
@@ -50,13 +68,37 @@ Agreement signed<% if @scholarship.agreement_signed_at %> · <%= @scholarship.agreement_signed_at.strftime("%B %-d, %Y") %><% end %>

+ <% elsif @scholarship.agreement_declined? %> +

+ + You declined this scholarship<% if @scholarship.agreement_declined_at %> · <%= @scholarship.agreement_declined_at.strftime("%B %-d, %Y") %><% end %> +

+

We've let the team 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 %> +
+ <%= form_with url: registration_scholarship_agreement_path(@event_registration.slug), method: :post do %> + + <% end %> + + <%# Native disclosure so the reason box only appears when declining — no JS needed. %> +
+ + Decline + + <%= form_with url: registration_scholarship_decline_path(@event_registration.slug), method: :post, class: "mt-3 w-full max-w-md" do %> + + + + <% end %> +
+
<% end %>
diff --git a/app/views/events/onboarding/_row.html.erb b/app/views/events/onboarding/_row.html.erb index 5dc987b216..1a2128fa45 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 e49b2a8eeb..a8a89da56f 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/notification_mailer/scholarship_agreement_declined_fyi.html.erb b/app/views/notification_mailer/scholarship_agreement_declined_fyi.html.erb new file mode 100644 index 0000000000..256a1aa4f7 --- /dev/null +++ b/app/views/notification_mailer/scholarship_agreement_declined_fyi.html.erb @@ -0,0 +1,47 @@ +

+ Scholarship declined +

+ +

+ <%= @person&.full_name %> + <% if @person&.preferred_email.present? %>(<%= @person.preferred_email %>)<% end %> +

+ +

+ Declined on + <%= Time.current + .in_time_zone("Pacific Time (US & Canada)") + .strftime("%B %-d, %Y at %-l:%M %p %Z") %> +

+ +
+

+ <%= dollars_from_cents(@scholarship.amount_cents) %> scholarship +

+ + <% if @event.present? %> +

+ for <%= @event.title %> +

+ <% end %> +
+ +
+

+ Reason given +

+ <% if @reason.present? %> +

<%= @reason %>

+ <% else %> +

No reason was provided.

+ <% end %> +
+ +

+ + View scholarship + +

diff --git a/app/views/notification_mailer/scholarship_agreement_declined_fyi.text.erb b/app/views/notification_mailer/scholarship_agreement_declined_fyi.text.erb new file mode 100644 index 0000000000..6277af54aa --- /dev/null +++ b/app/views/notification_mailer/scholarship_agreement_declined_fyi.text.erb @@ -0,0 +1,23 @@ +Scholarship declined +==================== + +<%= @person&.full_name %><% if @person&.preferred_email.present? %> (<%= @person.preferred_email %>)<% end %> + +Declined on +<%= Time.current + .in_time_zone("Pacific Time (US & Canada)") + .strftime("%B %-d, %Y at %-l:%M %p %Z") %> + +------------------------------------------------------------ + +<%= dollars_from_cents(@scholarship.amount_cents) %> scholarship<% if @event.present? %> for <%= @event.title %><% end %> + +------------------------------------------------------------ + +Reason given +<%= @reason.presence || "No reason was provided." %> + +------------------------------------------------------------ + +View scholarship: +<%= edit_scholarship_url(@scholarship) %> diff --git a/app/views/scholarships/_form.html.erb b/app/views/scholarships/_form.html.erb index 6d47e36363..6abd638ab1 100644 --- a/app/views/scholarships/_form.html.erb +++ b/app/views/scholarships/_form.html.erb @@ -84,17 +84,32 @@ <%# 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

+
+
- + <%# The recipient declined from their scholarship page. Shown here so admins + see it at a glance; editing the amount re-offers the award and clears this. %> + <% if f.object.agreement_declined? %> +
+ +
+

Declined by recipient<% if f.object.agreement_declined_at %> · <%= f.object.agreement_declined_at.strftime("%B %-d, %Y") %><% end %>

+ <% if f.object.agreement_declined_reason.present? %> +

“<%= f.object.agreement_declined_reason %>”

+ <% end %> +
+
+ <% end %>
diff --git a/app/views/scholarships/_recipient_row.html.erb b/app/views/scholarships/_recipient_row.html.erb index 8fbf9aed13..25abf7fbac 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/show.html.erb b/app/views/scholarships/show.html.erb index 6f8939c6b5..4ca62d1273 100644 --- a/app/views/scholarships/show.html.erb +++ b/app/views/scholarships/show.html.erb @@ -27,6 +27,18 @@ <% end %>
+ <% if @scholarship.agreement_declined? %> +
+
Agreement declined
+
+ Declined + · <%= @scholarship.agreement_declined_at.strftime("%B %d, %Y") %> + <% if @scholarship.agreement_declined_reason.present? %> +

“<%= @scholarship.agreement_declined_reason %>”

+ <% end %> +
+
+ <% end %> <% if @scholarship.grant.present? %>
Grant
diff --git a/config/routes.rb b/config/routes.rb index 3764344ea3..ff3fac1c56 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 diff --git a/db/migrate/20260812155344_add_agreement_decline_to_scholarships.rb b/db/migrate/20260812155344_add_agreement_decline_to_scholarships.rb new file mode 100644 index 0000000000..d642d4406d --- /dev/null +++ b/db/migrate/20260812155344_add_agreement_decline_to_scholarships.rb @@ -0,0 +1,11 @@ +class AddAgreementDeclineToScholarships < ActiveRecord::Migration[8.1] + def up + add_column :scholarships, :agreement_declined_at, :datetime + add_column :scholarships, :agreement_declined_reason, :text + end + + def down + remove_column :scholarships, :agreement_declined_reason, if_exists: true + remove_column :scholarships, :agreement_declined_at, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index d866821256..c04b7275d0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_13_143231) do +ActiveRecord::Schema[8.1].define(version: 2026_08_12_155344) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -783,13 +783,11 @@ end create_table "notifications", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.boolean "bulk", default: false, null: false t.string "channel", default: "autoemail", null: false t.datetime "created_at", precision: nil, null: false t.text "custom_message" t.string "custom_subject" t.datetime "delivered_at" - t.string "direction", default: "outgoing", null: false t.text "email_body_html", size: :medium t.text "email_body_text", size: :medium t.text "email_subject", size: :medium @@ -1244,6 +1242,8 @@ end create_table "scholarships", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "agreement_declined_at" + t.text "agreement_declined_reason" t.datetime "agreement_signed_at" t.integer "amount_cents", default: 0, null: false t.datetime "created_at", null: false diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index 0f34837806..bf42478ee7 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -1,6 +1,33 @@ require "rails_helper" RSpec.describe NotificationMailer, type: :mailer do + describe "#scholarship_agreement_declined_fyi" do + let(:event) { create(:event, cost_cents: 10_000, title: "Facilitator Training") } + let(:registration) { create(:event_registration, event:) } + let(:person) { registration.registrant } + let(:scholarship) { create(:scholarship, recipient: person, amount_cents: 5_000) } + let!(:allocation) { create(:allocation, source: scholarship, allocatable: registration, amount: 5_000) } + let(:notification) do + # Fresh load so notification.noticeable resolves its allocation from the DB + # (as the mailer job does), not a stale in-memory instance. + create(:notification, kind: "scholarship_agreement_declined_fyi", noticeable: Scholarship.find(scholarship.id), custom_message: "Timing no longer works") + end + + it "renders without raising" do + expect { described_class.scholarship_agreement_declined_fyi(notification).deliver_now }.not_to raise_error + end + + it "names the recipient in the subject" do + expect(described_class.scholarship_agreement_declined_fyi(notification).subject).to include("Scholarship declined by #{person.full_name}") + end + + it "includes the reason and the event in the body" do + body = described_class.scholarship_agreement_declined_fyi(notification).body.encoded + expect(body).to include("Timing no longer works") + expect(body).to include("Facilitator Training") + end + end + describe "#bulk_payment_confirmation_fyi" do let(:event) { create(:event) } let(:form) do diff --git a/spec/models/grant_spec.rb b/spec/models/grant_spec.rb index a07cfc691f..9c9a02d1a5 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_spec.rb b/spec/models/scholarship_spec.rb index a6486cff45..04f957b75e 100644 --- a/spec/models/scholarship_spec.rb +++ b/spec/models/scholarship_spec.rb @@ -142,6 +142,84 @@ end end + describe "agreement_declined (backed by agreement_declined_at)" do + it "infers the flag from the timestamp" do + scholarship = create(:scholarship) + expect(scholarship.agreement_declined?).to be(false) + + scholarship.update!(agreement_declined_at: Time.current) + expect(scholarship.agreement_declined?).to be(true) + end + + it "#decline_agreement! stamps the time and stores the reason" do + scholarship = create(:scholarship) + + scholarship.decline_agreement!("Timing no longer works") + + expect(scholarship.agreement_declined?).to be(true) + expect(scholarship.agreement_declined_at).to be_present + expect(scholarship.agreement_declined_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) + + 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.agreement_declined_reason).to be_nil + end + + it "clears the decline when the award amount is changed (re-offer)" 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") + expect(scholarship.agreement_declined?).to be(true) + + scholarship.update!(amount_cents: 6_000) + + expect(scholarship.reload.agreement_declined?).to be(false) + expect(scholarship.agreement_declined_reason).to be_nil + # sync re-funds the allocation the decline had zeroed. + expect(scholarship.allocation.reload.amount).to eq(6_000) + 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 + 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 + describe "report filter scopes" do let(:event) { create(:event, cost_cents: 50_000) } let(:funder) { create(:organization, name: "Community Trust") } diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index 5767af422e..e29fbf5323 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -568,6 +568,29 @@ 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 end describe "POST /registration/:slug/scholarship/agreement" do @@ -596,6 +619,58 @@ 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.agreement_declined_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 "emails the admin team an FYI with the reason" do + expect { + post registration_scholarship_decline_path(registration.slug), params: { decline_reason: "Moving away" } + }.to change { Notification.where(kind: "scholarship_agreement_declined_fyi").count }.by(1) + + notification = Notification.where(kind: "scholarship_agreement_declined_fyi").last + expect(notification.recipient_email).to eq(ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org")) + expect(notification.custom_message).to eq("Moving away") + expect(notification.noticeable).to eq(scholarship) + end + + it "does not email again when already declined" do + scholarship.decline_agreement!("first") + + expect { + post registration_scholarship_decline_path(registration.slug), params: { decline_reason: "second" } + }.not_to change { Notification.where(kind: "scholarship_agreement_declined_fyi").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/test/mailers/previews/notification_mailer_preview.rb b/test/mailers/previews/notification_mailer_preview.rb index e10eecfed6..a0a57edb3e 100644 --- a/test/mailers/previews/notification_mailer_preview.rb +++ b/test/mailers/previews/notification_mailer_preview.rb @@ -135,6 +135,24 @@ def workshop_log_submitted_fyi NotificationMailer.workshop_log_submitted_fyi(notification) end + def scholarship_agreement_declined_fyi + scholarship = Scholarship.where.not(agreement_declined_at: nil).order(id: :desc).first || + Scholarship.order(id: :desc).first || + raise("Need a Scholarship to preview") + + notification = find_valid_notification("scholarship_agreement_declined_fyi") || + Notification.create!( + noticeable: scholarship, + notification_type: 0, + kind: "scholarship_agreement_declined_fyi", + recipient_role: "admin", + recipient_email: ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org"), + custom_message: scholarship.agreement_declined_reason.presence || "The timing no longer works for me this year." + ) + + NotificationMailer.scholarship_agreement_declined_fyi(notification) + end + private def find_valid_notification(kind) From 5a5aa86b6635ce466646bda39ae7940205687038 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 12 Aug 2026 13:00:46 -0400 Subject: [PATCH 02/10] Confirm before an amount change clears a scholarship decline Editing the award amount re-offers the scholarship and discards the recipient's recorded decline; warn the admin before that happens. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 3 +- app/frontend/javascript/controllers/index.js | 3 + .../scholarship_decline_guard_controller.js | 46 ++++++++++++++++ app/views/scholarships/_form.html.erb | 4 +- app/views/scholarships/edit.html.erb | 2 +- .../scholarship_decline_amount_guard_spec.rb | 55 +++++++++++++++++++ 6 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 app/frontend/javascript/controllers/scholarship_decline_guard_controller.js create mode 100644 spec/system/scholarship_decline_amount_guard_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 7117b9b6d8..526a5cbc6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (77) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (78) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | @@ -361,6 +361,7 @@ end - `remote_select` — AJAX-powered select dropdown - `reveal_section` — Expand a collapsible section and scroll to it when loaded via matching URL hash - `rhino_source` — Rich text editor integration +- `scholarship_decline_guard` — Confirm before an amount change clears a declined scholarship's decline (re-offer) on the edit form - `scholarship_preview` — Live-preview the scholarship's allocated amount as the edit form changes - `scroll_to_top` — Scrolls the window to the top on connect (used by the duplicate-person warning so it comes into view on create) - `searchable_checkbox` — TomSelect checkbox-style multi-select diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 64be221b9e..d31d22e7c5 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -147,6 +147,9 @@ application.register("reveal-section", RevealSectionController) import RhinoSourceController from "./rhino_source_controller" application.register("rhino-source", RhinoSourceController) +import ScholarshipDeclineGuardController from "./scholarship_decline_guard_controller" +application.register("scholarship-decline-guard", ScholarshipDeclineGuardController) + import ScholarshipPreviewController from "./scholarship_preview_controller" application.register("scholarship-preview", ScholarshipPreviewController) diff --git a/app/frontend/javascript/controllers/scholarship_decline_guard_controller.js b/app/frontend/javascript/controllers/scholarship_decline_guard_controller.js new file mode 100644 index 0000000000..9b1741cdd0 --- /dev/null +++ b/app/frontend/javascript/controllers/scholarship_decline_guard_controller.js @@ -0,0 +1,46 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="scholarship-decline-guard" +// +// Warns before saving when an admin changes the award amount on a scholarship +// the recipient has DECLINED. Editing the amount re-offers the award, which +// clears the recorded decline (its date and reason) and re-funds the zeroed +// allocation — so the admin confirms before that decline data is discarded. +// +// Attach to the
. Only guards when the declined value is true; snapshots +// the amount input(s) on connect and compares them on submit. There are two +// amount inputs in the form (one per layout), only one of which renders, so it +// tracks all of them. +export default class extends Controller { + static values = { declined: Boolean }; + static targets = ["amount"]; + + connect() { + this.originalAmounts = this.amountTargets.map((input) => input.value); + this.handleSubmit = (event) => this.guardSubmit(event); + // Capture phase so this runs before other submit listeners (e.g. submit-once). + this.element.addEventListener("submit", this.handleSubmit, true); + } + + disconnect() { + this.element.removeEventListener("submit", this.handleSubmit, true); + } + + guardSubmit(event) { + if (!this.declinedValue || !this.amountChanged()) return; + + const message = + "This recipient declined the scholarship. Changing the amount re-offers " + + "the award and will clear their decline — the recorded date and reason — " + + "and re-fund the allocation.\n\nAre you sure you want to save this change?"; + + if (!window.confirm(message)) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + } + + amountChanged() { + return this.amountTargets.some((input, i) => input.value !== this.originalAmounts[i]); + } +} diff --git a/app/views/scholarships/_form.html.erb b/app/views/scholarships/_form.html.erb index 6abd638ab1..e1ff4a903e 100644 --- a/app/views/scholarships/_form.html.erb +++ b/app/views/scholarships/_form.html.erb @@ -37,7 +37,7 @@ <%= f.label :amount_dollars, "Scholarship amount", class: "block text-xs font-medium uppercase tracking-wide text-gray-400" %>
$ - <%= f.number_field :amount_dollars, step: 0.01, min: 0, autocomplete: "off", class: "w-full rounded-lg border border-gray-300 bg-white pl-7 pr-3 py-2 text-gray-800 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none", data: { "scholarship-preview-target": "amount", action: "input->scholarship-preview#update", lpignore: "true", "1p-ignore": "true", bwignore: "true", "form-type": "other" } %> + <%= f.number_field :amount_dollars, step: 0.01, min: 0, autocomplete: "off", class: "w-full rounded-lg border border-gray-300 bg-white pl-7 pr-3 py-2 text-gray-800 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none", data: { "scholarship-preview-target": "amount", "scholarship-decline-guard-target": "amount", action: "input->scholarship-preview#update", lpignore: "true", "1p-ignore": "true", bwignore: "true", "form-type": "other" } %>
diff --git a/app/views/scholarships/edit.html.erb b/app/views/scholarships/edit.html.erb index b737edcab4..517329d84f 100644 --- a/app/views/scholarships/edit.html.erb +++ b/app/views/scholarships/edit.html.erb @@ -62,7 +62,7 @@ <%# 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. %> - <%= simple_form_for @scholarship, url: scholarship_path(@scholarship, return_to: params[:return_to].presence, participant: params[:participant].presence), html: { class: "space-y-6" }, data: { turbo: false, controller: "submit-once", "submit-once-submitting-text-value": "Saving…" } do |f| %> + <%= simple_form_for @scholarship, url: scholarship_path(@scholarship, return_to: params[:return_to].presence, participant: params[:participant].presence), html: { class: "space-y-6" }, data: { turbo: false, controller: "submit-once scholarship-decline-guard", "submit-once-submitting-text-value": "Saving…", "scholarship-decline-guard-declined-value": @scholarship.agreement_declined? } do |f| %> <%= render "form", f: f %> <% end %> diff --git a/spec/system/scholarship_decline_amount_guard_spec.rb b/spec/system/scholarship_decline_amount_guard_spec.rb new file mode 100644 index 0000000000..08956c1cbe --- /dev/null +++ b/spec/system/scholarship_decline_amount_guard_spec.rb @@ -0,0 +1,55 @@ +require "rails_helper" + +# The admin edit form warns before saving an amount change on a scholarship the +# recipient has declined, because changing the amount re-offers the award and +# clears the recorded decline (date + reason) and re-funds the allocation. +RSpec.describe "Scholarship decline amount-change warning", type: :system do + let(:admin) { create(:user, :admin) } + let!(:admin_person) { create(:person, user: admin) } + let(:event) { create(:event, cost_cents: 10_000) } + let(:registration) { create(:event_registration, event:) } + let(:scholarship) { create(:scholarship, recipient: registration.registrant, amount_cents: 5_000) } + let!(:allocation) { create(:allocation, source: scholarship, allocatable: registration, amount: 5_000) } + + before do + driven_by(:selenium_chrome_headless) + scholarship.reload.decline_agreement!("Timing no longer works") + sign_in admin + end + + it "warns and, when confirmed, saves the new amount and clears the decline" do + visit edit_scholarship_path(scholarship) + fill_in "scholarship_amount_dollars", with: "75" + + accept_confirm(/declined the scholarship/) do + find("[type='submit']").click + end + + expect(page).to have_text("Scholarship updated.", wait: 10) + scholarship.reload + expect(scholarship.amount_cents).to eq(7_500) + expect(scholarship.agreement_declined?).to be(false) + end + + it "cancels the save when the warning is dismissed, preserving the decline" do + visit edit_scholarship_path(scholarship) + fill_in "scholarship_amount_dollars", with: "75" + + dismiss_confirm(/declined the scholarship/) do + find("[type='submit']").click + end + + expect(page).to have_css("[type='submit']", wait: 5) # still on the edit form + scholarship.reload + expect(scholarship.amount_cents).to eq(5_000) + expect(scholarship.agreement_declined?).to be(true) + end + + it "does not warn when the amount is left unchanged" do + visit edit_scholarship_path(scholarship) + find("[type='submit']").click + + expect(page).to have_text("Scholarship updated.", wait: 10) + expect(scholarship.reload.agreement_declined?).to be(true) + end +end From b2807cffe60c73c9a01d8852bf4a083d5703ae36 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Thu, 13 Aug 2026 05:53:09 -0400 Subject: [PATCH 03/10] Model agreement as a status with response history Replace the two mutually-exclusive agreement timestamps with a single agreement_response_status (pending/accepted/declined) + responded_at + reason, so the states can't contradict and reinstating is a one-field transition. Add a ScholarshipAgreementResponse audit log capturing each accept/decline/re-offer, shown as a collapsible timeline on the edit page. Signing a declined award now cleanly reinstates it; the decline guard warns before either an amount change or a sign clears a decline. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 5 +- app/controllers/events/callouts_controller.rb | 7 +- .../scholarship_decline_guard_controller.js | 30 +++--- app/models/event_registration.rb | 2 +- app/models/grant.rb | 2 +- app/models/scholarship.rb | 95 +++++++++++++------ app/models/scholarship_agreement_response.rb | 16 ++++ .../events/callouts/scholarship.html.erb | 4 +- .../scholarships/_agreement_history.html.erb | 31 ++++++ app/views/scholarships/_form.html.erb | 8 +- app/views/scholarships/edit.html.erb | 2 + app/views/scholarships/show.html.erb | 12 ++- config/brakeman.ignore | 23 ----- ...4_add_agreement_decline_to_scholarships.rb | 11 --- ...reement_timestamps_with_response_status.rb | 54 +++++++++++ ..._create_scholarship_agreement_responses.rb | 30 ++++++ db/schema.rb | 21 +++- .../scholarship_agreement_responses.rb | 10 ++ spec/models/event_registration_spec.rb | 4 +- .../scholarship_agreement_response_spec.rb | 30 ++++++ spec/models/scholarship_spec.rb | 86 +++++++++++------ spec/requests/events/callouts_spec.rb | 4 +- .../previews/notification_mailer_preview.rb | 4 +- 23 files changed, 357 insertions(+), 134 deletions(-) create mode 100644 app/models/scholarship_agreement_response.rb create mode 100644 app/views/scholarships/_agreement_history.html.erb delete mode 100644 db/migrate/20260812155344_add_agreement_decline_to_scholarships.rb create mode 100644 db/migrate/20260813094407_replace_scholarship_agreement_timestamps_with_response_status.rb create mode 100644 db/migrate/20260813094408_create_scholarship_agreement_responses.rb create mode 100644 spec/factories/scholarship_agreement_responses.rb create mode 100644 spec/models/scholarship_agreement_response_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 526a5cbc6f..e195f84992 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 | | `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 94900e1996..d932b4dddc 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,8 +65,7 @@ def sign_agreement end if params[:agreement] == "yes" - # Agreeing clears any prior decline — the two states are mutually exclusive. - scholarship.update!(agreement_signed: true, agreement_declined_at: nil, agreement_declined_reason: nil) 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." diff --git a/app/frontend/javascript/controllers/scholarship_decline_guard_controller.js b/app/frontend/javascript/controllers/scholarship_decline_guard_controller.js index 9b1741cdd0..05b36fad5b 100644 --- a/app/frontend/javascript/controllers/scholarship_decline_guard_controller.js +++ b/app/frontend/javascript/controllers/scholarship_decline_guard_controller.js @@ -2,18 +2,19 @@ import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="scholarship-decline-guard" // -// Warns before saving when an admin changes the award amount on a scholarship -// the recipient has DECLINED. Editing the amount re-offers the award, which -// clears the recorded decline (its date and reason) and re-funds the zeroed -// allocation — so the admin confirms before that decline data is discarded. +// Warns before saving a change that would silently reverse a recipient's +// DECLINE on the admin edit form. Two such changes clear the decline and +// re-activate the award: editing the amount (a re-offer) and ticking the +// "Agreement signed" toggle. Either way the recorded decline (date + reason) +// is discarded, so the admin confirms first. // // Attach to the . Only guards when the declined value is true; snapshots -// the amount input(s) on connect and compares them on submit. There are two -// amount inputs in the form (one per layout), only one of which renders, so it -// tracks all of them. +// the amount input(s) on connect and compares them (plus the signed checkbox +// state) on submit. There are two amount inputs (one per layout), only one of +// which renders, so it tracks all of them. export default class extends Controller { static values = { declined: Boolean }; - static targets = ["amount"]; + static targets = ["amount", "signed"]; connect() { this.originalAmounts = this.amountTargets.map((input) => input.value); @@ -27,12 +28,13 @@ export default class extends Controller { } guardSubmit(event) { - if (!this.declinedValue || !this.amountChanged()) return; + if (!this.declinedValue) return; + if (!this.amountChanged() && !this.markingSigned()) return; const message = - "This recipient declined the scholarship. Changing the amount re-offers " + - "the award and will clear their decline — the recorded date and reason — " + - "and re-fund the allocation.\n\nAre you sure you want to save this change?"; + "This recipient declined the scholarship. Saving this change will clear " + + "their decline — the recorded date and reason — and re-activate the award.\n\n" + + "Are you sure you want to save this change?"; if (!window.confirm(message)) { event.preventDefault(); @@ -43,4 +45,8 @@ export default class extends Controller { amountChanged() { return this.amountTargets.some((input, i) => input.value !== this.originalAmounts[i]); } + + markingSigned() { + return this.hasSignedTarget && this.signedTarget.checked; + } } diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 900705e7e3..936f8884a4 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 4d641c2114..4ee0032708 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 AND scholarships.agreement_declined_at IS NULL), 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). diff --git a/app/models/scholarship.rb b/app/models/scholarship.rb index b26ea4a8b5..ae718c70a2 100644 --- a/app/models/scholarship.rb +++ b/app/models/scholarship.rb @@ -4,28 +4,37 @@ 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 && !agreement_declined? } - after_update :sync_allocation_amount, if: -> { saved_change_to_amount_cents? } - # Changing the award amount re-offers the scholarship, so a prior decline is - # cleared — the recipient decides afresh on the new amount (and sync_allocation_amount - # above re-funds the allocation the decline had zeroed). - after_update :reset_decline_on_amount_change, if: -> { saved_change_to_amount_cents? && agreement_declined? } + # Changing the award amount on a declined scholarship re-offers it: back to + # pending in the same save, so the recipient decides afresh on the new amount. + before_update :reoffer_declined_on_amount_change, if: -> { will_save_change_to_amount_cents? && 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_declined, -> { where.not(agreement_declined_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(agreement_declined_at: nil) } + 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 @@ -60,29 +69,39 @@ 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 - def agreement_declined? = agreement_declined_at.present? + # 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? - # The recipient declining stamps the time + their reason and clears any signed - # state (signed and declined are mutually exclusive), and zeroes the allocation - # so the award stops counting in every allocation-based total (registration - # balances, dashboards, grant budgets). The row is kept for history. - def decline_agreement!(reason) - transaction do - update!(agreement_declined_at: Time.current, agreement_declined_reason: reason.presence, agreement_signed_at: nil) - allocation&.update!(amount: 0) - end + 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 # The event this scholarship was awarded at, via its allocation's registration @@ -139,15 +158,35 @@ def recipient_must_match_allocation_registrant end end + # Assign the new agreement state in memory (persisted by the caller's save). + # `by` is stashed for the history row the after_update callback writes. + def assign_agreement_response(status, reason: nil, by: "admin") + self.agreement_response_status = status + self.agreement_responded_at = Time.current + self.agreement_response_reason = (status == "declined" ? reason.presence : nil) + @agreement_response_by = by + end + + def reoffer_declined_on_amount_change + assign_agreement_response("pending", by: "admin") + 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 - # update_columns so this second write doesn't re-enter the after_update chain. - def reset_decline_on_amount_change - update_columns(agreement_declined_at: nil, agreement_declined_reason: nil) + def log_agreement_response + agreement_responses.create!( + status: agreement_response_status, + reason: agreement_response_reason, + responded_at: agreement_responded_at || Time.current, + responder: @agreement_response_by.presence || "admin", + amount_cents: amount_cents + ) + @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 0000000000..e7837220fa --- /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/views/events/callouts/scholarship.html.erb b/app/views/events/callouts/scholarship.html.erb index ade63eaef4..3817c3b294 100644 --- a/app/views/events/callouts/scholarship.html.erb +++ b/app/views/events/callouts/scholarship.html.erb @@ -66,12 +66,12 @@ <% if @scholarship.agreement_signed? %>

- Agreement signed<% if @scholarship.agreement_signed_at %> · <%= @scholarship.agreement_signed_at.strftime("%B %-d, %Y") %><% end %> + Agreement signed<% if @scholarship.agreement_responded_at %> · <%= @scholarship.agreement_responded_at.strftime("%B %-d, %Y") %><% end %>

<% elsif @scholarship.agreement_declined? %>

- You declined this scholarship<% if @scholarship.agreement_declined_at %> · <%= @scholarship.agreement_declined_at.strftime("%B %-d, %Y") %><% end %> + You declined this scholarship<% if @scholarship.agreement_responded_at %> · <%= @scholarship.agreement_responded_at.strftime("%B %-d, %Y") %><% end %>

We've let the team know. If you'd like to reconsider, please contact us.

<% else %> diff --git a/app/views/scholarships/_agreement_history.html.erb b/app/views/scholarships/_agreement_history.html.erb new file mode 100644 index 0000000000..39b2ccd6ba --- /dev/null +++ b/app/views/scholarships/_agreement_history.html.erb @@ -0,0 +1,31 @@ +<%# 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. %> +<% 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 + <%= 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 e1ff4a903e..e2a69644d1 100644 --- a/app/views/scholarships/_form.html.erb +++ b/app/views/scholarships/_form.html.erb @@ -91,7 +91,7 @@

Signed agreement on file from the recipient