Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
44cd065
Add scholarship decline flow with admin email and totals exclusion
maebeale Aug 12, 2026
7a4f629
Confirm before an amount change clears a scholarship decline
maebeale Aug 12, 2026
34ee56b
Model agreement as a status with response history
maebeale Aug 13, 2026
4308d38
Derive agreement date/reason from response log; link decline FYI
maebeale Aug 13, 2026
b6495f2
Remove scholarship decline-guard JS
maebeale Aug 13, 2026
6cb9d23
Replace implicit re-offer with an explicit Re-offer button
maebeale Aug 13, 2026
cfd258d
Confirm signed agreement to recipient + FYI; thread contact-us to reg…
maebeale Aug 13, 2026
4b76754
Consolidate scholarship agreement migrations
maebeale Aug 13, 2026
2632306
Drop migration DML; show admin-only agreement history on the callout
maebeale Aug 13, 2026
8004635
Split automated emails out to a separate PR
maebeale Aug 13, 2026
4d6d499
Trim comments to essentials
maebeale Aug 16, 2026
c5cff71
Keep a declined award visible where the decision matters
maebeale Aug 16, 2026
9eb535a
Adopt main's Tailwind conventions on the touched views
maebeale Aug 17, 2026
7382978
Refresh the AGENTS.md directory counts
maebeale Aug 17, 2026
9148c99
Keep the signed date when an award is created already accepted
maebeale Aug 18, 2026
bb466fd
Count declined awards out of the attendees breakdowns too
maebeale Aug 18, 2026
b927226
Render every agreement pill from one decorator method
maebeale Aug 18, 2026
415c66c
Make answering a decline the admin's next step
maebeale Aug 18, 2026
db92ca1
Price the accept/decline choice on the recipient's page
maebeale Aug 18, 2026
2f2bd3d
Seed the decline flow into features & tips
maebeale Aug 18, 2026
f114ed1
Refresh the AGENTS.md directory counts
maebeale Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,21 @@ This codebase (Rails 8.1)

| Directory | Purpose | Count |
|---|---|---|
| `app/models/` | ActiveRecord models | ~80 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~66 files |
| `app/jobs/` | SolidQueue background jobs | 5 files |
| `app/models/concerns/` | Shared model modules | 16 concerns |
| `app/models/` | ActiveRecord models | ~90 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for WordPress CSV import) | ~69 files |
| `app/jobs/` | SolidQueue background jobs | 6 files |
| `app/models/concerns/` | Shared model modules | 17 concerns |

### Presentation

| Directory | Purpose | Count |
|---|---|---|
| `app/controllers/` | Rails controllers (admin/, events/, home/) | ~91 files |
| `app/views/` | ERB templates | ~745 files |
| `app/decorators/` | Draper decorators for view logic | ~40 files |
| `app/policies/` | ActionPolicy authorization rules | ~55 files |
| `app/views/` | ERB templates | ~824 files |
| `app/decorators/` | Draper decorators for view logic | ~50 files |
| `app/policies/` | ActionPolicy authorization rules | ~63 files |
| `app/presenters/` | Presentation objects | 6 files |
| `app/helpers/` | View helpers | ~31 files |
| `app/helpers/` | View helpers | ~36 files |
| `app/mailers/` | ActionMailer classes | 5 files |
| `app/inputs/` | Custom SimpleForm inputs | 1 file |

Expand Down Expand Up @@ -106,7 +106,8 @@ This codebase (Rails 8.1)
| `OtherResponse` | A free-text "Other" typed on a form question, captured at submission time (registration, scholarship, bulk payment). Polymorphic `owner`: a **sector** "Other" is owned by the `Person` (promotable into a `Sector`, shown on their profile/edit chip); an **organization_type** "Other" is owned by the `Organization` (stored now, not promotable until `OrganizationType` is a model). `generic` questions aren't captured β€” that stays searchable in the form answers. `field_identifier` records the question; `kind` is derived. Curated at `/other_responses` (grouped by kind/question): `promote` (sectors only), `keep`, `dismiss`. `dismissed` hides the chip from the profile but stays in the review queue (still promotable later); only `promoted` leaves the queue. Admins deep-link there from a person's chip. |
| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage |
| `Grant` | Funds (polymorphic `funder`: Organization or Person) with eligibility criteria, tasks, deadlines; parent of `Scholarship`. Scholarship totals cannot exceed the grant amount |
| `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation` |
| `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation`. Tri-state `agreement_response_status` (pending/accepted/declined) drives the agreement; declined awards zero their allocation and drop out of all totals |
| `ScholarshipAgreementResponse` | Append-only history of a scholarship's accept ↔ decline back-and-forth (status, reason, responder, amount at the time); the scholarship's `agreement_response_status` is the denormalized latest row, and `responded_at`/reason are read from the latest response, not stored on the scholarship |
| `ProfessionalLicense` | A license a `Person` holds (`number`, `kind`, `issuing_state`, `expires_on`); a null `number` is a placeholder. `find_or_create_for` keeps one license per (person, number) |
| `ContinuingEducationRegistration` | A registrant's CE for one event against one `ProfessionalLicense`; billable `allocatable` (`Registerable`) with stored `hours` + `cost_cents` (default from the event). Payment is computed (no stored status); the certificate is delivered via `certificate_sent_at` and gated by its own `certificate_available?` |
| `TopicSubscription` | A `Person`'s standing subscription to a `TopicSubscriptionType`, optionally narrowed to a specific `interested_event` (null = the topic broadly). State is timestamp-driven (`unsubscribed_at IS NULL` = active β€” `active?`/`unsubscribe!`/`resubscribe` β€” non-bang, since reviving can collide with a newer active row, no status column); `subscribed_at` + `source` mirror the `mailing_list_consent_*` provenance pattern. Distinct from the `mailing_list_consent_*` flag (consent = "you may email me"; subscription = "what I want to hear about") and from an `EventRegistration` (an actual enrollment). One active subscription per (person, type, event) |
Expand Down
25 changes: 21 additions & 4 deletions app/controllers/events/callouts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,7 @@ def scholarship
@form_responses_available = @event.registration_form&.form_submissions&.exists?(person: @event_registration.registrant)
end

# Records the recipient agreeing, from their scholarship page, to complete the
# scholarship's tasks. The Agree button submits agreement=yes, which stamps
# agreement_signed_at via the model.
# The Agree button (agreement=yes) records an "accepted" response.
def sign_agreement
scholarship = @event_registration.scholarships.first
unless scholarship
Expand All @@ -65,13 +63,32 @@ def sign_agreement
end

if params[:agreement] == "yes"
scholarship.update!(agreement_signed: true) unless scholarship.agreement_signed?
scholarship.accept_agreement!(by: "recipient")
redirect_to registration_scholarship_path(@event_registration.slug), notice: "Thanks β€” your agreement has been recorded."
else
redirect_to registration_scholarship_path(@event_registration.slug), alert: "Something went wrong recording your agreement. Please try again."
end
end

# Record the recipient's decline (drops the award from all totals). A repeat
# decline is a no-op.
def decline_agreement
scholarship = @event_registration.scholarships.first
unless scholarship
redirect_to registration_scholarship_path(@event_registration.slug)
return
end

if scholarship.agreement_declined?
redirect_to registration_scholarship_path(@event_registration.slug), notice: "You've already declined this scholarship. Contact us if you'd like to reconsider."
return
end

scholarship.decline_agreement!(params[:decline_reason].to_s.strip)

redirect_to registration_scholarship_path(@event_registration.slug), notice: "Thanks for letting us know β€” the team will follow up with you."
end

# CE hours status: hours, amount owed, and license number. The heading and the
# requirements copy live on the materialized ce_hours callout row now.
def ce
Expand Down
19 changes: 15 additions & 4 deletions app/controllers/scholarships_controller.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
class ScholarshipsController < ApplicationController
before_action :set_scholarship, only: [ :show, :edit, :update, :destroy, :toggle_tasks ]
before_action :set_scholarship, only: [ :show, :edit, :update, :destroy, :toggle_tasks, :reoffer ]
before_action :set_grant, only: [ :new, :create ]

def index
authorize! Scholarship
set_report_filter_state
scholarships = filtered_scholarships
@funder_groups = ScholarshipsGrouping.new(scholarships).funder_groups
@scholarships_count = scholarships.size
grouping = ScholarshipsGrouping.new(filtered_scholarships)
@funder_groups = grouping.funder_groups
@scholarships_count = grouping.total_count
@scholarship_report = EventScholarshipReport.new(report_training_events, featured_year: @selected_year, funder: @filter_funder)
end

Expand Down Expand Up @@ -108,6 +108,17 @@ def toggle_tasks
end
end

# Re-offer a declined award: back to pending, allocation re-funded.
def reoffer
authorize! @scholarship, to: :update?
@scholarship.reoffer_agreement!(by: "admin")
redirect_to edit_scholarship_path(@scholarship, return_to: params[:return_to].presence, participant: params[:participant].presence),
notice: "Scholarship re-offered β€” awaiting the recipient's response."
rescue ActiveRecord::RecordInvalid => e
redirect_to edit_scholarship_path(@scholarship, return_to: params[:return_to].presence, participant: params[:participant].presence),
alert: e.record.errors.full_messages.to_sentence.presence || "Couldn't re-offer this scholarship."
end

private

# Filter state for the shared report filter partials (time period, event,
Expand Down
4 changes: 2 additions & 2 deletions app/decorators/grant_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,11 @@ def remaining_percentage
# completed/total. .size / Enumerable count use the preloaded association
# (index eager-loads :scholarships) so these add no per-row queries.
def scholarships_count
object.scholarships.size
object.scholarships.reject(&:agreement_declined?).size
end

def completed_scholarships_count
object.scholarships.count(&:tasks_completed?)
object.scholarships.reject(&:agreement_declined?).count(&:tasks_completed?)
end

# Where the index "Scholarships" count links. When every event-funded
Expand Down
39 changes: 39 additions & 0 deletions app/decorators/scholarship_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,43 @@ def tasks_completed?
def agreement_signed?
object.agreement_signed?
end

def agreement_declined?
object.agreement_declined?
end

AGREEMENT_STATUS_LABELS = {
"declined" => "Declined",
"accepted" => "Signed",
"pending" => "Pending"
}.freeze

AGREEMENT_STATUS_CLASSES = {
"declined" => "bg-red-50 text-red-700 border-red-200",
"accepted" => "bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200",
"pending" => "bg-amber-50 text-amber-700 border-amber-200"
}.freeze

AGREEMENT_STATUS_ICONS = {
"declined" => "fa-solid fa-circle-xmark",
"accepted" => "fa-solid fa-file-signature",
"pending" => "fa-solid fa-file-signature"
}.freeze

def agreement_status_label = AGREEMENT_STATUS_LABELS.fetch(object.agreement_response_status)
def agreement_status_classes = AGREEMENT_STATUS_CLASSES.fetch(object.agreement_response_status)
def agreement_status_icon = AGREEMENT_STATUS_ICONS.fetch(object.agreement_response_status)

# The agreement-status pill every surface that lists a scholarship renders, so
# the three states read the same everywhere: Declined (red), Signed (fuchsia),
# Pending (amber). Compact surfaces only need to flag the exception, so
# pending/signed render nothing unless `all_states:`. `prefix:` reads it as
# "Agreement declined" where the pill sits next to a tasks pill.
def agreement_status_badge(all_states: false, prefix: false, icon_size: "text-xs")
return unless all_states || object.agreement_declined?

label = prefix ? "Agreement #{agreement_status_label.downcase}" : agreement_status_label
h.render "shared/badge", label: label, classes: agreement_status_classes,
icon: [ agreement_status_icon, icon_size ].compact_blank.join(" ")
end
end
7 changes: 7 additions & 0 deletions app/models/concerns/registerable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ def remaining_cost
[ cost_cents.to_i - allocations_sum, 0 ].max
end

# What would still be owed if one source's allocation were taken away β€” e.g. a
# scholarship the recipient hasn't accepted yet, which is already allocated.
def remaining_cost_without(source)
withdrawn = allocations.to_a.select { |a| a.source == source }.sum(&:amount)
[ cost_cents.to_i - (allocations_sum - withdrawn), 0 ].max
end

# A free (or zero-cost) registration is paid by definition.
def paid_in_full?
return true if cost_cents.to_i <= 0
Expand Down
13 changes: 10 additions & 3 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ class EventRegistration < ApplicationRecord
WHERE allocations.allocatable_type = 'EventRegistration'
AND allocations.allocatable_id = event_registrations.id
AND allocations.source_type = 'Scholarship'
AND scholarships.agreement_signed_at IS NOT NULL
AND scholarships.agreement_response_status = 'accepted'
)
SQL
}
Expand Down Expand Up @@ -622,9 +622,16 @@ def registration_subject_noun
scholarship_requested? ? "event scholarship registration" : "event registration"
end

# A declined award carries no tasks, so it can't hold the certificate or the
# readiness checklist open.
def scholarship_tasks_met?
return true if scholarships.empty?
scholarships.all?(&:tasks_completed?)
live = scholarships.reject(&:agreement_declined?)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Worth knowing this reaches past the readiness checklist: scholarship_tasks_met? also gates certificate_available? and the CE certificate callout, so a registrant who declined and attended can now get their certificate instead of being held on tasks they never took on. That looked right, but it is a behaviour change in files this diff does not touch.

return true if live.empty?
live.all?(&:tasks_completed?)
end

def scholarship_declined?
scholarships.any?(&:agreement_declined?)
end

# Display-only: a scholarship is only *shown* as awarded once the recipient has
Expand Down
12 changes: 6 additions & 6 deletions app/models/grant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def self.self_funded_ids
# funds scopes so they stay flat WHERE clauses β€” no GROUP BY/HAVING, which would
# break will_paginate's total_entries count on the paginated index.
ALLOCATED_CENTS_SUBQUERY =
"COALESCE((SELECT SUM(scholarships.amount_cents) FROM scholarships WHERE scholarships.grant_id = grants.id), 0)".freeze
"COALESCE((SELECT SUM(scholarships.amount_cents) FROM scholarships WHERE scholarships.grant_id = grants.id AND scholarships.agreement_response_status <> 'declined'), 0)".freeze

# Grants that still have unallocated funds (grant amount exceeds the sum of
# scholarships drawn against them).
Expand All @@ -71,11 +71,11 @@ def self.self_funded_ids
# exclude grant-less scholarships (grant_id IS NULL) β€” a stray NULL in the
# NOT IN set below would otherwise make all_tasks_completed match nothing.
scope :tasks_outstanding, -> {
where(id: Scholarship.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id))
where(id: Scholarship.not_declined.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id))
}
scope :all_tasks_completed, -> {
where(id: Scholarship.where.not(grant_id: nil).select(:grant_id))
.where.not(id: Scholarship.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id))
where(id: Scholarship.not_declined.where.not(grant_id: nil).select(:grant_id))
.where.not(id: Scholarship.not_declined.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id))
}

# Grants offered in a scholarship's "Funded by grant" picker: every grant with
Expand Down Expand Up @@ -126,9 +126,9 @@ def name_with_funder
# association in memory when present (the index eager-loads :scholarships) to
# avoid a per-row SQL SUM; otherwise issues a single aggregate query.
def scholarships_total_cents
return scholarships.sum { |s| s.amount_cents.to_i } if scholarships.loaded?
return scholarships.reject(&:agreement_declined?).sum { |s| s.amount_cents.to_i } if scholarships.loaded?

scholarships.sum(:amount_cents)
scholarships.not_declined.sum(:amount_cents)
end

def remaining_cents
Expand Down
Loading