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. %>
<%= @scholarship.agreement_signed? ? "Amount awarded" : "Amount offered" %>
<%= dollars_from_cents(@scholarship.amount_cents) %>
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 %> +- 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. %> +“<%= response.reason %>”
+ <% end %> +Scholarship agreement
-Signed agreement on file from the recipient
+Scholarship agreement
+Signed agreement on file from the recipient
+Declined by recipient<% if declined_response&.responded_at %> · <%= declined_response.responded_at.strftime("%B %-d, %Y") %><% end %>
+ <% if declined_response&.reason.present? %> +“<%= declined_response.reason %>”
+ <% end %> +This award isn't counted in any totals while declined.
+To re-offer at new terms, change the amount and save first — then click Re-offer.
+“<%= latest_response.reason %>”
+ <% end %> +