diff --git a/AGENTS.md b/AGENTS.md index 7117b9b6d8..5417da04a8 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 64353b4487..a48c55b330 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,45 @@ def sign_agreement end if params[:agreement] == "yes" - scholarship.update!(agreement_signed: true) unless scholarship.agreement_signed? + newly_signed = !scholarship.agreement_signed? + scholarship.accept_agreement!(by: "recipient") + notify_scholarship_agreement_signed(scholarship) if newly_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 @@ -189,6 +221,29 @@ def faq private + # On the recipient signing: confirm to them (with a link back to their ticket) + # and send the team an FYI. + def notify_scholarship_agreement_signed(scholarship) + recipient_email = scholarship.recipient&.preferred_email + if recipient_email.present? + NotificationServices::CreateNotification.call( + noticeable: scholarship, + kind: :scholarship_agreement_signed, + recipient_role: :person, + recipient_email: recipient_email, + notification_type: 0 + ) + end + + NotificationServices::CreateNotification.call( + noticeable: scholarship, + kind: :scholarship_agreement_signed_fyi, + recipient_role: :admin, + recipient_email: ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org"), + notification_type: 0 + ) + end + # Whether the event's built-in callout for this key is materialized and # published (visible). These public pages gate on that alone now — the admin's # published/hidden choice on the row decides whether the page is reachable, so diff --git a/app/controllers/scholarships_controller.rb b/app/controllers/scholarships_controller.rb index 0db5478d21..b532e6ad60 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 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..ed131b3461 100644 --- a/app/jobs/notification_mailer_job.rb +++ b/app/jobs/notification_mailer_job.rb @@ -17,7 +17,10 @@ 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_signed" => ->(n) { NotificationMailer.scholarship_agreement_signed(n) }, + "scholarship_agreement_signed_fyi" => ->(n) { NotificationMailer.scholarship_agreement_signed_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/contact_us_mailer.rb b/app/mailers/contact_us_mailer.rb index 94e6cf109d..a016f05248 100644 --- a/app/mailers/contact_us_mailer.rb +++ b/app/mailers/contact_us_mailer.rb @@ -3,6 +3,10 @@ def hello(contact_us, user = nil) @contact_us = contact_us @user = user + # When the message came from a scholarship page, link the team to that + # registration (threaded through as a slug on the form). + @registration = EventRegistration.find_by(slug: contact_us[:registration_id]) if contact_us[:registration_id].present? + @mail_to = ENV.fetch("REPLY_TO_EMAIL", "programs@awbw.org") sender_name = if user.present? diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 4a50c43b19..6b740a6c22 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -146,6 +146,43 @@ def workshop_log_submitted_fyi(notification) ) end + def scholarship_agreement_signed(notification) + @scholarship = notification.noticeable + @person = @scholarship.recipient + @event = @scholarship.event&.decorate + registration = @scholarship.event_registration + @ticket_url = registration_ticket_url(registration.slug) if registration + @notification_type = "Scholarship agreement confirmation" + + mail( + to: notification.recipient_email, + subject: "#{SUBJECT_PREFIX} Your scholarship agreement is confirmed" + ) + end + + def scholarship_agreement_signed_fyi(notification) + @scholarship = notification.noticeable + @person = @scholarship.recipient + @event = @scholarship.event&.decorate + @notification_type = "Scholarship agreement signed" + + mail( + subject: "#{FYI_PREFIX} Scholarship agreement signed by #{@person&.full_name}" + ) + 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/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 77f7f65352..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), 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/notification.rb b/app/models/notification.rb index 1eae8911d6..9256719f1c 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -36,6 +36,10 @@ class Notification < ApplicationRecord workshop_log_submitted workshop_log_submitted_fyi + scholarship_agreement_signed + scholarship_agreement_signed_fyi + scholarship_agreement_declined_fyi + manual_log ].freeze @@ -62,6 +66,7 @@ class Notification < ApplicationRecord FormSubmission Person Report + Scholarship StoryIdea User WorkshopLog @@ -80,6 +85,9 @@ 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 agreement signed", "Scholarship agreement signed" ], + [ "Admin FYI: scholarship declined", "Scholarship declined" ], + [ "Scholarship: agreement confirmation", "scholarship agreement is confirmed" ], [ "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..182820ab27 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 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/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/contact_us/index.html.erb b/app/views/contact_us/index.html.erb index b0caf4b8fe..0fe706d5f9 100644 --- a/app/views/contact_us/index.html.erb +++ b/app/views/contact_us/index.html.erb @@ -122,6 +122,11 @@ <% if @from_story_share %> <% end %> + <%# Carries the scholarship registration through to the FYI so the team + can jump to the registration the message is about. %> + <% if params[:registration_id].present? %> + + <% end %> <% unless current_user %>
+ Sent from the scholarship page for + <%= @registration.event&.title %> + (<%= @registration.registrant&.full_name %>). +
+ + View registration + +Submitted on 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. %>
<%= @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 <%= link_to "contact us", contact_us_path(registration_id: @event_registration.slug), class: "font-medium text-red-700 hover:underline" %>.
<% 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. %> ++ Scholarship declined +
+ ++ Declined on + <%= Time.current + .in_time_zone("Pacific Time (US & Canada)") + .strftime("%B %-d, %Y at %-l:%M %p %Z") %> +
+ ++ for <%= @event.title %> +
+ <% end %> ++ Reason given +
+ <% if @reason.present? %> +<%= @reason %>
+ <% else %> +No reason was provided.
+ <% end %> ++ Thank you<% if @person&.first_name.present? %>, <%= @person.first_name %><% end %> — we've recorded your agreement + to complete your scholarship tasks<% if @event.present? %> for <%= @event.title %><% end %>. +
+ +Award confirmed
+<%= dollars_from_cents(@scholarship.amount_cents) %>
++ Your ticket has your award details, tasks, and next steps. +
+<% end %> diff --git a/app/views/notification_mailer/scholarship_agreement_signed.text.erb b/app/views/notification_mailer/scholarship_agreement_signed.text.erb new file mode 100644 index 0000000000..9beefe79b4 --- /dev/null +++ b/app/views/notification_mailer/scholarship_agreement_signed.text.erb @@ -0,0 +1,10 @@ +Your scholarship agreement is confirmed +======================================= + +Thank you<% if @person&.first_name.present? %>, <%= @person.first_name %><% end %> — we've recorded your agreement to complete your scholarship tasks<% if @event.present? %> for <%= @event.title %><% end %>. + +Award confirmed: <%= dollars_from_cents(@scholarship.amount_cents) %> + +<% if @ticket_url.present? %>View your ticket (award details, tasks, and next steps): +<%= @ticket_url %> +<% end %> diff --git a/app/views/notification_mailer/scholarship_agreement_signed_fyi.html.erb b/app/views/notification_mailer/scholarship_agreement_signed_fyi.html.erb new file mode 100644 index 0000000000..64252dda25 --- /dev/null +++ b/app/views/notification_mailer/scholarship_agreement_signed_fyi.html.erb @@ -0,0 +1,36 @@ ++ Scholarship agreement signed +
+ ++ Signed on + <%= Time.current + .in_time_zone("Pacific Time (US & Canada)") + .strftime("%B %-d, %Y at %-l:%M %p %Z") %> +
+ ++ for <%= @event.title %> +
+ <% end %> +“<%= 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 %> +